diff --git a/build.rs b/build.rs index 6568079a..0f0bff81 100644 --- a/build.rs +++ b/build.rs @@ -1,12 +1,3 @@ -//! Build script — Windows-only: embed the app icon into the `.exe`. -//! -//! On Windows the taskbar / window / Explorer icon comes from an icon *resource* -//! compiled into the executable; there's no equivalent of macOS's `.app` bundle -//! (which gets its icon from `tty7.icns` via `.github/scripts/bundle.sh`). So we -//! compile `assets/favicon.ico` (a multi-res 16–256px ICO) into the binary here. -//! -//! On every other platform this is a no-op. - fn main() { #[cfg(windows)] { @@ -14,8 +5,6 @@ fn main() { let mut res = winresource::WindowsResource::new(); res.set_icon("assets/favicon.ico"); if let Err(e) = res.compile() { - // Don't fail the build just because the resource compiler is missing; - // the app still runs, it just falls back to the default Windows icon. println!("cargo:warning=failed to embed Windows icon: {e}"); } } diff --git a/crates/tty7-core/src/core/agent_hooks.rs b/crates/tty7-core/src/core/agent_hooks.rs index ce62a24e..f63c2fa8 100644 --- a/crates/tty7-core/src/core/agent_hooks.rs +++ b/crates/tty7-core/src/core/agent_hooks.rs @@ -1,32 +1,3 @@ -//! Agent-side hook integration: the emitter behind `tty7 agent-hook …` and the -//! per-agent installers that wire it into each CLI agent's own hook surface. -//! -//! The rich agent-status channel ([`crate::core::cli_agent`]) needs the agent -//! itself to say what it's doing. Each supported agent exposes that -//! differently — Claude Code and Codex take a declarative hooks map, Copilot -//! and Grok auto-load JSON hook files from a directory, OpenCode loads JS -//! plugins, Pi loads TS extensions — but every integration bottoms out in the -//! same tiny emitter: `tty7 agent-hook ` reads the hook's JSON -//! payload from stdin and writes one sentinel OSC 777 sequence to the controlling -//! terminal, where tty7's daemon-side sniffer picks it up and folds it into -//! the pane's session state. -//! -//! Emission is gated on the `TTY7` environment variable (injected into every -//! shell tty7 spawns), so hooks installed globally stay silent when an agent -//! runs in another terminal. -//! -//! # Installing on a machine that isn't this one -//! -//! An agent running in a remote workspace's pane is running on the *remote* -//! machine, reading that machine's `~/.claude/settings.json` and spawning -//! processes from that machine's disk. So every installer here takes a -//! [`HookTarget`] — the three facts that differ between machines: where `~` is, -//! which filesystem to write through, and which executable answers -//! `agent-hook`. Locally that is this binary; remotely it is the -//! `tty7-server-cp` the installer published there, which -//! carries the same emitter for exactly this reason -//! (`crates/tty7-server/src/main.rs`). - use std::io; use std::io::{IsTerminal as _, Read as _}; use std::path::{Path, PathBuf}; @@ -34,45 +5,18 @@ use std::path::{Path, PathBuf}; use crate::core::cli_agent::AGENT_EVENT_SENTINEL; use crate::host::Host; -/// The env var tty7 sets in every spawned shell; the hook emitter refuses to -/// write escape sequences into terminals that aren't tty7. pub const TTY7_ENV_MARKER: &str = "TTY7"; -/// Env var Grok Build's hook runner injects into every hook process it spawns. -/// Its presence identifies *who ran us*, which matters because grok also scans -/// `~/.claude/settings.json` for hooks (its Claude-compat layer) — see -/// [`run_agent_hook`]. const GROK_HOOK_ENV: &str = "GROK_HOOK_EVENT"; -/// Cap on how much hook stdin we'll read: real payloads are a few hundred -/// bytes of JSON; anything huge is not for us. const MAX_STDIN: u64 = 64 * 1024; -/// Entry point for the `tty7 agent-hook ` subcommand: read the -/// hook's JSON payload from stdin, build the sentinel event, and write it to -/// the controlling terminal. Always exits quietly — a hook that fails must -/// never break the agent's own flow (agents surface nonzero exits). pub fn run_agent_hook(agent: &str, event: &str) { - // Shed our own console before doing anything else. Debug builds are - // console-subsystem (so `println!` logging works while developing the GUI), - // so every hook process Claude Code spawns gets its *own* console window — - // a rash of terminal windows that flash open and vanish as each end-of-turn - // hook fires. We never use this console for I/O (stdin is piped and we write - // to the *agent's* console via AttachConsole), so freeing it now tears the - // window down before it can paint. No-op in release (GUI subsystem) and Unix. detach_console(); - // Not inside tty7 (or a remote shell): stay silent, so globally-installed - // hooks don't leak escape sequences into other terminals. if std::env::var_os(TTY7_ENV_MARKER).is_none() { return; } let agent = effective_agent(agent, std::env::var_os(GROK_HOOK_ENV).is_some()); - // Hook payload: the agent writes JSON ({"session_id": …, "message": …, …}) - // and closes stdin. Absent/malformed input still emits the bare event — - // the state machine works without ids or messages. A tty stdin means the - // spawner inherited the pane's terminal instead of piping a payload (e.g. - // OpenCode's plugin runner, issue #88); reading it would block forever on - // an EOF that never comes and swallow the user's keystrokes, so skip it. let mut input = String::new(); if !std::io::stdin().is_terminal() { let _ = std::io::stdin().take(MAX_STDIN).read_to_string(&mut input); @@ -83,18 +27,9 @@ pub fn run_agent_hook(agent: &str, event: &str) { write_to_controlling_tty(&build_hook_sequence(agent, event, &input)); } -/// Detach from — and, when we're the only process attached, destroy — the -/// calling process's console. On Windows debug builds each `tty7 agent-hook …` -/// process owns a throwaway console whose window would otherwise flash on -/// screen; freeing it before the window paints removes the flash. The emitter -/// re-attaches to the agent's console via `AttachConsole` when it writes, so -/// this doesn't cost us the output path. No-op where there's no console to free. #[cfg(not(unix))] fn detach_console() { use windows_sys::Win32::System::Console::FreeConsole; - // SAFETY: FreeConsole takes no arguments; it simply returns 0 when the - // process has no attached console (release/GUI builds) and is otherwise a - // clean detach. unsafe { FreeConsole(); } @@ -103,30 +38,10 @@ fn detach_console() { #[cfg(unix)] fn detach_console() {} -/// The agent slug an invocation really speaks for. Normally the one the -/// installed hook passed, but Grok Build reads `~/.claude/settings.json` as -/// well as its own hooks directory (a deliberate Claude-compat layer), so a -/// tty7 Claude Code integration also fires inside grok panes. Grok's hook -/// runner stamps every hook process with [`GROK_HOOK_ENV`], so those events are -/// relabeled to the agent that actually ran them — otherwise a grok pane -/// reports "Claude Code", and a user with both integrations installed emits -/// each turn under two identities instead of one deduplicated stream. fn effective_agent(agent: &str, ran_by_grok: bool) -> &str { if ran_by_grok { "grok" } else { agent } } -/// The sentinel event one hook invocation maps onto, or `None` to stay silent. -/// Most hooks pass their event through; the exceptions are the single catch-all -/// `notification` hook Copilot and Grok expose, which fires for *every* -/// notification type. Only the types that always mean a real block escalate to -/// `permission-request`; everything else is dropped rather than parroted as one. -/// -/// The two agents draw that line differently. Copilot's `permission_prompt` -/// only fires when it is actually asking, so it counts; grok dispatches the -/// same type *before* its permission system decides — on essentially every tool -/// call, auto-approved ones included — so only `elicitation_dialog` (grok -/// asking the user a question) survives there. Grok's completions and errors -/// (`task_complete`, `agent_error`) are never blocks for either. fn effective_event<'a>(agent: &str, event: &'a str, stdin_json: &str) -> Option<&'a str> { if matches!(agent, "copilot" | "grok") && event == "notification" { let blocks = stdin_json.contains("elicitation_dialog") @@ -136,10 +51,6 @@ fn effective_event<'a>(agent: &str, event: &'a str, stdin_json: &str) -> Option< Some(event) } -/// Build the sentinel OSC sequence for one hook invocation — the pure core of -/// [`run_agent_hook`], separated so the wire bytes are testable without a PTY. -/// Round-trips through [`crate::core::cli_agent::parse_agent_event`] on the -/// daemon side. fn build_hook_sequence(agent: &str, event: &str, stdin_json: &str) -> Vec { let payload: serde_json::Value = serde_json::from_str(stdin_json).unwrap_or(serde_json::json!({})); @@ -148,11 +59,6 @@ fn build_hook_sequence(agent: &str, event: &str, stdin_json: &str) -> Vec { "agent": agent, "event": event, }); - // The sentinel body is always snake_case, but the payloads are not: Claude - // Code & friends send `session_id`, while Grok Build's envelope is - // camelCase throughout (`sessionId`). Read both spellings of each field so - // one vendor's convention doesn't cost us the session id that `--resume` - // needs. for (key, alias) in [ ("session_id", "sessionId"), ("message", "message"), @@ -170,17 +76,6 @@ fn build_hook_sequence(agent: &str, event: &str, stdin_json: &str) -> Vec { format!("\x1b]777;notify;{AGENT_EVENT_SENTINEL};{body}\x07").into_bytes() } -/// Write raw bytes to the pane's PTY so the daemon's sniffer reads them as pane -/// output. Two routes, because agents run hooks differently: -/// -/// 1. `/dev/tty` — the hook's own controlling terminal. Works when the agent -/// runs the hook attached to its tty. -/// 2. An ancestor's tty device — Claude Code runs hooks *detached* from the -/// controlling terminal (they have no `/dev/tty`), but the agent process -/// itself still owns the pane's PTY slave. So walk up the parent chain to -/// the nearest process that has a real tty (that's the agent) and write its -/// device (`/dev/ttysNNN` on macOS, `/dev/pts/N` on Linux) directly. Writing -/// the slave sends output to the master, exactly like `/dev/tty` would. #[cfg(unix)] fn write_to_controlling_tty(bytes: &[u8]) -> bool { if write_dev(std::path::Path::new("/dev/tty"), bytes) { @@ -201,21 +96,14 @@ fn write_dev(path: &std::path::Path, bytes: &[u8]) -> bool { } } -/// The controlling-tty device of the nearest ancestor that has one — the agent -/// process, when it ran us detached. Walks the parent chain via `ps` (the hook -/// runs at most a few times per turn, so the process spawn is negligible and -/// beats platform-specific sysctl/`/proc` FFI here). #[cfg(unix)] fn ancestor_tty_device() -> Option { use std::process::Command; - // SAFETY: getppid is always safe and never fails. let mut pid = unsafe { libc::getppid() }; for _ in 0..8 { if pid <= 1 { break; } - // `tty=` prints the terminal (`ttys004`, `pts/3`, or `??`/empty for none) - // and `ppid=` the parent, both header-less so parsing is trivial. let out = Command::new("ps") .args(["-o", "tty=", "-o", "ppid=", "-p", &pid.to_string()]) .output() @@ -232,28 +120,11 @@ fn ancestor_tty_device() -> Option { None } -/// Windows: the hook has no `/dev/tty`. It runs as a descendant of the agent -/// inside tty7's ConPTY, but agents (Claude Code, a Node app) spawn hooks with -/// `CREATE_NO_WINDOW`, which gives the hook its *own hidden console* — so a -/// naive write to `CONOUT$` succeeds into a throwaway buffer that isn't the -/// pane's PTY, and nothing reaches the daemon. -/// -/// So mirror the Unix "write the agent's tty" strategy at the console layer: -/// walk up the parent chain and, for the nearest ancestor whose console we can -/// borrow, `FreeConsole` off our hidden one, `AttachConsole` to theirs (the -/// shell / agent are attached to tty7's ConPTY), and write `CONOUT$` there — -/// the OSC bytes then flow through ConPTY to the daemon, exactly like the -/// shell-integration marks. Best-effort: `false` if no ancestor console works. #[cfg(not(unix))] fn write_to_controlling_tty(bytes: &[u8]) -> bool { let procs = crate::daemon::winproc::snapshot(); let ancestors = ancestor_pids(&procs); - // The shell tty7 spawned is the process on the pane's ConPTY. Agents wrap - // hooks in extra hidden-console layers (Node's `shell:true` → a - // `cmd.exe` launched with `windowsHide`), so the *nearest* attachable - // console is a dead-end buffer. Identify the shell deterministically: the - // ancestor whose parent is the `tty7.exe` daemon. Attach to *that* console. let name_of = |pid: u32| { procs .iter() @@ -274,9 +145,6 @@ fn write_to_controlling_tty(bytes: &[u8]) -> bool { } } - // Fallback: no shell pinned down (nested/unusual tree) — spray every - // ancestor console. The ConPTY one gets the bytes; the hidden dead-ends - // swallow harmless duplicates. let mut any = false; for pid in ancestors { any |= attach_and_write(pid, bytes); @@ -284,14 +152,9 @@ fn write_to_controlling_tty(bytes: &[u8]) -> bool { any } -/// Detach from the current (possibly hidden) console, attach to `pid`'s console, -/// write `bytes` to its `CONOUT$`, then detach. Returns whether the write -/// itself succeeded. #[cfg(not(unix))] fn attach_and_write(pid: u32, bytes: &[u8]) -> bool { use windows_sys::Win32::System::Console::{AttachConsole, FreeConsole}; - // SAFETY: FreeConsole/AttachConsole take no memory and simply return 0 when - // there is nothing to detach / no attachable console for `pid`. unsafe { FreeConsole(); if AttachConsole(pid) == 0 { @@ -299,16 +162,12 @@ fn attach_and_write(pid: u32, bytes: &[u8]) -> bool { } } let ok = write_conout(bytes); - // SAFETY: leave no lingering attachment (the hook process exits right after). unsafe { FreeConsole(); } ok } -/// Open the currently-attached console's output buffer and write `bytes`. -/// `read(true).write(true)` is what the console driver expects for a `CONOUT$` -/// handle; the name is resolved by the Win32 layer regardless of cwd. #[cfg(not(unix))] fn write_conout(bytes: &[u8]) -> bool { use std::io::Write as _; @@ -322,9 +181,6 @@ fn write_conout(bytes: &[u8]) -> bool { } } -/// The hook's ancestor pids, nearest first, from the Windows process table — -/// the chain `agent-hook → agent → shell` up which one process owns tty7's -/// ConPTY console. Bounded walk; robust to pid-reuse cycles via a seen set. #[cfg(not(unix))] fn ancestor_pids(procs: &[crate::daemon::winproc::Proc]) -> Vec { let parent_of = |pid: u32| procs.iter().find(|p| p.pid == pid).map(|p| p.parent); @@ -344,31 +200,13 @@ fn ancestor_pids(procs: &[crate::daemon::winproc::Proc]) -> Vec { out } -// --------------------------------------------------------------------------- -// Integrations: one installer per agent that exposes a hook surface. -// --------------------------------------------------------------------------- - -/// The agents tty7 can wire its rich-status channel into. Each carries a -/// different install mechanism (see [`install_hooks`]); the emitter side is -/// identical for all of them. Gemini/Aider/… are recognized in the sidebar -/// but have no hook surface to install into, so they are not listed here. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum HookAgent { - /// Hooks map merged into `~/.claude/settings.json` (or - /// `$CLAUDE_CONFIG_DIR/settings.json`). Claude, - /// Hooks map merged into `~/.codex/hooks.json`, plus the one-time - /// `codex features enable hooks` feature flag. Codex, - /// A tty7-owned hook file in `~/.copilot/hooks/` (Copilot auto-loads - /// every JSON file there). Copilot, - /// A tty7-owned JS plugin in `~/.config/opencode/plugins/`. OpenCode, - /// A tty7-owned TS extension in `~/.pi/agent/extensions/tty7/`. Pi, - /// A tty7-owned hook file in `~/.grok/hooks/` (Grok Build loads every JSON - /// file there, and global hooks need no folder-trust grant). Grok, } @@ -382,8 +220,6 @@ impl HookAgent { HookAgent::Grok, ]; - /// The `agent` slug in hook commands and sentinel events — matches - /// [`crate::core::cli_agent::CLIAgent::slug`] so events brand the pane. pub fn slug(self) -> &'static str { match self { HookAgent::Claude => "claude", @@ -395,7 +231,6 @@ impl HookAgent { } } - /// User-facing name for the settings row. pub fn display_name(self) -> &'static str { match self { HookAgent::Claude => "Claude Code", @@ -407,16 +242,10 @@ impl HookAgent { } } - /// The file the integration installs into on `target`'s machine, - /// `~`-abbreviated for display. pub fn target_display(self, target: &HookTarget) -> String { target.abbreviate_home(&self.target_path(target)) } - /// The file this agent's integration lives in, on `target`'s machine. - /// - /// Built with [`Host::join`] rather than `PathBuf::join`: a Windows client - /// installing onto a Linux box would otherwise write `/home/me\.claude`. fn target_path(self, target: &HookTarget) -> PathBuf { match self { HookAgent::Claude => target.claude_settings_path(), @@ -431,30 +260,13 @@ impl HookAgent { } } - /// Substring that identifies a hook entry / owned file as tty7's, for - /// idempotent install/upgrade and ownership-guarded uninstall. Every - /// generated command and file embeds `agent-hook ` verbatim. fn marker(self) -> String { format!("agent-hook {}", self.slug()) } } -// --------------------------------------------------------------------------- -// The machine an integration is installed on. -// --------------------------------------------------------------------------- - -/// Cap on how much of an agent's config file we will read. Real ones are a few -/// kilobytes; the limit is enforced on the *host* (see [`Host::read_file`]), so -/// a pathological file never crosses the wire only to be discarded. const MAX_CONFIG_BYTES: u64 = 4 * 1024 * 1024; -/// Which machine an install acts on, and everything about it that differs from -/// every other machine: its filesystem, its `$HOME`, and the absolute path of -/// the binary its hooks will invoke. -/// -/// Borrowed rather than owning an `Arc` because the whole lifetime of -/// one of these is a single background task — the caller already holds the host -/// and is not free to call it from anywhere else anyway. pub struct HookTarget<'a> { host: &'a dyn Host, home: PathBuf, @@ -462,11 +274,6 @@ pub struct HookTarget<'a> { } impl<'a> HookTarget<'a> { - /// This computer: `$HOME` (or `%USERPROFILE%`) and the running binary. - /// - /// `None` when the home directory cannot be resolved — the one condition - /// under which there is no file to install into and nothing worth - /// guessing. pub fn local(host: &'a dyn Host) -> Option> { Some(HookTarget { host, @@ -475,19 +282,6 @@ impl<'a> HookTarget<'a> { }) } - /// A remote machine: the `$HOME` its handshake reported, and the - /// `tty7-server-cp` this client published into it. - /// - /// The *installed* binary, not the one the running daemon was launched - /// from. The two can differ — a user who kept an older daemon alive rather - /// than restart their sessions — and it doesn't matter here: the emitter is - /// a one-shot child of the agent that writes an escape sequence to its own - /// tty and exits. It never talks to the daemon, so the binary only has to - /// exist, and the one this client installed is the one it can prove does. - /// - /// Naming it stays a pure function of `home` because the name is built from - /// this client's own dialect numbers — nothing has to be asked of the remote - /// to know what tty7 called the file it put there. pub fn remote(host: &'a dyn Host, home: PathBuf) -> HookTarget<'a> { let dialect = crate::daemon::install::RemoteProtocol::of_this_build(); let binary = crate::daemon::install::asset::remote_paths( @@ -503,14 +297,10 @@ impl<'a> HookTarget<'a> { } } - /// Whether this is this computer. Gates the three things that are only true - /// here: our own environment variables, atomic writes, and running the - /// `codex` CLI. fn is_local(&self) -> bool { self.host.id().is_local() } - /// `base` + each of `parts`, in the host's own separator. fn under(&self, base: &Path, parts: &[&str]) -> PathBuf { let mut p = base.to_path_buf(); for part in parts { @@ -519,19 +309,10 @@ impl<'a> HookTarget<'a> { p } - /// [`under`](Self::under), rooted at the machine's home directory. fn under_home(&self, parts: &[&str]) -> PathBuf { self.under(&self.home, parts) } - /// Claude Code's user settings file: `$CLAUDE_CONFIG_DIR/settings.json`, - /// defaulting to `~/.claude/settings.json`. - /// - /// The override is honored **only on this computer**. `CLAUDE_CONFIG_DIR` - /// is read out of *our* process's environment, and the agent on the far end - /// reads its own login shell's — which we cannot see from here. Guessing - /// with this machine's value would install into a directory the remote - /// Claude never opens, and the row would then say "Installed" forever. fn claude_settings_path(&self) -> PathBuf { if self.is_local() && let Some(dir) = std::env::var_os("CLAUDE_CONFIG_DIR").filter(|d| !d.is_empty()) @@ -541,8 +322,6 @@ impl<'a> HookTarget<'a> { self.under_home(&[".claude", "settings.json"]) } - /// `$XDG_CONFIG_HOME`, defaulting to `~/.config` (OpenCode's config root). - /// Local-only override, for the reason in [`claude_settings_path`]. fn xdg_config_dir(&self) -> PathBuf { if self.is_local() && let Some(dir) = std::env::var_os("XDG_CONFIG_HOME").filter(|d| !d.is_empty()) @@ -552,11 +331,6 @@ impl<'a> HookTarget<'a> { self.under_home(&[".config"]) } - /// The hook command line written into an agent's config — the emitter's - /// binary by absolute path, so it works regardless of PATH. Quoted because - /// macOS app paths ("/Applications/…") can carry spaces. `event` is one of - /// tty7's kebab-case sentinel events, passed straight through by the - /// emitter. fn hook_command(&self, agent: HookAgent, event: &str) -> String { format!( "\"{}\" agent-hook {} {event}", @@ -565,20 +339,11 @@ impl<'a> HookTarget<'a> { ) } - /// Read a config file whole, as text. fn read(&self, p: &Path) -> io::Result { let bytes = self.host.read_file(p, MAX_CONFIG_BYTES)?; String::from_utf8(bytes).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e)) } - /// Write `bytes` to `p`, creating its parent chain first. - /// - /// Local writes stay [`crate::core::config::write_atomic`]: a hooks file - /// left truncated by a crash mid-save is an agent that refuses to start, - /// and that guarantee predates this function. There is no atomic form to - /// keep over the wire — [`Host::write_file`] truncates and writes, and - /// [`Host::rename`] refuses to overwrite — so a remote install carries the - /// same window every remote save in the app already carries. fn write(&self, p: &Path, bytes: &[u8]) -> anyhow::Result<()> { if let Some(parent) = p.parent() { self.host.create_dir(parent, true)?; @@ -591,7 +356,6 @@ impl<'a> HookTarget<'a> { Ok(()) } - /// Abbreviate the machine's home-directory prefix to `~` for display. fn abbreviate_home(&self, path: &Path) -> String { match path.strip_prefix(&self.home) { Ok(rest) => format!("~/{}", rest.display()), @@ -600,26 +364,13 @@ impl<'a> HookTarget<'a> { } } -/// Install state of one agent's tty7 hooks, as shown in Settings → Agents. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum HooksState { - /// No tty7 hook entry / file anywhere. NotInstalled, - /// The integration is present and points at this binary. Installed, - /// tty7 wrote it, but it points at a different tty7 binary (the app - /// moved/updated, or it was installed from another build) or is missing a - /// piece — the hooks fire into the wrong or a vanished executable. A - /// reinstall rewrites it in place. Outdated, } -/// Read one agent's install state off `target`'s machine. An unreadable or -/// malformed file reports `NotInstalled` — the same "nothing usable there" -/// answer the installer would start from. -/// -/// **Blocking**, and on a remote target that means a round trip per agent: call -/// it from a background task (`ui::host_ops::HostOps`), never from render. pub fn hooks_state(target: &HookTarget, agent: HookAgent) -> HooksState { let path = agent.target_path(target); match agent { @@ -634,11 +385,6 @@ pub fn hooks_state(target: &HookTarget, agent: HookAgent) -> HooksState { } } -/// Install (or rewrite in place) one agent's tty7 hooks on `target`'s machine. -/// Idempotent: existing tty7 entries/files are replaced, never duplicated, and -/// anything user-authored is left untouched. Returns a terse summary meant for -/// the settings row's note line — the row already shows the agent and target -/// path, so the summary never repeats them. pub fn install_hooks(target: &HookTarget, agent: HookAgent) -> anyhow::Result { let path = agent.target_path(target); match agent { @@ -648,15 +394,6 @@ pub fn install_hooks(target: &HookTarget, agent: HookAgent) -> anyhow::Result { hook_map_install(target, &path, agent, CODEX_HOOK_EVENTS)?; - // Codex only reads hooks.json once the hooks feature flag is on. - // Best-effort: the file install above is complete and correct - // either way, so a missing codex binary downgrades to advice - // instead of failing the install. - // - // Remote machines always get the advice: `Host` carries files and - // git, not arbitrary commands, so there is no way from here to run - // a CLI over there — and inventing one to flip a feature flag would - // be a far larger hole than the flag is worth. if !target.is_local() { return Ok( "Installed — run `codex features enable hooks` once on that machine" @@ -679,9 +416,6 @@ pub fn install_hooks(target: &HookTarget, agent: HookAgent) -> anyhow::Result anyhow::Result { let path = agent.target_path(target); match agent { @@ -692,10 +426,6 @@ pub fn uninstall_hooks(target: &HookTarget, agent: HookAgent) -> anyhow::Result< } } -/// Rewrite every integration on `target`'s machine that is installed but stale -/// (see [`HooksState::Outdated`]), so hooks keep pointing at a binary that -/// exists. Only ever touches entries tty7 already owns; a machine with no -/// integration installed is left alone. Returns how many were refreshed. pub fn refresh_hooks(target: &HookTarget) -> usize { let mut refreshed = 0; for agent in HookAgent::ALL { @@ -720,38 +450,13 @@ pub fn refresh_hooks(target: &HookTarget) -> usize { refreshed } -/// [`refresh_hooks`] for a machine that has just answered a handshake. -/// -/// A remote install writes the server binary's absolute path into the agent's -/// config, and that path carries the version (`…/bin/tty7-server-26.7.5`). So a -/// tty7 upgrade leaves every hook on every remote machine pointing at a file -/// that no longer exists — exactly the staleness -/// [`refresh_hooks_at_launch`] heals here, arriving by a different route. The -/// handshake is the trigger because it is the only moment we are certain which -/// binary is over there. -/// -/// **Blocking**: one config read per agent, over the control connection. pub fn refresh_remote_hooks(host: &dyn Host, home: PathBuf) -> usize { - // `RemoteTarget::LocalStdio` — the dev seam that stands a "remote" server up - // on this very computer — reports *this* machine's `$HOME`, and therefore - // shares its hook files. Rewriting those to point at whatever binary - // `TTY7_LOCAL_STDIO_SERVER` happens to name would silently break the user's - // real local integration on every dev launch. One machine, one set of - // hooks: if the home is ours, the local refresh already owns them. if home_dir().is_some_and(|ours| ours == home) { return 0; } refresh_hooks(&HookTarget::remote(host, home)) } -/// Startup keeper for *this computer*: the app moving or updating leaves every -/// installed hook pointing at the old absolute path. -/// -/// Release builds only — a debug build auto-claiming the hooks would steal them -/// from the installed app on every dev launch (installing *from* a dev build -/// stays possible, just explicit). The remote counterpart has no such rule and -/// no such trigger; see [`refresh_hooks`] and its caller in -/// `ui::remote_connect`. pub fn refresh_hooks_at_launch() -> usize { if cfg!(debug_assertions) { return 0; @@ -763,10 +468,6 @@ pub fn refresh_hooks_at_launch() -> usize { refresh_hooks(&target) } -// --------------------------------------------------------------------------- -// Shared: paths and the hook command line. -// --------------------------------------------------------------------------- - fn home_dir() -> Option { #[cfg(unix)] { @@ -778,22 +479,9 @@ fn home_dir() -> Option { } } -/// Owned-file names, shared by path resolution and tests. const OWNED_FILE_STEM_JSON: &str = "tty7.json"; const OWNED_FILE_STEM_JS: &str = "tty7.js"; -// --------------------------------------------------------------------------- -// Hooks-map installer (Claude Code, Codex): a JSON object with a top-level -// `"hooks"` key mapping event names to entry lists; tty7 owns exactly one -// marker-carrying entry per event and never touches the rest of the file. -// --------------------------------------------------------------------------- - -/// Claude Code's hook events and the sentinel event each maps onto. -/// `Notification` covers both "needs permission" and "waiting for input" — -/// exactly the Waiting state. `PostToolUse` is the way *back*: Claude has no -/// "permission replied" hook, so the first tool that completes after the user -/// approves is the signal that the turn is moving again (state machine flips -/// Waiting → Working on it, and ignores it otherwise). const CLAUDE_HOOK_EVENTS: &[(&str, &str)] = &[ ("SessionStart", "session-start"), ("UserPromptSubmit", "prompt-submit"), @@ -803,39 +491,14 @@ const CLAUDE_HOOK_EVENTS: &[(&str, &str)] = &[ ("SessionEnd", "session-end"), ]; -/// Codex's hook events (`~/.codex/hooks.json`, Claude-shaped). Codex is -/// turn-level only: no Notification hook, and no SessionEnd — the pane's -/// foreground detection clears the badge when Codex exits. const CODEX_HOOK_EVENTS: &[(&str, &str)] = &[ ("SessionStart", "session-start"), ("UserPromptSubmit", "prompt-submit"), ("Stop", "stop"), ]; -/// Seconds grok gives one tty7 hook before killing it. Set explicitly for two -/// reasons: it keeps `Stop` off grok's 600-second gate default (that budget is -/// for hooks that run test suites; ours writes a few bytes and returns), and it -/// leaves headroom for the first hook of a session, which pays the cold-start -/// cost of paging in the tty7 binary. A killed hook only loses that one event — -/// the session id arrives again on the next `UserPromptSubmit`. const GROK_HOOK_TIMEOUT_SECS: u32 = 10; -/// Grok Build's hook events — Claude Code's vocabulary, because grok mirrors it -/// deliberately (it even reads `~/.claude/settings.json`) — plus the matcher -/// regex each subscription is narrowed by (grok tests it against the event's -/// own discriminator: the notification type on `Notification`, the tool name on -/// tool events, …). Written as an owned file rather than merged into a shared -/// one; see [`grok_hooks_json`]. -/// -/// `Notification` is the one narrowed subscription. Grok dispatches its -/// `permission_prompt` notification *before* the permission system decides, so -/// it fires on essentially every tool call, auto-approved ones included — -/// escalating that to the amber "needs you" state would flash the pane (and -/// fire a desktop notification) on every tool a turn runs. `elicitation_dialog` -/// — grok's ask-the-user question — is the type that always means a real block, -/// so it is the only one subscribed. The emitter re-checks this (see -/// [`effective_event`]), which is what covers the same events arriving through -/// grok's Claude-compat scan, where the matcher isn't ours to set. const GROK_HOOK_EVENTS: &[(&str, &str, Option<&str>)] = &[ ("SessionStart", "session-start", None), ("UserPromptSubmit", "prompt-submit", None), @@ -882,10 +545,6 @@ fn hook_map_state( } } -/// Merge tty7's hook entries into the file at `path`, preserving everything -/// else. Idempotent: entries carrying the agent's marker are rewritten in -/// place (e.g. after the binary moved); user-defined hooks on the same events -/// are left untouched. fn hook_map_install( target: &HookTarget, path: &Path, @@ -930,9 +589,8 @@ fn hook_map_install( .entry(*hook_event) .or_insert_with(|| serde_json::json!([])); let Some(list) = entries.as_array_mut() else { - continue; // malformed user config on this event; leave it alone + continue; }; - // Drop any previous tty7 entry (stale exe path), then append ours. list.retain(|matcher| marker_command(matcher, &marker).is_none()); list.push(serde_json::json!({ "hooks": [{ "type": "command", "command": command }] @@ -942,10 +600,6 @@ fn hook_map_install( target.write(path, serde_json::to_string_pretty(&root)?.as_bytes()) } -/// Remove every tty7 hook entry from the file, leaving user-defined hooks and -/// all other settings untouched. Sweeps *all* hook events (not just the ones -/// we currently subscribe to) so entries left by an older tty7 with a -/// different event set are cleaned up too. fn hook_map_uninstall( target: &HookTarget, path: &Path, @@ -975,7 +629,6 @@ fn hook_map_uninstall( removed += before - list.len(); } } - // Drop event lists we emptied; a user's own hooks keep their event key. hooks.retain(|_, entries| entries.as_array().is_none_or(|list| !list.is_empty())); } if removed == 0 { @@ -985,8 +638,6 @@ fn hook_map_uninstall( Ok("Removed".to_string()) } -/// The tty7 hook command inside one matcher entry -/// (`{"matcher": …, "hooks": [{"command": …}]}`), if it carries one. fn marker_command<'a>(matcher: &'a serde_json::Value, marker: &str) -> Option<&'a str> { matcher .get("hooks") @@ -999,11 +650,6 @@ fn marker_command<'a>(matcher: &'a serde_json::Value, marker: &str) -> Option<&' }) } -/// Turn Codex's hooks feature flag on (`[features] hooks = true`), which gates -/// whether `~/.codex/hooks.json` is read at all. Runs the codex CLI itself so -/// the flag lands wherever the installed version keeps it; probes the common -/// install locations first because the GUI's PATH is often minimal. Uninstall -/// deliberately leaves the flag on — other tools' hooks may rely on it. fn enable_codex_hooks_feature() -> Result<(), String> { let candidates = [ PathBuf::from("/opt/homebrew/bin/codex"), @@ -1026,15 +672,6 @@ fn enable_codex_hooks_feature() -> Result<(), String> { } } -// --------------------------------------------------------------------------- -// Owned-file installer (Copilot, OpenCode, Pi): tty7 writes a whole file it -// owns outright, identified by the marker. Install refuses to clobber a file -// tty7 didn't write; uninstall only ever deletes a marker-carrying file. -// --------------------------------------------------------------------------- - -/// The exact file content for an owned-file agent, deterministic so state -/// detection can byte-compare (drift ⇒ `Outdated`). `None` for the two agents -/// that take a merged hooks map instead of a file of their own. fn owned_file_content(target: &HookTarget, agent: HookAgent) -> Option { match agent { HookAgent::Copilot => copilot_hooks_json(target), @@ -1052,8 +689,6 @@ fn owned_file_state(target: &HookTarget, path: &Path, expected: &str, marker: &s if contents == expected { HooksState::Installed } else if contents.contains(marker) { - // tty7 wrote it (the marker survives), but from another binary or an - // older version of the content. HooksState::Outdated } else { HooksState::NotInstalled @@ -1066,8 +701,6 @@ fn owned_file_install( content: &str, marker: &str, ) -> anyhow::Result<()> { - // Refuse to clobber a user-authored file at the managed path, symmetric - // with uninstall's ownership guard. if let Ok(existing) = target.read(path) && !existing.contains(marker) { @@ -1094,9 +727,6 @@ fn owned_file_uninstall(target: &HookTarget, path: &Path, marker: &str) -> anyho )); } target.host.remove(path, false)?; - // Pi's extension lives in its own directory; sweep it if now empty so - // uninstall leaves no husk (ignore failure — a non-empty dir is the - // user's). if let Some(parent) = path.parent() && parent.file_name().is_some_and(|n| n == "tty7") { @@ -1105,11 +735,6 @@ fn owned_file_uninstall(target: &HookTarget, path: &Path, marker: &str) -> anyho Ok("Removed".to_string()) } -/// Copilot hook file (`~/.copilot/hooks/tty7.json`): Copilot auto-loads every -/// JSON file in that directory, so tty7 owns its own file and never touches -/// the user's. Event names are Copilot's camelCase vocabulary; each runs the -/// emitter with the sentinel event it maps onto. `notification` is passed -/// through and filtered in the emitter (see [`effective_event`]). fn copilot_hooks_json(target: &HookTarget) -> Option { let hook = |event: &str, timeout: u32| { serde_json::json!([{ @@ -1131,13 +756,6 @@ fn copilot_hooks_json(target: &HookTarget) -> Option { serde_json::to_string_pretty(&root).ok() } -/// Grok Build hook file (`~/.grok/hooks/tty7.json`). Grok loads every JSON file -/// in that directory and global hooks are always trusted (project hooks need a -/// folder-trust grant; ours don't), so tty7 owns its own file and never touches -/// the user's. Both the schema and the event names are Claude Code's — grok -/// mirrors them deliberately — so this is the same wiring as -/// [`CLAUDE_HOOK_EVENTS`] in owned-file form; see [`GROK_HOOK_EVENTS`] for the -/// table and why `Notification` carries a matcher. fn grok_hooks_json(target: &HookTarget) -> Option { let mut hooks = serde_json::Map::new(); for (event, sentinel, matcher) in GROK_HOOK_EVENTS { @@ -1156,14 +774,7 @@ fn grok_hooks_json(target: &HookTarget) -> Option { serde_json::to_string_pretty(&serde_json::json!({ "hooks": hooks })).ok() } -/// OpenCode plugin (`~/.config/opencode/plugins/tty7.js`). OpenCode has no -/// declarative hooks — its extensibility surface is JS plugins auto-loaded -/// from that directory — so the plugin bridges its events onto the same -/// emitter every other agent's hooks run. Inert outside tty7 (both the JS -/// guard and the emitter check `TTY7`). fn opencode_plugin_js(target: &HookTarget) -> Option { - // The command prefix as a JS string literal (JSON string escaping is - // valid JS), completed with the event name at call time. let prefix = serde_json::to_string(&format!( "{} ", target.hook_command(HookAgent::OpenCode, "").trim_end() @@ -1204,21 +815,7 @@ export const Tty7Presence = async ({{ $ }}) => {{ )) } -/// Pi extension (`~/.pi/agent/extensions/tty7/index.ts`). Pi auto-loads TS -/// extensions from per-directory `index.ts` files; this one forwards Pi's -/// lifecycle events to the emitter. Inert outside tty7 (both the TS guard and -/// the emitter check `TTY7`). -/// -/// Every forwarded event carries Pi's own session id, read off the read-only -/// session manager on the handler's context — that id is what -/// [`crate::core::cli_agent::CLIAgent::resume_command`] feeds to `pi --session -/// ` when a restored pane relaunches its conversation. The context is only -/// reachable from a handler, so the load-time presence ping stays bare and -/// `session_start` (fired for startup / new / resume / fork) is what actually -/// reports the id, including when the user switches sessions mid-pane. fn pi_extension_ts(target: &HookTarget) -> Option { - // The machine that will *run* the agent, not this one — a remote workspace - // installs its hooks over there, where `current_exe` is the wrong binary. let exe = serde_json::to_string(&target.exe.display().to_string()).ok()?; Some(format!( r#"/* tty7 agent-hook pi bridge — generated by tty7, do not edit. */ @@ -1272,8 +869,6 @@ export default function (pi: ExtensionAPI) {{ mod tests { use super::*; - /// The emitter's bytes must parse back into the exact event the daemon's - /// sniffer expects — the two ends of the protocol locked together. #[test] fn hook_sequence_round_trips_through_the_daemon_parser() { use crate::core::cli_agent::{AgentEventKind, CLIAgent, parse_agent_event}; @@ -1283,8 +878,6 @@ mod tests { "notification", r#"{"session_id":"abc-123","message":"Claude needs your permission","cwd":"/w"}"#, ); - // Strip the OSC framing (`ESC ]` … `BEL`) to get the payload the - // tokenizer would deliver. let payload = &seq[2..seq.len() - 1]; let ev = parse_agent_event(payload).expect("daemon parses the emitted event"); assert_eq!(ev.agent, Some(CLIAgent::Claude)); @@ -1293,14 +886,11 @@ mod tests { assert!(ev.message.as_deref().unwrap().contains("permission")); assert_eq!(ev.cwd.as_deref(), Some(std::path::Path::new("/w"))); - // Garbage stdin still yields a well-formed bare event. let seq = build_hook_sequence("claude", "stop", "not json at all"); let ev = parse_agent_event(&seq[2..seq.len() - 1]).expect("bare event still parses"); assert_eq!(ev.kind, AgentEventKind::Stop); assert_eq!(ev.session_id, None); - // Grok's envelope is camelCase throughout; the same fields must land in - // the snake_case sentinel body, or restore loses the id `--resume` needs. let seq = build_hook_sequence( "grok", "session-start", @@ -1312,21 +902,14 @@ mod tests { assert_eq!(ev.cwd.as_deref(), Some(std::path::Path::new("/w"))); } - /// Grok reads `~/.claude/settings.json` too, so a tty7 Claude Code - /// integration fires inside grok panes. Those invocations must speak as - /// grok — otherwise the pane reports the wrong agent, and having both - /// integrations installed emits every turn twice under two identities. #[test] fn grok_run_hooks_are_relabeled_to_grok() { assert_eq!(effective_agent("claude", true), "grok"); assert_eq!(effective_agent("grok", true), "grok"); - // Outside grok's hook runner nothing is rewritten. assert_eq!(effective_agent("claude", false), "claude"); assert_eq!(effective_agent("grok", false), "grok"); } - /// Every event name any installer writes must be one the daemon's parser - /// accepts — a typo here would install hooks that emit into the void. #[test] fn every_installed_event_parses_as_a_sentinel_kind() { use crate::core::cli_agent::parse_agent_event; @@ -1337,7 +920,6 @@ mod tests { .map(|(_, e)| *e) .chain(GROK_HOOK_EVENTS.iter().map(|(_, e, _)| *e)) .collect(); - // Owned-file integrations embed their events in generated source. events.extend([ "prompt-submit", "permission-request", @@ -1349,15 +931,11 @@ mod tests { let seq = build_hook_sequence("codex", event, "{}"); let ev = parse_agent_event(&seq[2..seq.len() - 1]) .unwrap_or_else(|| panic!("event {event:?} must parse")); - // Round-trip sanity: serde derives kebab-case names from the enum. let kind_json = serde_json::to_value(ev.kind).unwrap(); assert_eq!(kind_json, serde_json::Value::String(event.to_string())); } } - /// Copilot's catch-all `notification` hook is filtered in the emitter: - /// permission/elicitation prompts escalate to `permission-request`, and - /// everything else stays silent instead of masquerading as a block. #[test] fn copilot_notifications_filter_to_permission_requests() { assert_eq!( @@ -1376,11 +954,6 @@ mod tests { effective_event("copilot", "notification", r#"{"type":"turn_summary"}"#), None ); - // Grok's catch-all Notification hook is filtered harder: only its - // ask-the-user question is reliably a block. `permission_prompt` fires - // ahead of the permission decision — verified against grok 0.2.112, - // where an auto-approved `list_dir` emitted one — so escalating it - // would flash amber on every tool call. assert_eq!( effective_event( "grok", @@ -1400,7 +973,6 @@ mod tests { "grok {noisy} is not a block" ); } - // Other agents and events pass through untouched. assert_eq!( effective_event("claude", "notification", "{}"), Some("notification") @@ -1409,13 +981,6 @@ mod tests { assert_eq!(effective_event("grok", "stop", "{}"), Some("stop")); } - /// The controlling-tty fallback (`ancestor_tty_device`) is what makes the - /// hook work at all: Claude Code runs hooks detached from the controlling - /// terminal, so `/dev/tty` fails and we must reach the agent's tty via the - /// parent chain (verified end-to-end against a real detached-hook PTY - /// setup). The device path itself is environment-dependent, so this guards - /// only the invariant that survives CI: the `ps`-walk never panics and only - /// ever yields a `/dev/…` device (never a bare tty name we'd fail to open). #[cfg(unix)] #[test] fn ancestor_tty_device_is_none_or_a_dev_path() { @@ -1434,7 +999,6 @@ mod tests { "hooks": [{ "type": "command", "command": "\"/x/tty7\" agent-hook claude stop" }] }); assert!(marker_command(&ours, "agent-hook claude").is_some()); - // Another agent's entry in the same file is not ours. assert!(marker_command(&ours, "agent-hook codex").is_none()); let theirs = serde_json::json!({ "hooks": [{ "type": "command", "command": "afplay /System/Library/Sounds/Glass.aiff" }] @@ -1443,22 +1007,10 @@ mod tests { assert!(marker_command(&serde_json::json!({}), "agent-hook claude").is_none()); } - // ----------------------------------------------------------------------- - // Two machines - // ----------------------------------------------------------------------- - - /// This computer's host object. fn local_host() -> crate::host::SharedHost { crate::host::local::LocalHost::new() } - /// A stand-in for a Linux box: a real filesystem (so the bytes land - /// somewhere a test can read) behind a *remote* [`HostId`] and a POSIX - /// separator, which is what the path arithmetic and the write path key on. - /// - /// Delegation rather than a stub for the same reason `host::server`'s - /// `SlowGit` delegates: the installer has to really write, and a stub would - /// only prove it calls methods. struct FakeRemote(crate::host::SharedHost); impl FakeRemote { @@ -1537,10 +1089,6 @@ mod tests { assert!(cmd.ends_with("agent-hook claude stop")); } - /// Every path an install touches on a remote machine is built with *that* - /// machine's separator and *that* machine's home — never `PathBuf::join`, - /// which on a Windows client talking to Linux would write `/home/me\.claude` - /// and install into a directory the agent never opens. #[test] fn remote_paths_are_built_in_the_remote_machine_s_spelling() { let host = FakeRemote::shared(); @@ -1570,10 +1118,6 @@ mod tests { } } - /// The hook a remote machine runs is the binary that lives *there*. The - /// local exe path is meaningless over there, and the dialects are in the - /// server binary's filename — which is what makes a wire break leave hooks - /// pointing at a path that no longer exists (see [`refresh_hooks`]). #[test] fn the_hook_command_names_the_binary_on_that_machine() { let host = FakeRemote::shared(); @@ -1585,7 +1129,6 @@ mod tests { format!("\"/home/me/.local/share/tty7/bin/{name}\" agent-hook claude stop") ); - // And locally it is still this process's own executable. let local = local_host(); let here = HookTarget::local(&*local).expect("home resolves in tests"); let exe = std::env::current_exe().unwrap(); @@ -1595,10 +1138,6 @@ mod tests { ); } - /// A full install → state → uninstall round trip through a *non-local* - /// host: the write goes through [`Host::write_file`] rather than - /// `write_atomic`, and the state read back has to agree with what was - /// written. Rooted at a scratch "home" so nothing real is touched. #[test] fn a_remote_install_round_trips_through_the_host() { let dir = std::env::temp_dir().join(format!("tty7-remote-hooks-{}", std::process::id())); @@ -1611,7 +1150,6 @@ mod tests { assert_eq!(hooks_state(&target, agent), HooksState::NotInstalled); install_hooks(&target, agent).expect("install succeeds"); assert_eq!(hooks_state(&target, agent), HooksState::Installed); - // The file really is there, under the remote-shaped path. let path = agent.target_path(&target); let dialect = crate::daemon::install::RemoteProtocol::of_this_build(); assert!(std::fs::read_to_string(&path).unwrap().contains(&format!( @@ -1622,9 +1160,6 @@ mod tests { assert_eq!(hooks_state(&target, agent), HooksState::NotInstalled); } - // Codex cannot have its feature flag flipped from here — `Host` carries - // files and git, not commands — so the install says so instead of - // claiming a wiring that isn't live yet. let summary = install_hooks(&target, HookAgent::Codex).expect("codex install succeeds"); assert!( summary.contains("codex features enable hooks"), @@ -1634,13 +1169,8 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } - /// Owned-file content sanity: valid/parseable where applicable, and every - /// generated file carries its ownership marker and this binary's path. #[test] fn owned_file_contents_carry_marker_and_exe() { - // The exe path is embedded inside JSON / JS string literals, so look - // for its string-escaped form — on Windows the raw path's backslashes - // appear as `\\` in the generated content. let exe_raw = std::env::current_exe().unwrap().display().to_string(); let exe_json = serde_json::to_string(&exe_raw).unwrap(); let exe = exe_json.trim_matches('"').to_string(); @@ -1674,10 +1204,6 @@ mod tests { assert!(pi.contains("agent-hook pi")); assert!(pi.contains(&exe)); assert!(pi.contains(r#"process.env["TTY7"]"#)); - // Pi's session id is what `pi --session ` resumes with, and it only - // reaches tty7 if the bridge pipes a payload instead of ignoring stdin - // — which is how this integration shipped, silently costing Pi panes - // their resume. assert!(pi.contains("getSessionId")); assert!(pi.contains("session_id")); assert!(pi.contains(r#"stdio: ["pipe", "ignore", "ignore"]"#)); @@ -1694,9 +1220,6 @@ mod tests { cmd.ends_with(&format!("agent-hook grok {sentinel}")), "grok {event} runs the emitter with {sentinel}, got {cmd}" ); - // A narrowed subscription must carry its matcher — without it grok - // fires the hook for every notification type, which is the amber - // flash this integration exists to avoid. assert_eq!( group.get("matcher").and_then(|m| m.as_str()), *matcher, @@ -1706,8 +1229,6 @@ mod tests { assert!(grok.contains(&exe)); } - /// Owned-file lifecycle against a scratch path: install → Installed, - /// drift → Outdated, foreign file → refused, uninstall → gone. #[test] fn owned_file_round_trip_and_ownership_guard() { let dir = std::env::temp_dir().join(format!("tty7-owned-test-{}", std::process::id())); @@ -1728,8 +1249,6 @@ mod tests { HooksState::Installed ); - // Drift (e.g. written by an older tty7 or another binary) reads as - // Outdated, and a reinstall heals it. std::fs::write(&path, content.replace(marker, "agent-hook copilot --old")).unwrap(); assert_eq!( owned_file_state(&t, &path, &content, marker), @@ -1741,13 +1260,10 @@ mod tests { HooksState::Installed ); - // A user-authored file at the managed path is never clobbered or - // deleted. std::fs::write(&path, "// my own hooks, hands off").unwrap(); assert!(owned_file_install(&t, &path, &content, marker).is_err()); assert!(owned_file_uninstall(&t, &path, marker).is_err()); - // Restore ours, then uninstall removes it; a second uninstall no-ops. std::fs::write(&path, &content).unwrap(); owned_file_uninstall(&t, &path, marker).expect("uninstall succeeds"); assert!(!path.exists()); @@ -1756,18 +1272,11 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } - /// Full install → verify → re-install → outdated → uninstall round trip - /// for the hooks-map installer, against a scratch settings file - /// (`CLAUDE_CONFIG_DIR` is honored, so the test never touches the real - /// `~/.claude`). One test on purpose: it is the only place - /// CLAUDE_CONFIG_DIR is mutated, so `cargo test` threads never race the - /// env var. #[test] fn install_is_idempotent_and_preserves_user_hooks() { let dir = std::env::temp_dir().join(format!("tty7-hooks-test-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let settings = dir.join("settings.json"); - // Pre-existing user config: a model pick and their own Stop hook. std::fs::write( &settings, serde_json::json!({ @@ -1779,15 +1288,10 @@ mod tests { .to_string(), ) .unwrap(); - // SAFETY: test-only env mutation; no other test reads this var. unsafe { std::env::set_var("CLAUDE_CONFIG_DIR", &dir) }; let host = local_host(); let t = HookTarget::local(&*host).expect("home resolves in tests"); - // The override is ours, not the far machine's: a remote target must - // keep resolving to its own `~/.claude`, or an install would land in a - // directory the remote Claude never opens. Asserted here because this - // is the one test that owns the env var. let remote_host = FakeRemote::shared(); let remote = HookTarget::remote(&*remote_host, PathBuf::from("/home/me")); assert_eq!( @@ -1799,11 +1303,9 @@ mod tests { install_hooks(&t, HookAgent::Claude).expect("install succeeds"); assert_eq!(hooks_state(&t, HookAgent::Claude), HooksState::Installed); - // Install again: no duplicates. install_hooks(&t, HookAgent::Claude).expect("re-install succeeds"); let root: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&settings).unwrap()).unwrap(); - // User settings and user hooks survive. assert_eq!(root["model"], "opus"); let stop = root["hooks"]["Stop"].as_array().unwrap(); assert_eq!( @@ -1818,7 +1320,6 @@ mod tests { .any(|m| m.to_string().contains("afplay ding.aiff")), "the user's own Stop hook survives" ); - // All five events are wired. for (event, _) in CLAUDE_HOOK_EVENTS { assert!( root["hooks"][*event] @@ -1830,8 +1331,6 @@ mod tests { ); } - // A tty7 entry rewritten to another binary's path reads as Outdated — - // the state the launch-time refresh keys on — and a reinstall heals it. let healthy = std::fs::read_to_string(&settings).unwrap(); std::fs::write( &settings, @@ -1842,8 +1341,6 @@ mod tests { install_hooks(&t, HookAgent::Claude).expect("reinstall over an outdated entry succeeds"); assert_eq!(hooks_state(&t, HookAgent::Claude), HooksState::Installed); - // Uninstall removes exactly our entries: the user's Stop hook and their - // other settings survive, and the emptied event keys are dropped. uninstall_hooks(&t, HookAgent::Claude).expect("uninstall succeeds"); assert_eq!(hooks_state(&t, HookAgent::Claude), HooksState::NotInstalled); let root: serde_json::Value = @@ -1859,10 +1356,8 @@ mod tests { root["hooks"].get("SessionStart").is_none(), "an event list that held only the tty7 hook is dropped" ); - // Nothing left to remove: a second uninstall is a no-op, not an error. uninstall_hooks(&t, HookAgent::Claude).expect("uninstall is idempotent"); - // SAFETY: restore for any later test relying on the default path. unsafe { std::env::remove_var("CLAUDE_CONFIG_DIR") }; let _ = std::fs::remove_dir_all(&dir); } diff --git a/crates/tty7-core/src/core/cli_agent.rs b/crates/tty7-core/src/core/cli_agent.rs index 63e392a4..e2390160 100644 --- a/crates/tty7-core/src/core/cli_agent.rs +++ b/crates/tty7-core/src/core/cli_agent.rs @@ -1,38 +1,7 @@ -//! Third-party CLI coding-agent registry + detection. -//! -//! tty7 recognizes when a pane is running someone else's coding agent (Claude -//! Code, Codex, Gemini CLI, …) so the tab chip can brand it and desktop -//! notifications can say *which* agent finished or needs you. This is -//! deliberately *not* tty7's own agent: it only observes and enriches whatever -//! agent the user launched. -//! -//! Detection is command-based: on macOS/Linux the daemon already -//! reads the foreground process's `argv` for SSH-context sniffing, so we reuse -//! that to match the invoked command against a known agent. Matching is a pure -//! function over `argv` — [`CLIAgent::detect_from_argv`] — kept here in `core` -//! (framework-light, unit-tested) and called daemon-side, with the resulting -//! `Option` streamed to the client for the UI. On Windows ConPTY -//! exposes no foreground process group, so the input is the *typed command -//! line* the shell integration captures at preexec and carries on the `133;C` -//! mark — [`CLIAgent::detect_from_command_with`] matches it the same way. -//! -//! The enum is serialized across the daemon↔client protocol, so its variants -//! are the wire contract; add new agents at the end. -//! -//! Beyond identity, this module also defines the *rich status* layer (a -//! second detection tier): agents whose hooks/plugins emit tty7's OSC 777 -//! sentinel events ([`AGENT_EVENT_SENTINEL`]) get a per-session state machine -//! ([`AgentSessionState`]: idle / working / waiting-for-you / done) plus the -//! native session id used for resume-after-restart. Everything here is pure -//! and unit-tested; the daemon sniffs the events and streams state changes to -//! the client. - use std::collections::HashMap; use serde::{Deserialize, Serialize}; -/// A recognized third-party CLI coding agent. Ordering is the wire contract -/// (serialized in [`crate::daemon::protocol`]); append, never reorder. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum CLIAgent { Claude, @@ -55,7 +24,6 @@ pub enum CLIAgent { } impl CLIAgent { - /// Every known agent, for iteration in detection and tests. pub const ALL: [CLIAgent; 17] = [ CLIAgent::Claude, CLIAgent::Codex, @@ -76,11 +44,6 @@ impl CLIAgent { CLIAgent::Qwen, ]; - /// The command names that identify this agent — the launcher binary plus any - /// npm/pip package-dir aliases that show up in an interpreter-wrapped `argv` - /// (e.g. `node …/@anthropic-ai/claude-code/cli.js`, where the launcher is - /// `node` and only the `claude-code` path segment names the agent). All - /// lowercase; matched against extension-stripped path segments. fn aliases(self) -> &'static [&'static str] { match self { CLIAgent::Claude => &["claude", "claude-code"], @@ -103,9 +66,6 @@ impl CLIAgent { } } - /// Stable machine name (lowercase), used as the `agent` field of the OSC - /// event protocol and as the value side of user-defined detection rules in - /// `config.json` (`agent_commands: {"my-wrapper": "claude"}`). pub fn slug(self) -> &'static str { match self { CLIAgent::Claude => "claude", @@ -128,13 +88,11 @@ impl CLIAgent { } } - /// Look an agent up by its [`slug`](Self::slug) (case-insensitive). pub fn from_slug(name: &str) -> Option { let name = name.trim().to_ascii_lowercase(); CLIAgent::ALL.into_iter().find(|a| a.slug() == name) } - /// Human-readable name for tab chips, notifications, and menus. pub fn display_name(self) -> &'static str { match self { CLIAgent::Claude => "Claude Code", @@ -157,121 +115,50 @@ impl CLIAgent { } } - /// The shell command that resumes a previous session of this agent by its - /// native session id, or `None` for agents without a known resume flag. - /// The id is what the agent reported in its `session-start` event (see - /// [`AgentEvent`]); commands mirror cmux's per-agent resume table. - /// - /// `launch_argv` is the argv the agent was originally launched with, when - /// the daemon observed one. Its flags (`--dangerously-skip-permissions`, - /// `--model …`) are carried onto the resume command so the restored - /// session runs in the same mode the user picked — verbatim only when the - /// whole tail passes the conservative shell-safety gate; otherwise the - /// bare table command still resumes, just without the flags. pub fn resume_command( self, session_id: &str, launch_argv: Option<&[String]>, ) -> Option { - // A pane the user launched as deliberately ephemeral has nothing on - // disk to come back to, whatever id the agent reported. Resume-only: - // no agent that opts out of sessions has a fork command today, so - // hoisting this into the shared helper would only add a dead branch. if launch_argv.is_some_and(|argv| self.opts_out_of_sessions(argv)) { return None; } let flags = self.session_command_flags(session_id, launch_argv)?; match self { CLIAgent::Claude => Some(format!("claude{flags} --resume {session_id}")), - // Codex resumes via a subcommand that accepts the interactive - // options after the positional id (`codex resume [OPTIONS] - // [SESSION_ID]`). CLIAgent::Codex => Some(format!("codex resume {session_id}{flags}")), CLIAgent::Gemini => Some(format!("gemini{flags} --resume {session_id}")), CLIAgent::OpenCode => Some(format!("opencode{flags} --session {session_id}")), - // Amp's global options (`--dangerously-allow-all`, …) are accepted - // by the `threads continue` subcommand (verified: unknown options - // are a parse error, globals pass). CLIAgent::Amp => Some(format!("amp threads continue {session_id}{flags}")), CLIAgent::Cursor => Some(format!("cursor-agent{flags} --resume {session_id}")), - // Copilot CLI: `copilot --resume ` (`-r` shorthand) — - // the one hooks-covered agent that was missing from this table. CLIAgent::Copilot => Some(format!("copilot{flags} --resume {session_id}")), - // Grok Build: `grok --resume `; a UUID-shaped value - // always takes the id path, which is what its hooks report. CLIAgent::Grok => Some(format!("grok{flags} --resume {session_id}")), - // Pi's `--resume`/`-r` is a *boolean* that opens the interactive - // session picker and `--continue`/`-c` just takes the newest - // session; the flag that targets one by id is `--session - // ` ("Use specific session file or partial UUID"). Its - // ids are uuidv7, so they clear `session_command_flags`' token - // gate. CLIAgent::Pi => Some(format!("pi{flags} --session {session_id}")), _ => None, } } - /// Whether `argv` launched the agent with session persistence turned off, - /// which makes the pane unresumable: nothing was written to disk, so a - /// replayed id would point at a session file that never existed *and* - /// would quietly undo the user's opt-out. Distinct from the stale flags in - /// [`Self::replay_flags`], which name a different session and merely have - /// to lose to the injected id. fn opts_out_of_sessions(self, argv: &[String]) -> bool { let ephemeral: &[&str] = match self { - // Pi still mints an in-memory session id under `--no-session` — it - // only skips the write — so tty7 does observe an id to replay. CLIAgent::Pi => &["--no-session"], _ => &[], }; argv.iter().any(|t| ephemeral.contains(&t.as_str())) } - /// The shell command that *forks* a previous session of this agent — one - /// that branches the transcript into a fresh session id, leaving the - /// original untouched so both can be continued independently. `None` for - /// agents tty7 has no verified fork command for; those must not be offered - /// the action at all rather than shown a command that fails in the pane. - /// - /// Shares [`resume_command`](Self::resume_command)'s id validation and - /// launch-flag replay verbatim: every agent below takes the same option set - /// on its fork path as on its resume path, so there is no second table to - /// keep in step. - /// - /// Deliberately *not* wired into session restore. Restoring a forked pane - /// after a tty7 restart must **continue** that fork (its own id resumes), - /// not fork it again — see `ui::app`'s restore path, which calls - /// `resume_command` for every pane including forks. pub fn fork_command(self, session_id: &str, launch_argv: Option<&[String]>) -> Option { let flags = self.session_command_flags(session_id, launch_argv)?; match self { - // `codex fork [OPTIONS] [SESSION_ID]` — a first-class subcommand - // taking the same options as `codex resume`. It mints a new thread - // id, writes a new rollout recording `forked_from_id`, and leaves - // the parent's bytes untouched. CLIAgent::Codex => Some(format!("codex fork {session_id}{flags}")), - // `--fork-session` is a modifier on the resume, not a mode of its - // own: it makes the resumed conversation take a new session id. CLIAgent::Claude => Some(format!( "claude{flags} --resume {session_id} --fork-session" )), CLIAgent::Grok => Some(format!("grok{flags} --resume {session_id} --fork-session")), - // opencode's `--fork` likewise only means anything alongside - // `--session`/`--continue`. CLIAgent::OpenCode => Some(format!("opencode{flags} --session {session_id} --fork")), - // Everything else: no fork flag we could verify from the CLI's own - // help, so tty7 claims none. Guessing a flag shape here would - // surface a menu row that only ever produces a usage error. _ => None, } } - /// The menu label for forking this agent's session — one wording for every - /// agent, because every agent that has the capability calls it forking - /// (`codex fork`; `--fork-session` on Claude Code and Grok; `--fork` on - /// OpenCode). `Some` exactly when [`fork_command`](Self::fork_command) can - /// build a command, so the UI can use it as the single capability gate; - /// per-agent wording stays expressible here should one ever diverge. pub fn fork_label(self) -> Option<&'static str> { match self { CLIAgent::Claude | CLIAgent::Codex | CLIAgent::Grok | CLIAgent::OpenCode => { @@ -281,11 +168,6 @@ impl CLIAgent { } } - /// The launch-flag tail replayed onto a resume/fork command, pre-joined - /// with a leading space so it splices straight into the format strings, and - /// empty when no flags survive. `None` rejects the whole command: ids come - /// from the agent's own events but still land on a shell command line, so - /// anything that isn't a plain token could smuggle shell syntax. fn session_command_flags( self, session_id: &str, @@ -312,22 +194,6 @@ impl CLIAgent { ) } - /// The launch-flag tail of `argv` worth replaying on a resume command, or - /// `None` to resume bare. Deliberately conservative: anything ambiguous - /// falls back to no flags rather than a corrupted command line. - /// - /// - The tail is everything after the token that names this agent (the - /// launcher itself, or the script path in an interpreter-wrapped argv); - /// leading `VAR=value` env assignments are skipped first so they can't - /// mis-anchor (`CLAUDE_CONFIG_DIR=/opt/claude claude …`). No naming - /// token at all (custom wrapper rules) → no flags. - /// - Stale session-targeting flags (`--resume old-id`, `--continue`, a - /// re-launched `codex resume `) are stripped — the new id must win. - /// - Every surviving token must be a plain shell-safe word, the first must - /// be a `-` flag, and no two bare words may run consecutively — a bare - /// word is only acceptable as the value directly behind a flag; anything - /// else is a positional prompt that must not re-submit itself into the - /// resumed session. Any violation drops the whole tail. fn replay_flags(self, argv: &[String]) -> Option> { let names_self = |token: &str| { token.split(['/', '\\']).any(|seg| { @@ -338,9 +204,6 @@ impl CLIAgent { let named = argv.iter().position(|t| names_self(t))?; let mut tail: Vec<&str> = argv[named + 1..].iter().map(String::as_str).collect(); - // A relaunched `codex resume ` — or `codex fork `, which - // is what a forked pane's argv looks like: drop the subcommand and its - // id so they don't replay as a positional prompt. if self == CLIAgent::Codex && matches!(tail.first(), Some(&"resume") | Some(&"fork")) { tail.remove(0); if tail.first().is_some_and(|t| !t.starts_with('-')) { @@ -348,14 +211,7 @@ impl CLIAgent { } } - // Session-targeting flags whose old value must not survive; each is - // stripped together with one following non-flag value token (harmless - // for the value-less ones — anything trailing them is positional). let stale: &[&str] = match self { - // `--fork-session` / `--fork` are session-targeting too: left in - // place they would branch again on every relaunch (and double up on - // a fork of a fork). Both resume and fork re-add them from the - // table when they are what the user actually asked for. CLIAgent::Claude => &[ "--resume", "-r", @@ -368,17 +224,7 @@ impl CLIAgent { CLIAgent::Gemini | CLIAgent::Cursor => &["--resume", "-r"], CLIAgent::Copilot => &["--resume", "-r", "--continue", "-c"], CLIAgent::OpenCode => &["--session", "-s", "--continue", "-c", "--fork"], - // `--last` targets "the most recent session" and would contradict - // the explicit id we inject. CLIAgent::Codex => &["--last"], - // Pi's ways of picking a session all fight the `--session ` we - // inject: `--session`/`--session-id` name a different one, - // `--fork` would branch instead of continue, and the boolean - // `-r`/`-c` re-open the picker or the newest session. - // `--session-dir` is *not* here — it says where sessions live, so - // the id we inject needs it to still be there. `--no-session` is - // not here either: it isn't stale, it means there is nothing to - // resume at all (see [`Self::opts_out_of_sessions`]). CLIAgent::Pi => &[ "--session", "--session-id", @@ -388,13 +234,6 @@ impl CLIAgent { "--continue", "-c", ], - // Beyond the session-targeting flags (`--load` is grok's hidden - // alias for `--resume`; `--session-id` names a *new* session and - // `--fork-session` would branch off the one we mean to continue), - // the worktree pair goes too: `--worktree` with no value mints a - // fresh git worktree on every relaunch, and `--worktree-ref` - // requires `--worktree`, so leaving it behind would make grok - // reject the resume outright. CLIAgent::Grok => &[ "--resume", "-r", @@ -428,12 +267,6 @@ impl CLIAgent { } } - // The safety gate: plain tokens only, and a flag-shaped tail — every - // bare word must sit directly behind a `-` flag (its value slot); the - // first token being bare, or two bare words in a row, is a positional - // prompt and drops the whole tail. (A single bare word behind a - // boolean flag is indistinguishable from a flag value and slips - // through — the residual ambiguity of not knowing each flag's arity.) let safe = |t: &str| { !t.is_empty() && t.bytes() @@ -453,41 +286,28 @@ impl CLIAgent { Some(tail.into_iter().map(String::from).collect()) } - /// Brand accent (0xRRGGBB) for the tab chip's agent dot. Chosen for legibility - /// on both light and dark themes rather than exact brand black/white. A pure - /// *white* field vanishes against a light theme, so vendors whose mark is a - /// grey or gradient monochrome (Cursor) get a recognizable mid-tone hue - /// instead. A black field is a different case: it stays darker than even the - /// darkest theme background and the white mark on it carries the badge, so - /// vendors who actually brand in black (Codex, Grok) keep it. pub fn accent_rgb(self) -> u32 { match self { - CLIAgent::Claude => 0xD97757, // Claude terracotta - CLIAgent::Codex => 0x000000, // Codex black field - CLIAgent::Gemini => 0x4285F4, // Google blue - CLIAgent::Aider => 0x14B8A6, // teal - CLIAgent::Amp => 0xF34E3F, // Amp red - CLIAgent::OpenCode => 0x6E56CF, // violet - CLIAgent::Copilot => 0x8957E5, // GitHub purple - CLIAgent::Cursor => 0x9AA0A6, // Cursor is monochrome → neutral grey - CLIAgent::Goose => 0x9A8CFF, // periwinkle - CLIAgent::Droid => 0xF59E0B, // amber - CLIAgent::Pi => 0x0EA5E9, // sky - CLIAgent::Auggie => 0x16A34A, // Augment green - CLIAgent::Hermes => 0x8B5CF6, // violet - CLIAgent::Vibe => 0xFF7000, // Mistral orange - CLIAgent::Antigravity => 0x2563EB, // Google blue (darker than Gemini's) - CLIAgent::Grok => 0x000000, // xAI brands in black - CLIAgent::Qwen => 0x7C3AED, // Qwen purple + CLIAgent::Claude => 0xD97757, + CLIAgent::Codex => 0x000000, + CLIAgent::Gemini => 0x4285F4, + CLIAgent::Aider => 0x14B8A6, + CLIAgent::Amp => 0xF34E3F, + CLIAgent::OpenCode => 0x6E56CF, + CLIAgent::Copilot => 0x8957E5, + CLIAgent::Cursor => 0x9AA0A6, + CLIAgent::Goose => 0x9A8CFF, + CLIAgent::Droid => 0xF59E0B, + CLIAgent::Pi => 0x0EA5E9, + CLIAgent::Auggie => 0x16A34A, + CLIAgent::Hermes => 0x8B5CF6, + CLIAgent::Vibe => 0xFF7000, + CLIAgent::Antigravity => 0x2563EB, + CLIAgent::Grok => 0x000000, + CLIAgent::Qwen => 0x7C3AED, } } - /// Asset path of this agent's brand glyph, resolved through the app's - /// [`crate::ui::assets`] source and rendered as a white silhouette on the - /// brand-colored avatar (gpui rasterizes SVGs to a tinted alpha mask, so the - /// mark's own fill is irrelevant — geometry only). Vendors we ship a brand - /// mark for point at `icons/agents/…`; the rest fall back to the generic - /// gpui-component `bot` glyph so every recognized agent still gets an avatar. pub fn icon_path(self) -> &'static str { match self { CLIAgent::Claude => "icons/agents/claude.svg", @@ -501,7 +321,6 @@ impl CLIAgent { CLIAgent::Droid => "icons/agents/droid.svg", CLIAgent::Grok => "icons/agents/grok.svg", CLIAgent::Pi => "icons/agents/pi.svg", - // No brand mark bundled → generic robot glyph. CLIAgent::Aider | CLIAgent::Auggie | CLIAgent::Hermes @@ -511,52 +330,21 @@ impl CLIAgent { } } - /// Match a single extension-stripped, lowercased command token against the - /// registry. `None` when nothing matches. fn match_token(token: &str) -> Option { CLIAgent::ALL .into_iter() .find(|a| a.aliases().contains(&token)) } - /// Identify the coding agent a foreground `argv` is running, or `None`. - /// - /// The strategy is command-name detection: - /// 1. Strip any leading `VAR=value` environment assignments (`FOO=1 claude`). - /// 2. If the launcher's own basename matches a known agent, that's it — the - /// native-binary case (`claude`, `codex`, `gemini`, `aider`, …). - /// 3. Otherwise, if the launcher is a script *interpreter* (`node`, `bun`, - /// `python`, `npx`, …), scan the remaining path-like arguments for a - /// segment that names an agent — the npm/pip-wrapped case - /// (`node …/claude-code/cli.js`, `npx @anthropic-ai/claude-code`). - /// - /// The interpreter gate is what keeps `cat codex.md` or `vim aider.py` from - /// false-matching: a non-interpreter launcher only ever matches on its own - /// name, never on its arguments. - /// - /// The production caller (the daemon's foreground poll) goes through - /// [`detect_from_argv_with`](Self::detect_from_argv_with) to honor - /// user-defined rules; this rule-free form is the pure core the test suite - /// exercises. #[cfg_attr(not(test), allow(dead_code))] pub fn detect_from_argv(argv: &[String]) -> Option { Self::detect_from_argv_with(argv, &HashMap::new()) } - /// [`detect_from_argv`](Self::detect_from_argv) extended with user-defined - /// rules (`config.json`'s `agent_commands`): a map from a command basename - /// to an agent [`slug`](Self::slug), so a personal wrapper (`"cc": - /// "claude"`) is branded like the agent it launches — a command allowlist - /// keyed by exact basename instead of regex. Custom rules apply to the - /// *launcher* only (never to - /// interpreter arguments) and lose to a built-in match on the same name. pub fn detect_from_argv_with( argv: &[String], custom: &HashMap, ) -> Option { - // 1. Skip leading environment assignments (`KEY=val`). A bare `env` prefix - // (`env claude`) is treated as an interpreter below so its target is - // scanned. let mut rest = argv .iter() .map(String::as_str) @@ -565,8 +353,6 @@ impl CLIAgent { let launcher = rest.next()?; let launcher_stem = base_stem(launcher); - // 2. Native binary: the launcher itself is the agent — by the built-in - // registry first, then by a user-defined rule. if let Some(agent) = CLIAgent::match_token(launcher_stem) { return Some(agent); } @@ -577,11 +363,8 @@ impl CLIAgent { return Some(agent); } - // 3. Interpreter wrapper: scan the script path / package arg it runs. if is_interpreter(launcher_stem) { for arg in rest { - // Only inspect path-like / package-like tokens (the script it - // runs), never bare flags or option values. if arg.starts_with('-') { continue; } @@ -598,22 +381,6 @@ impl CLIAgent { None } - /// [`detect_from_argv_with`](Self::detect_from_argv_with) over a *typed - /// command line* rather than a live process `argv` — the Windows detection - /// input. ConPTY has no foreground process group to resolve to an argv, so - /// there the daemon learns what runs from the shell integration instead: - /// PowerShell's `PSConsoleHostReadLine` wrapper reports the submitted line - /// on the `133;C` mark (the same capture Warp's Windows integration uses), - /// and this matches it like an argv. - /// - /// The tokenization is deliberately naive — whitespace split, surrounding - /// quotes trimmed, a leading PowerShell call operator (`&`) dropped, and - /// everything lowercased (Windows commands are case-insensitive). A quoted - /// launcher path containing spaces splits wrong and misses — notably - /// PSReadLine tab-completion's `& 'C:\Program Files\…\claude.exe'` — the - /// accepted trade-off for not writing a shell parser; the dominant shapes - /// (a bare shim on PATH, `npx …`) tokenize fine, and `agent_commands` - /// rules cover personal wrappers. pub fn detect_from_command_with( command: &str, custom: &HashMap, @@ -630,14 +397,9 @@ impl CLIAgent { } } -/// A `KEY=value` shell environment assignment prefix (`FOO=bar cmd`). The `KEY` -/// must be a non-empty run of identifier chars before the first `=`. fn is_env_assignment(token: &str) -> bool { match token.split_once('=') { Some((key, _)) => { - // A real env var starts with a letter/underscore and is otherwise - // alphanumerics/underscores — this rejects things like `a=b` paths or - // `--flag=val` that merely contain `=`. let mut bytes = key.bytes(); bytes .next() @@ -648,24 +410,12 @@ fn is_env_assignment(token: &str) -> bool { } } -/// The final path component with a leading dir and a trailing script extension -/// stripped, lowercased-ready but case preserved (callers lowercase when they -/// match interpreter args). `/usr/bin/claude` → `claude`, `cli.js` → `cli`. -/// Splits on both separators by hand (not [`Path`]) so a Windows path in a -/// captured command line (`C:\…\claude.cmd`) resolves the same on every -/// platform — including in tests run on Unix. fn base_stem(token: &str) -> &str { - // Trailing separators are dropped first (`claude/` → `claude`, matching - // the old `Path::file_name` behavior), then everything up to the last - // separator. let trimmed = token.trim_end_matches(['/', '\\']); let name = match trimmed.rfind(['/', '\\']) { Some(i) => &trimmed[i + 1..], None => trimmed, }; - // Strip one known script/launcher extension; leave unknown suffixes intact - // so `claude-code` stays whole. The Windows set covers npm's shim trio - // (`claude.cmd` / `claude.ps1` / `claude.exe`). for ext in [ ".js", ".mjs", ".cjs", ".ts", ".py", ".rb", ".sh", ".exe", ".cmd", ".bat", ".ps1", ] { @@ -676,10 +426,6 @@ fn base_stem(token: &str) -> &str { name } -/// Whether a launcher basename is a script interpreter whose argument (rather -/// than the launcher itself) names the real program — so agent detection should -/// scan past it. Covers the common Node/Python/Ruby/`env`/`npx` wrappers agents -/// ship as. fn is_interpreter(stem: &str) -> bool { matches!( stem.to_ascii_lowercase().as_str(), @@ -699,103 +445,43 @@ fn is_interpreter(stem: &str) -> bool { ) } -// --------------------------------------------------------------------------- -// Rich session status — the OSC event protocol + per-pane state machine. -// -// Identity detection above answers "*which* agent runs here"; this layer -// answers "what is it doing". Agent-side hooks (installed by -// `core::agent_hooks`, or hand-wired for any agent) emit an OSC 777 -// notification whose title is the [`AGENT_EVENT_SENTINEL`] and whose body is a -// small JSON event. The daemon sniffs those out of the PTY stream, folds them -// through [`AgentSessionState::apply_event`], and streams the state to the -// client (`DaemonMsg::AgentStatus`) for status dots, "needs your input" -// notifications, and session resume. It's a self-describing sentinel channel -// (OSC 777 + `tty7://cli-agent` sentinel + versioned JSON). -// --------------------------------------------------------------------------- - -/// The OSC 777 notification title that marks a payload as a tty7 agent event -/// rather than a user-facing notification: -/// `ESC ] 777;notify;tty7://cli-agent;{json} BEL`. pub const AGENT_EVENT_SENTINEL: &str = "tty7://cli-agent"; -/// What an agent session is doing right now, coarsely. `Waiting` is the state -/// the whole feature exists for: the agent stopped mid-turn and needs the user -/// (a permission prompt, a question) — the moment worth a notification and an -/// amber dot. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum AgentStatus { - /// Session open, no turn in flight (freshly started, or the user hasn't - /// prompted since the last turn ended and was seen). #[default] Idle, - /// A turn is in flight (prompt submitted, tools running). Working, - /// Stopped mid-turn on the user: permission request, question, or an - /// opaque "the agent pinged you" notification. Waiting, - /// The turn finished; the result is sitting there waiting to be read. Done, } -/// Per-pane agent session state, maintained daemon-side and mirrored to the -/// client. Exists only while an agent is detected in the pane's foreground. #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct AgentSessionState { #[serde(default = "AgentSessionState::default_status")] pub status: AgentStatus, - /// Human-readable context for `Waiting`/`Done` (e.g. "Claude needs your - /// permission to use Bash"), straight from the event. #[serde(default)] pub message: Option, - /// The agent's *native* session id (from its `session-start` event), the - /// key its own `--resume` flag takes — persisted for restore. #[serde(default)] pub session_id: Option, - /// The argv the agent was launched with, as the daemon observed it (the - /// foreground process-table poll on Unix, the shell integration's typed - /// `133;C` capture on Windows). Persisted alongside the session id so - /// restore can carry the user's launch flags - /// (`--dangerously-skip-permissions`, `--model …`) onto the resume - /// command — see [`CLIAgent::resume_command`]. Not touched by - /// [`apply_event`](Self::apply_event); the daemon stamps it from the - /// identity-detection side. #[serde(default)] pub launch_argv: Option>, - /// Whether this state came from the rich sentinel channel (hooks - /// installed) rather than the opaque OSC 9/777 fallback. Rich state drives - /// turn-level notifications; fallback state only paints the dot (the - /// agent's own notification text was already toasted by the client). #[serde(default)] pub rich: bool, - /// The agent's working directory as its hook payloads report it — the - /// agent's own claim, which tracks internal chdirs the PTY can't show - /// (Claude Code's EnterWorktree moves the session without any shell `cd`). - /// Cleared on `session-end` so a finished session can't pin consumers to - /// a stale path; while absent, consumers fall back to the pane's proc cwd. #[serde(default)] pub cwd: Option, - /// Tool completions seen in this session, counted only so consumers can - /// spot *that* the agent did something — a turn's edits land tool by tool, - /// and the status alone can't say so (`ToolComplete` is a no-op transition - /// during normal work, by design). The sidebar's git probe watches this to - /// refresh mid-turn instead of waiting for `stop`; see - /// [`TerminalView::refresh_git_status`](crate::terminal::view::TerminalView). - /// Monotonic within a session and never reset — consumers compare against - /// the value they last saw, so only the *change* means anything. #[serde(default)] pub activity: u64, } impl AgentStatus { - /// The status dot color (0xRRGGBB) shared by the tab chip and the sidebar, - /// or `None` for `Idle` (no dot — a resting agent is just its brand mark). pub fn dot_rgb(self) -> Option { match self { AgentStatus::Idle => None, - AgentStatus::Working => Some(0x3B82F6), // blue: in flight - AgentStatus::Waiting => Some(0xF59E0B), // amber: needs you - AgentStatus::Done => Some(0x22C55E), // green: result ready + AgentStatus::Working => Some(0x3B82F6), + AgentStatus::Waiting => Some(0xF59E0B), + AgentStatus::Done => Some(0x22C55E), } } } @@ -805,8 +491,6 @@ impl AgentSessionState { AgentStatus::Idle } - /// Fold one rich event into the state. Pure transition function — the - /// daemon owns *when* to call it and who to tell. pub fn apply_event(&mut self, ev: &AgentEvent) { self.rich = true; if let Some(id) = &ev.session_id { @@ -824,37 +508,17 @@ impl AgentSessionState { self.status = AgentStatus::Working; self.message = None; } - // Explicit blocks from agents that distinguish them (Codex/OpenCode - // plugins): always the urgent "needs you" state. AgentEventKind::PermissionRequest | AgentEventKind::QuestionAsked => { self.status = AgentStatus::Waiting; self.message = ev.message.clone(); } - // Claude Code overloads its single Notification hook: it fires - // *mid-turn* for a permission/decision prompt (a genuine block worth - // the amber "needs you" state), but ALSO fires *between* turns as an - // idle "Claude is waiting for your input" reminder — which must not - // masquerade as a block. Escalate only when a turn is actually in - // flight; otherwise it's a passive nudge and the current state - // (typically Done, freshly replied) stands. Keyed on turn phase, not - // the message text, so it survives version/locale changes. AgentEventKind::Notification => { if self.status == AgentStatus::Working { self.status = AgentStatus::Waiting; self.message = ev.message.clone(); } } - // A tool call finished. Only meaningful as the recovery edge out - // of a block: the user answered the permission prompt / question, - // the approved tool ran, so the turn is moving again — no agent - // emits an explicit "permission replied" signal here, so the next - // tool completion is that signal. Guarded on Waiting so the steady - // stream of completions during normal work is a no-op and can - // never overwrite Done between turns. AgentEventKind::ToolComplete => { - // The count moves even when the status doesn't: a tool call is - // the one signal that the working tree may have just changed - // under a turn that won't end for minutes. self.activity = self.activity.wrapping_add(1); if self.status == AgentStatus::Waiting { self.status = AgentStatus::Working; @@ -865,10 +529,6 @@ impl AgentSessionState { self.status = AgentStatus::Done; self.message = ev.message.clone(); } - // The agent session ended but its id stays: Claude & friends can - // resume an *ended* session, which is exactly what restore does. - // Its cwd claim does NOT stay: with no agent running, the pane's - // real (proc-observed) directory is the truth again. AgentEventKind::SessionEnd => { self.status = AgentStatus::Idle; self.message = None; @@ -878,11 +538,6 @@ impl AgentSessionState { } } -/// The event vocabulary of the sentinel protocol (`"event"` in the JSON). -/// Deliberately a superset of what any one agent emits: Claude Code hooks map -/// onto session-start / prompt-submit / notification / tool-complete / stop / -/// session-end, while permission-request / question-asked are there for -/// agents (Codex, OpenCode plugins) that can distinguish them. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum AgentEventKind { @@ -896,26 +551,15 @@ pub enum AgentEventKind { SessionEnd, } -/// One parsed sentinel event. #[derive(Clone, Debug, PartialEq, Eq)] pub struct AgentEvent { - /// Which agent sent it, when the payload names one we know. Lets the event - /// brand a pane even where argv detection can't see the process (a wrapper - /// we don't recognize). pub agent: Option, pub kind: AgentEventKind, pub session_id: Option, pub message: Option, - /// The agent's working directory at the moment the hook fired, when the - /// payload carries one (Claude Code sends it on every hook event). pub cwd: Option, } -/// Parse a complete OSC payload (identifier included, e.g. -/// `777;notify;tty7://cli-agent;{"v":1,…}`) into an [`AgentEvent`]. `None` for -/// anything that isn't a well-formed sentinel event — including unknown -/// `event` values, so the protocol can grow without old daemons -/// mis-classifying new events. pub fn parse_agent_event(payload: &[u8]) -> Option { let rest = payload.strip_prefix(b"777;notify;")?; let rest = rest.strip_prefix(AGENT_EVENT_SENTINEL.as_bytes())?; @@ -923,7 +567,6 @@ pub fn parse_agent_event(payload: &[u8]) -> Option { #[derive(Deserialize)] struct Wire { - // Protocol version; v1 is all that exists. Kept for forward evolution. #[serde(default)] #[allow(dead_code)] v: u32, @@ -976,7 +619,6 @@ mod tests { CLIAgent::detect_from_argv(&argv(&["cursor-agent"])), Some(CLIAgent::Cursor) ); - // A trailing separator is tolerated, matching Path::file_name. assert_eq!( CLIAgent::detect_from_argv(&argv(&["claude/"])), Some(CLIAgent::Claude) @@ -1027,8 +669,6 @@ mod tests { #[test] fn non_interpreter_does_not_match_on_arguments() { - // A file *named* like an agent, opened by an unrelated tool, must not - // trip detection — only interpreters have their args scanned. assert_eq!( CLIAgent::detect_from_argv(&argv(&["cat", "codex.md"])), None @@ -1060,18 +700,12 @@ mod tests { } } - /// The two vendors who actually brand in black keep the black field rather - /// than the mid-tone substitute monochrome marks otherwise get. #[test] fn black_branded_avatars_keep_their_brand_field() { assert_eq!(CLIAgent::Codex.accent_rgb(), 0x000000); assert_eq!(CLIAgent::Grok.accent_rgb(), 0x000000); } - /// The fallback robot glyph is a placeholder, not a resting state: an agent - /// may only sit on it while no mark is bundled, and a bundled mark may not - /// silently fall back off. Pinning the exact fallback set makes either - /// direction a deliberate edit here rather than something noticed in the UI. #[test] fn only_the_unbranded_agents_use_the_fallback_glyph() { let fallback: Vec<&str> = CLIAgent::ALL @@ -1083,7 +717,6 @@ mod tests { fallback, ["aider", "auggie", "hermes", "vibe", "antigravity", "qwen"] ); - // Everything else names a bundled mark under the agents directory. for a in CLIAgent::ALL { let path = a.icon_path(); assert!( @@ -1116,18 +749,15 @@ mod tests { CLIAgent::detect_from_argv_with(&argv(&["/home/x/bin/cc", "-c"]), &custom), Some(CLIAgent::Claude) ); - // A rule naming an unknown agent is ignored, not an error. let bogus: HashMap = [("cc".to_string(), "hal9000".to_string())].into(); assert_eq!( CLIAgent::detect_from_argv_with(&argv(&["cc"]), &bogus), None ); - // Custom rules never scan interpreter arguments. assert_eq!( CLIAgent::detect_from_argv_with(&argv(&["node", "cc/cli.js"]), &custom), None ); - // Built-ins still win on their own names. let shadow: HashMap = [("codex".to_string(), "claude".to_string())].into(); assert_eq!( CLIAgent::detect_from_argv_with(&argv(&["codex"]), &shadow), @@ -1138,13 +768,10 @@ mod tests { #[test] fn detects_from_typed_command_lines() { let none = HashMap::new(); - // Plain invocations, flags in tow. assert_eq!( CLIAgent::detect_from_command_with("claude --resume abc", &none), Some(CLIAgent::Claude) ); - // Windows launcher shapes: npm shims, absolute backslash paths, and - // case-insensitive names. assert_eq!( CLIAgent::detect_from_command_with("claude.exe", &none), Some(CLIAgent::Claude) @@ -1160,12 +787,10 @@ mod tests { CLIAgent::detect_from_command_with("CLAUDE", &none), Some(CLIAgent::Claude) ); - // PowerShell call operator + a quoted (space-free) path. assert_eq!( CLIAgent::detect_from_command_with(r#"& "C:\tools\codex.exe""#, &none), Some(CLIAgent::Codex) ); - // Interpreter-wrapped, Windows separators in the script path. assert_eq!( CLIAgent::detect_from_command_with( r"node C:\x\node_modules\@anthropic-ai\claude-code\cli.js", @@ -1177,7 +802,6 @@ mod tests { CLIAgent::detect_from_command_with("npx.cmd @google/gemini-cli", &none), Some(CLIAgent::Gemini) ); - // Non-interpreter launchers never match on their arguments. assert_eq!( CLIAgent::detect_from_command_with("notepad claude.txt", &none), None @@ -1187,7 +811,6 @@ mod tests { None ); assert_eq!(CLIAgent::detect_from_command_with("", &none), None); - // Custom rules apply to the typed launcher too. let custom: HashMap = [("cc".to_string(), "claude".to_string())].into(); assert_eq!( CLIAgent::detect_from_command_with("cc -c", &custom), @@ -1206,14 +829,11 @@ mod tests { assert_eq!(ev.session_id.as_deref(), Some("abc-123")); assert!(ev.message.as_deref().unwrap().contains("permission")); - // A plain OSC 777 notification is NOT an event. assert_eq!(parse_agent_event(b"777;notify;Build;done"), None); - // Unknown event names are dropped (forward evolution). assert_eq!( parse_agent_event(br#"777;notify;tty7://cli-agent;{"event":"quantum-leap"}"#), None ); - // Malformed JSON is dropped. assert_eq!( parse_agent_event(b"777;notify;tty7://cli-agent;{oops"), None @@ -1241,8 +861,6 @@ mod tests { s.apply_event(&ev(AgentEventKind::PromptSubmit, None, None)); assert_eq!(s.status, AgentStatus::Working); - // A Notification arriving MID-TURN (while Working) is a real block — - // a permission/decision prompt — so it escalates to Waiting. s.apply_event(&ev( AgentEventKind::Notification, Some("Claude needs your permission"), @@ -1251,28 +869,19 @@ mod tests { assert_eq!(s.status, AgentStatus::Waiting); assert!(s.message.as_deref().unwrap().contains("permission")); - // The user approved: the granted tool runs to completion, and that - // completion is the "back to work" edge — amber flips back to blue - // instead of lingering for the rest of the turn. s.apply_event(&ev(AgentEventKind::ToolComplete, None, None)); assert_eq!(s.status, AgentStatus::Working); assert_eq!(s.message, None, "the stale permission prompt is cleared"); - // Tool completions during normal work are a no-op, not state churn. s.apply_event(&ev(AgentEventKind::ToolComplete, None, None)); assert_eq!(s.status, AgentStatus::Working); s.apply_event(&ev(AgentEventKind::Stop, None, None)); assert_eq!(s.status, AgentStatus::Done); - // A straggler tool-complete after the turn ended must not resurrect - // Working and hide the unread green dot. s.apply_event(&ev(AgentEventKind::ToolComplete, None, None)); assert_eq!(s.status, AgentStatus::Done); - // A Notification arriving BETWEEN turns (while Done) is Claude Code's - // idle "waiting for your input" nudge, NOT a block — it must not flip - // the finished-and-green session to amber "needs you". s.apply_event(&ev( AgentEventKind::Notification, Some("Claude is waiting for your input"), @@ -1284,18 +893,11 @@ mod tests { "an idle notification between turns must not fabricate a block" ); - // Session end goes idle but KEEPS the id — ended sessions resume. s.apply_event(&ev(AgentEventKind::SessionEnd, None, None)); assert_eq!(s.status, AgentStatus::Idle); assert_eq!(s.session_id.as_deref(), Some("sid-1")); } - /// Tool completions are deliberately a *status* no-op during normal work - /// (the assertions above), which leaves consumers watching the status with - /// no way to tell that an agent mid-turn just wrote a file. `activity` is - /// what makes them observable: it moves on every completion, in every - /// status, and never rewinds — the sidebar's git probe compares it against - /// the value it last saw. #[test] fn tool_completions_count_even_when_the_status_holds_still() { let ev = |kind| AgentEvent { @@ -1316,8 +918,6 @@ mod tests { assert_eq!(s.activity, n, "…while the counter is what moves"); } - // A straggler after the turn ended still counts: it may well have - // written a file, and it must not be mistaken for "nothing happened". s.apply_event(&ev(AgentEventKind::Stop)); s.apply_event(&ev(AgentEventKind::ToolComplete)); assert_eq!( @@ -1327,16 +927,10 @@ mod tests { ); assert_eq!(s.activity, 4); - // Session end resets plenty of state but not this — a rewind to 0 would - // read to a delta-comparing consumer as one more tool call. s.apply_event(&ev(AgentEventKind::SessionEnd)); assert_eq!(s.activity, 4); } - /// The agent's cwd claim: any event carrying one sets it, later events - /// without one leave it alone (mid-turn events keep the worktree path - /// alive), and session end drops it — an exited agent must not pin the - /// pane's git line to a directory nothing runs in anymore. #[test] fn session_state_tracks_and_releases_the_agent_cwd() { use std::path::PathBuf; @@ -1353,7 +947,6 @@ mod tests { s.apply_event(&ev(AgentEventKind::SessionStart, Some("/repo"))); assert_eq!(s.cwd.as_deref(), Some(std::path::Path::new("/repo"))); - // EnterWorktree lands as a tool-complete carrying the new directory. s.apply_event(&ev( AgentEventKind::ToolComplete, Some("/repo/.claude/worktrees/fix-x"), @@ -1363,7 +956,6 @@ mod tests { Some(std::path::Path::new("/repo/.claude/worktrees/fix-x")) ); - // An event without a cwd (another agent's sparser payload) keeps it. s.apply_event(&ev(AgentEventKind::Stop, None)); assert_eq!( s.cwd.as_deref(), @@ -1384,17 +976,13 @@ mod tests { CLIAgent::Codex.resume_command("th_read.9", None).as_deref(), Some("codex resume th_read.9") ); - // Pi targets a session by id through `--session`, not through its - // boolean `--resume` (which only opens the picker). assert_eq!( CLIAgent::Pi .resume_command("0199c3f2-1b0e-7c3a-9f21-6d4b8e2a5c17", None) .as_deref(), Some("pi --session 0199c3f2-1b0e-7c3a-9f21-6d4b8e2a5c17") ); - // No resume flag known → None. assert_eq!(CLIAgent::Aider.resume_command("abc", None), None); - // An id carrying shell syntax is refused outright. assert_eq!(CLIAgent::Claude.resume_command("abc; rm -rf /", None), None); assert_eq!(CLIAgent::Claude.resume_command("$(boom)", None), None); assert_eq!(CLIAgent::Claude.resume_command("", None), None); @@ -1404,7 +992,6 @@ mod tests { fn resume_carries_launch_flags() { let argv = |parts: &[&str]| parts.iter().map(|s| s.to_string()).collect::>(); - // The headline case: the user's mode flags survive the restart. assert_eq!( CLIAgent::Claude .resume_command( @@ -1414,15 +1001,12 @@ mod tests { .as_deref(), Some("claude --dangerously-skip-permissions --resume abc-123") ); - // Value-taking flags ride along whole. assert_eq!( CLIAgent::Claude .resume_command("abc", Some(&argv(&["claude", "--model", "opus"]))) .as_deref(), Some("claude --model opus --resume abc") ); - // Interpreter-wrapped launch: flags start after the token naming the - // agent, and the table's launcher name is what replays. assert_eq!( CLIAgent::Claude .resume_command( @@ -1436,7 +1020,6 @@ mod tests { .as_deref(), Some("claude --dangerously-skip-permissions --resume abc") ); - // A stale session-targeting flag is stripped — the new id must win. assert_eq!( CLIAgent::Claude .resume_command( @@ -1446,8 +1029,6 @@ mod tests { .as_deref(), Some("claude --model opus --resume new-id") ); - // Codex resumes via its subcommand, flags after the positional id; a - // relaunched `codex resume ` sheds the old subcommand + id. assert_eq!( CLIAgent::Codex .resume_command("id-1", Some(&argv(&["codex", "--yolo"]))) @@ -1460,8 +1041,6 @@ mod tests { .as_deref(), Some("codex resume id-2 --yolo") ); - // Anything shell-unsafe or positional-shaped drops the WHOLE tail — - // resume still works, just bare. assert_eq!( CLIAgent::Claude .resume_command( @@ -1477,7 +1056,6 @@ mod tests { .as_deref(), Some("claude --resume abc") ); - // A leading env assignment doesn't mis-anchor the flag tail. assert_eq!( CLIAgent::Claude .resume_command( @@ -1491,8 +1069,6 @@ mod tests { .as_deref(), Some("claude --dangerously-skip-permissions --resume abc") ); - // Two consecutive bare words = a positional prompt, not a flag value — - // it must not re-submit itself into the resumed session. assert_eq!( CLIAgent::Claude .resume_command( @@ -1502,8 +1078,6 @@ mod tests { .as_deref(), Some("claude --resume abc") ); - // Codex `--last` targets "most recent" and would contradict the - // explicit id → stripped. assert_eq!( CLIAgent::Codex .resume_command( @@ -1513,9 +1087,6 @@ mod tests { .as_deref(), Some("codex resume id-3 --yolo") ); - // Pi: mode flags replay, but every way of naming a *different* session - // is stripped so the injected id wins — including the boolean picker - // flags. assert_eq!( CLIAgent::Pi .resume_command("id-a", Some(&argv(&["pi", "--model", "opus"]))) @@ -1540,9 +1111,6 @@ mod tests { .as_deref(), Some("pi --model opus --session id-b") ); - // `--no-session` is the user asking for an ephemeral pane: Pi mints an - // id but never writes the file, so resuming it would open an empty - // session *and* override the opt-out — no resume command at all. assert_eq!( CLIAgent::Pi.resume_command( "id-x", @@ -1550,8 +1118,6 @@ mod tests { ), None ); - // `--session-dir` says where sessions live — the injected id needs it, - // so it is deliberately *not* stripped. assert_eq!( CLIAgent::Pi .resume_command( @@ -1567,7 +1133,6 @@ mod tests { .as_deref(), Some("pi --session-dir /w/.sessions --session id-c") ); - // No token names the agent (custom wrapper rule) → bare. assert_eq!( CLIAgent::Claude .resume_command( @@ -1577,22 +1142,18 @@ mod tests { .as_deref(), Some("claude --resume abc") ); - // Amp: global mode flags ride after the `threads continue` positional. assert_eq!( CLIAgent::Amp .resume_command("t-1", Some(&argv(&["amp", "--dangerously-allow-all"]))) .as_deref(), Some("amp threads continue t-1 --dangerously-allow-all") ); - // A relaunch via `amp threads continue …` is subcommand-shaped, not - // flag-shaped → bare (the gate rejects the leading bare word). assert_eq!( CLIAgent::Amp .resume_command("t-2", Some(&argv(&["amp", "threads", "continue", "t-1"]))) .as_deref(), Some("amp threads continue t-2") ); - // Copilot resumes by flag, stale session targeting stripped. assert_eq!( CLIAgent::Copilot .resume_command( @@ -1606,8 +1167,6 @@ mod tests { CLIAgent::Copilot.resume_command("s-9", None).as_deref(), Some("copilot --resume s-9") ); - // Grok: mode flags survive, and every way of naming another session is - // stripped so the injected id is the only target left. assert_eq!( CLIAgent::Grok .resume_command("g-2", Some(&argv(&["grok", "--model", "grok-code"]))) @@ -1623,8 +1182,6 @@ mod tests { .as_deref(), Some("grok --resume g-2") ); - // `--worktree` would mint a fresh git worktree on every restore, and - // `--worktree-ref` can't survive without it. assert_eq!( CLIAgent::Grok .resume_command( @@ -1638,9 +1195,6 @@ mod tests { #[test] fn fork_commands_cover_exactly_the_agents_with_a_verified_fork() { - // Each command below was checked against the installed CLI's own - // `--help`; anything unverified stays `None` so the UI never offers a - // row that can only produce a usage error. assert_eq!( CLIAgent::Codex.fork_command("abc-123", None).as_deref(), Some("codex fork abc-123") @@ -1658,8 +1212,6 @@ mod tests { Some("opencode --session s-1 --fork") ); - // Not forkable: resumable but with no fork flag (Gemini, Copilot, - // Cursor, Amp), and agents tty7 can't even resume. for agent in [ CLIAgent::Gemini, CLIAgent::Copilot, @@ -1676,9 +1228,6 @@ mod tests { ); } - // `fork_label` is the UI's capability gate, so it must agree with - // `fork_command` for every agent — no menu row without a command, and - // no command the menu can't name. for agent in CLIAgent::ALL { assert_eq!( agent.fork_label().is_some(), @@ -1691,8 +1240,6 @@ mod tests { #[test] fn fork_commands_are_shell_safe() { - // Same id gate as resume: an id carrying shell syntax is refused - // outright rather than escaped, because it reaches a command line. for id in ["abc; rm -rf /", "$(boom)", "", "a b"] { assert_eq!( CLIAgent::Codex.fork_command(id, None), @@ -1707,7 +1254,6 @@ mod tests { fn fork_carries_launch_flags_and_sheds_stale_session_targeting() { let argv = |parts: &[&str]| parts.iter().map(|s| s.to_string()).collect::>(); - // The user's mode flags ride onto the fork exactly as onto a resume. assert_eq!( CLIAgent::Codex .fork_command("id-1", Some(&argv(&["codex", "--yolo"]))) @@ -1724,9 +1270,6 @@ mod tests { Some("claude --dangerously-skip-permissions --resume abc --fork-session") ); - // Fork of a fork: the pane's own argv is a fork command, so the stale - // subcommand + id (codex) and the stale `--fork-session` / `--fork` - // modifier (claude, grok, opencode) must not replay. assert_eq!( CLIAgent::Codex .fork_command("id-2", Some(&argv(&["codex", "fork", "id-1", "--yolo"]))) @@ -1761,8 +1304,6 @@ mod tests { Some("opencode --session s-2 --fork") ); - // Restoring a forked pane must *continue* it, not fork it again: the - // resume command built from a fork's own argv carries no fork flag. assert_eq!( CLIAgent::Codex .resume_command("id-2", Some(&argv(&["codex", "fork", "id-1", "--yolo"]))) @@ -1799,7 +1340,6 @@ mod tests { ] { assert!(st.dot_rgb().is_some()); } - // Wire form is kebab-case (shared with the JSON protocol). assert_eq!( serde_json::to_string(&AgentStatus::Waiting).unwrap(), "\"waiting\"" diff --git a/crates/tty7-core/src/core/config.rs b/crates/tty7-core/src/core/config.rs index 78b5f1cc..36f90ce7 100644 --- a/crates/tty7-core/src/core/config.rs +++ b/crates/tty7-core/src/core/config.rs @@ -1,45 +1,17 @@ -//! User configuration loaded from `~/.config/tty7/config.json`. -//! -//! Every field is optional in the file: a missing or malformed config falls back -//! to the built-in defaults (which mirror the values previously hardcoded across -//! the app), so the terminal always starts cleanly. Parse failures are logged via -//! `log::warn!` rather than panicking. - use std::collections::HashMap; use std::path::PathBuf; use std::sync::{Arc, OnceLock}; use serde::{Deserialize, Serialize}; -/// The OpenType features configured for terminal text, as an ordered tag → value -/// list (`[("calt", 1), ("liga", 1)]`). -/// -/// This is a deliberate, behavior-identical replica of `gpui::FontFeatures`: the -/// field it backs is a real key in the user's `config.json`, so its wire format -/// is frozen, but `Config` itself has to parse on a headless machine that never -/// links gpui. The GUI crate converts this into the gpui type at the one place -/// it hands features to the text system (`ui::app::gpui_font_features`), and a -/// test there pins the two serializations together. -/// -/// Wire format, matching gpui byte for byte: -/// - a JSON object of four-character alphanumeric tags to `true` / `false` / -/// a non-negative integer; -/// - `true` → 1, `false` → 0, an integer passes through; -/// - a tag that isn't four alphanumeric characters, a negative or fractional -/// value, or a `null` value is logged and skipped rather than failing the -/// whole config parse; -/// - serialization always writes integers, so `{"calt":true}` round-trips as -/// `{"calt":1}`. #[derive(Default, Clone, Eq, PartialEq, Hash)] pub struct FontFeatures(pub Arc>); impl FontFeatures { - /// The tag → value pairs, in the order they were parsed. pub fn tag_value_list(&self) -> &[(String, u32)] { self.0.as_slice() } - /// Whether `calt` is enabled, or `None` when the feature isn't present. pub fn is_calt_enabled(&self) -> Option { self.0 .iter() @@ -58,7 +30,6 @@ impl std::fmt::Debug for FontFeatures { } } -/// A feature value as it appears in `config.json`: `true`/`false` or a number. #[derive(Debug, Serialize, Deserialize)] #[serde(untagged)] enum FeatureValue { @@ -66,8 +37,6 @@ enum FeatureValue { Number(serde_json::Number), } -/// A tag in the OpenType sense: exactly four ASCII alphanumerics (`calt`, -/// `ss01`, `zero`). fn is_valid_feature_tag(tag: &str) -> bool { tag.len() == 4 && tag.chars().all(|c| c.is_ascii_alphanumeric()) } @@ -139,372 +108,133 @@ impl serde::Serialize for FontFeatures { } } -/// Top-level configuration. The GUI installs it as a GPUI global (through -/// `ui`'s `ConfigGlobal` wrapper) so any view can read it; the headless server -/// just holds it. #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(default)] pub struct Config { - /// Primary monospace font face. pub font_family: String, - /// Fallback faces tried, in order, for glyphs the primary lacks. pub font_fallbacks: Vec, - /// Optional distinct face for bold cells. `None` reuses `font_family` with a - /// synthesized bold weight (the current behavior). pub font_family_bold: Option, - /// Optional distinct face for italic cells. `None` reuses `font_family` with a - /// synthesized italic slant. pub font_family_italic: Option, - /// Optional OpenType font features for terminal text. When absent, tty7 keeps - /// terminal-safe defaults and disables contextual ligatures; when present, - /// this map is handed to gpui's text system as-is (for example - /// `{ "calt": true }`) — see [`FontFeatures`]. pub font_features: Option, - /// Base font size in pixels. pub font_size: f32, - /// Line height as a multiple of the font size (e.g. 1.35 → a 13px font gets - /// ~18px rows). Larger values loosen the vertical rhythm; smaller ones pack - /// rows tighter. Clamped to a sane range when applied. pub line_height: f32, - /// Startup theme mode: "dark" or "light". pub theme: String, - /// Selected color theme id. Resolves against the theme registry (built-ins + - /// `~/.config/tty7/themes/*`); unknown ids fall back to the default theme. The - /// native chrome is forced to match the theme's light/dark brightness. pub theme_preset: String, - /// Follow the OS light/dark appearance. When `true` the active theme is - /// resolved from `theme_preset_light` / `theme_preset_dark` by the current - /// system appearance (switching live when the OS mode flips) and - /// `theme_preset` is ignored; the native chrome follows the OS instead of - /// being pinned to the theme. pub theme_follow_system: bool, - /// Theme id used while `theme_follow_system` is on and the OS is in light - /// mode. Same registry/fallback rules as `theme_preset`. pub theme_preset_light: String, - /// Theme id used while `theme_follow_system` is on and the OS is in dark - /// mode. Same registry/fallback rules as `theme_preset`. pub theme_preset_dark: String, - /// Global window-opacity override, 0.2–1.0. `None` (the default) follows the - /// active theme's own `opacity`; when set it applies to every theme, so a - /// chosen translucency survives theme switches. pub window_opacity: Option, - /// Global window-blur override. `None` follows the active theme's `blur`. pub window_blur: Option, - /// Fade unfocused panes in a split tab so the focused terminal reads as - /// foreground. On by default; when off every pane renders at full opacity - /// and only focus (cursor, etc.) distinguishes the active one. #[serde(default = "default_true")] pub dim_inactive_panes: bool, - /// Optional keybinding overrides: action name (e.g. "NewTab") → keystroke - /// (e.g. "secondary-t", which is ⌘ on macOS and Ctrl elsewhere). Unknown - /// actions and unparseable keystrokes are ignored (with a warning) so a bad - /// entry never blocks startup. pub keybindings: HashMap, - /// Keybinding preset layered between the built-in defaults and the user's - /// `keybindings` overrides. `"default"` (the default) adds nothing; `"tmux"` - /// remaps pane/tab actions onto `prefix`-led sequences (e.g. `ctrl-b c`). - /// Parsed leniently — an unknown value resolves back to the default preset. #[serde(default = "default_preset")] pub keybinding_preset: String, - /// The prefix chord the `tmux` preset builds its sequences from (tmux's - /// `C-b`). Only meaningful when `keybinding_preset` is `"tmux"`. Validated as - /// a gpui keystroke where it's consumed; a common alternative is `ctrl-a`. #[serde(default = "default_prefix")] pub prefix: 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 (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, - // ── Behavior ──────────────────────────────────────────────────────────── - /// Detect URLs (OSC 8 hyperlinks + bare URLs in the text), underline them on - /// hover, and open them on ⌘/Ctrl-click. On by default. pub link_url: bool, - /// Optional command template run when ⌘/Ctrl-clicking a detected file-path - /// link, instead of tty7's built-in "open in the default app" behavior. The - /// template is tokenized on whitespace and the placeholders `{path}`, - /// `{line}`, and `{column}` are substituted per argument; an argument that - /// contains a placeholder with no value (e.g. `{line}` on a link that has no - /// line number) is dropped. `None` (the default) keeps the built-in open. - /// Example: `"herdr edit {path} --line {line}"`. pub link_file_command: Option, - /// When a pane is in a detected SSH session, Command-clicking loopback URLs - /// opens them through a temporary local SSH port-forward. Off by default - /// because it starts background `ssh` processes. pub ssh_loopback_forward: bool, - /// Blink the block cursor while the terminal is focused. On by default; when - /// off the cursor stays solid. pub cursor_blink: bool, - /// Scrollback lines kept per pane. Clamped to alacritty's ceiling (100 000) - /// in `sanitize`. Only applies to newly spawned/attached panes. pub scrollback_limit: usize, - /// Where a newly opened tab lands relative to the active one. #[serde(default, deserialize_with = "de_lenient")] pub new_tab_position: NewTabPosition, - /// Where the tab bar is rendered: a vertical list down the left side - /// (`left`, the default) or a horizontal strip in the title bar (`top`). #[serde(default, deserialize_with = "de_lenient")] pub tab_bar_position: TabBarPosition, - /// Width (px) of the vertical tab sidebar (only meaningful when - /// `tab_bar_position` is `left`). Set by dragging the sidebar's right edge; - /// the live layout re-clamps it to `[180, window_width/2]`. #[serde(default = "default_sidebar_width")] pub sidebar_width: f32, - /// Whether the vertical tab sidebar starts collapsed out of the layout (only - /// meaningful when `tab_bar_position` is `left`). Distinct from - /// `tab_bar_position`: collapsing hides the rail *without* falling back to - /// the horizontal title-bar strip, so the terminal gets the full width and - /// re-expanding restores the same rail. Toggled by `ToggleLeftPanel`. - /// - /// Like `right_panel_visible` and `right_panel_tab` below, this is the value - /// a *newly opened window* starts with, not the live state of any window on - /// screen — that lives on [`Tty7App`](crate::ui::app::Tty7App), so toggling - /// one window's chrome leaves every other window alone. Each toggle writes - /// back here, so a new window inherits the last choice made anywhere. #[serde(default)] pub sidebar_collapsed: bool, - /// Whether the right detail panel (session info / changes / files) starts - /// docked open. Toggled by `ToggleRightPanel`. Per-window at runtime — see - /// `sidebar_collapsed`. #[serde(default)] pub right_panel_visible: bool, - /// Width (px) of the right detail panel. Re-clamped by the live layout the - /// same way `sidebar_width` is. Unlike the two flags around it this stays - /// shared: a width is a preference, not a view state, and every window - /// tracking the config is what makes a drag in one hold in the next. #[serde(default = "default_right_panel_width")] pub right_panel_width: f32, - /// Which tab the right detail panel starts on, so reopening it lands where - /// it was left. Per-window at runtime — see `sidebar_collapsed`. #[serde(default, deserialize_with = "de_lenient")] pub right_panel_tab: RightPanelTab, - /// How the vertical tab sidebar arranges its rows (only meaningful when - /// `tab_bar_position` is `left`): grouped under a header per git work tree - /// (`repo`, the default), or one flat list (`none`). #[serde(default, deserialize_with = "de_lenient")] pub sidebar_grouping: SidebarGrouping, - /// Whether clicking a sidebar row's `+N −N` working-tree counts opens the - /// diff overlay. Off leaves the branch and the counts exactly as they are — - /// they are a readout worth having on their own — and only takes away the - /// click target and its pointer cursor, so the press falls through to - /// ordinary tab activation. On by default: the overlay is the reason the - /// counts are there for most people, and the large-diff cost it used to - /// carry is now bounded by the diff parser's budgets. This is the escape - /// hatch for anyone who wants the numbers without the viewer. #[serde(default = "default_true")] pub sidebar_diff_preview: bool, - /// When to post a desktop notification after a long foreground command - /// finishes. #[serde(default, deserialize_with = "de_lenient")] pub notify_on_command_finish: NotifyMode, - /// On startup, ask GitHub whether a newer release has shipped and, if so, - /// surface a "download" prompt in Settings → About. Never downloads or - /// self-updates — it only links to the Releases page. On by default; set to - /// `false` to skip the network call entirely (offline / privacy). pub check_for_updates: bool, - /// Seconds a foreground command must run before it's eligible for a - /// "command finished" notification (further gated by - /// `notify_on_command_finish`). Defaults to 10; clamped in `sanitize` so a - /// hand-edit can't set a degenerate value. #[serde(default = "default_notify_threshold_secs")] pub notify_threshold_secs: u64, - /// Restore the previous session (tab/split layout + each pane's cwd) on - /// launch. On by default; when off, every launch starts with a single fresh - /// terminal instead of the last window's layout. The session is still saved - /// on quit — it's just ignored at startup. #[serde(default = "default_true")] pub restore_session: bool, - /// Show the system tray / menu bar status item: the icon flips to an - /// attention state when a coding agent needs input, and its menu lists the - /// agent panes. On by default; the tray's poll loop re-reads this every - /// second, so toggling it (Settings or a `config.json` edit) applies live. #[serde(default = "default_true")] pub show_tray_icon: bool, - /// Ask before closing the *last* window (the close that also quits the app). - /// On by default, which is the behavior every build so far has had. - /// - /// The prompt was only ever a teaching device, not a safety net: ⌘Q, the - /// tray's Quit and the palette's Quit all leave without asking, and nothing - /// is lost either way — the panes keep running in the daemon. So once the - /// user has learned that (Settings states it permanently under "How sessions - /// work"), being asked on every quit is pure friction. Off makes the last - /// window close exactly like any other: detach the workspace, quit. #[serde(default = "default_true")] pub confirm_window_close: bool, - /// How the terminal bell (BEL / `^G`) is signalled. Defaults to a brief - /// visual flash (the current behavior). #[serde(default, deserialize_with = "de_lenient")] pub bell: BellMode, - /// Tab at the prompt opens tty7's own completion menu (commands, paths, - /// per-command signatures). On by default. When off — or whenever the - /// engine has nothing to offer — the prompt line is handed to the shell - /// and Tab goes to the PTY, so the shell's native completion (compsys, - /// fzf-tab, …) answers instead. #[serde(default = "default_true")] pub tab_completion: bool, - /// Ctrl+R at the prompt opens tty7's fuzzy history menu. On by default. - /// When off, the prompt line is handed to the shell and Ctrl+R goes to the - /// PTY, so whatever is bound there answers instead — readline/zle's own - /// reverse-i-search, or a widget like fzf's or percol's (#163). #[serde(default = "default_true")] pub history_search: bool, - // ── Appearance ────────────────────────────────────────────────────────── - /// The shape drawn for the terminal cursor. #[serde(default, deserialize_with = "de_lenient")] pub cursor_style: CursorStyle, - // ── Input / Mouse ─────────────────────────────────────────────────────── - /// macOS only: treat the Option (⌥) key as Alt/Meta. On, an Option chord - /// sends the ESC-prefixed sequence Meta bindings expect (Option+B → `ESC b`, - /// readline's backward-word), like Ghostty's `macos-option-as-alt` / - /// iTerm2's "Option as Meta". Off (the default), Option keeps its macOS - /// role of composing special characters (Option+B → `∫`). Ignored on other - /// platforms, where Alt always carries the Meta meaning. pub macos_option_as_alt: bool, - /// Hide the OS mouse pointer while typing; it reappears on the next mouse - /// move. Off by default. pub mouse_hide_while_typing: bool, - /// Focus a pane as soon as the mouse moves over it, without a click. Off by - /// default; handy with split panes. pub focus_follows_mouse: bool, - /// Multiplier applied to mouse-wheel scroll distance. 1.0 = one row per wheel - /// line (the raw amount). Clamped to a sane band in `sanitize`. pub mouse_scroll_multiplier: f32, - /// Report mouse events (click / drag / wheel) to full-screen apps that ask - /// for them (vim, tmux, htop). On by default. When off, the mouse always - /// stays local — native selection and scrollback — regardless of what the - /// app requested. Holding Shift already forces local behavior for a single - /// gesture even while this is on. #[serde(default = "default_true")] pub mouse_reporting: bool, - /// Drop trailing whitespace from each copied line. Off by default. pub clipboard_trim_trailing_spaces: bool, - /// Copy a mouse selection to the clipboard as soon as the gesture ends, - /// without ⌘C (à la Ghostty/iTerm2's copy-on-select). Off by default — - /// the clipboard is never overwritten by a stray selection unless opted - /// into. pub copy_on_select: bool, - /// Double-click smart selection: expand the selection to the whole URL, - /// email address, file path, or matching bracket pair under the cursor - /// when the plain word sits inside one. On by default; off restores the - /// bare word-boundary double-click. #[serde(default = "default_true")] pub smart_select: bool, - /// Characters (besides whitespace) that end a double-click word - /// selection, in both the terminal grid and the prompt's command editor. - /// The default mirrors alacritty's semantic escape set — note `/ . - _` - /// are *not* separators, so paths select as one word. JSON-only (no GUI - /// widget yet). #[serde(default = "default_word_separators")] pub word_separators: String, - /// Window state at launch: normal / maximized / fullscreen. #[serde(default, deserialize_with = "de_lenient")] pub startup_mode: StartupMode, - /// Reopen a normal (non-maximized/fullscreen) startup window at the size - /// and position it had when tty7 last quit. On by default; off opens - /// centered at the built-in default size. The remembered geometry itself - /// lives in `window.json` (see [`crate::core::window_state`]), not here. #[serde(default = "default_true")] pub remember_window_size: bool, - // ── Shell environment ─────────────────────────────────────────────────── - /// Where a shell starts when the client doesn't pass an explicit directory - /// (a new tab inheriting the active pane's cwd, or session restore, always - /// win over this). #[serde(default)] pub working_directory: WorkingDirectory, - /// Extra environment variables injected into every spawned shell, on top of - /// the inherited environment. Currently JSON-only (no GUI widget yet); a - /// key/value editor is a future addition. #[serde(default)] pub env: HashMap, - // ── SSH connection manager ─────────────────────────────────────────────── - /// Saved SSH connection profiles (the connection-manager data layer). Secrets - /// never live here — a profile only carries a `credential_ref` naming its OS - /// keychain entry (see [`crate::core::keychain`]). This is distinct from the - /// live `ssh_config` alias *discovery* in [`crate::core::ssh_config`]: these - /// are user-owned, editable profiles that can be imported from `~/.ssh/config`. #[serde(default)] pub ssh_profiles: Vec, - /// Global default for verifying SSH host keys against `known_hosts` on the - /// native (russh) path. On by default (never weaken security silently). A - /// per-profile `verify_host_keys` override wins over this when set; this is - /// the fallback when a profile leaves it unset and for QuickConnect. Turning - /// it off disables unknown/changed-host-key confirmation entirely — a - /// deliberate, documented escape hatch (PRD FR-S4). #[serde(default = "default_true")] pub verify_host_keys: bool, - /// Global default for the "confirm before closing a live SSH session" - /// prompt (PRD FR-E3). Off by default (closing is unsurprising for most - /// panes). A per-profile `warn_on_close: Some(true/false)` override wins over - /// this when set; this is the fallback for profiles that leave it unset and - /// for QuickConnect panes. #[serde(default)] pub ssh_warn_on_close: bool, - /// Per-profile usage stats driving the palette's frecency ordering (PRD - /// FR-P3): a saved profile's id → how many times it was connected and when it - /// was last used. Bumped on every connect; read to rank the palette's profile - /// rows. Entries for deleted profiles are harmless (never surfaced). #[serde(default)] pub ssh_profile_frecency: HashMap, - /// Per-command usage for the palette's "Recent" group, keyed by the stable - /// id in `ui::palette::CommandKind::id`. The static command list is ordered - /// by hand, which means the first screenful is whatever the author typed - /// first rather than what this user actually runs; this is what lets the - /// palette lead with the latter. Only commands with a stable id are tracked - /// — a "switch to tab 3" is not a thing to be recently-used. #[serde(default)] pub command_frecency: HashMap, - // ── CLI coding agents ──────────────────────────────────────────────────── - /// User-defined agent-detection rules: a command basename → an agent slug - /// (`{"cc": "claude", "my-codex": "codex"}`), so personal wrappers get - /// branded like the agent they launch. Complements the built-in registry in - /// [`crate::core::cli_agent`]; built-ins win on their own names. The daemon - /// reads this once per process (restart the daemon to apply changes). #[serde(default)] pub agent_commands: HashMap, - /// On session restore, when a pane can't re-attach (the daemon lost it — - /// reboot, daemon restart) but it was running a coding agent whose native - /// session id we captured, type that agent's resume command into the fresh - /// shell (`claude --resume `, `codex resume `, …) so the - /// conversation continues where it left off. cmux-style; on by default. #[serde(default = "default_true")] pub restore_agent_sessions: bool, } -/// One saved profile's usage record for palette frecency (see -/// [`Config::ssh_profile_frecency`]). #[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)] #[serde(default)] pub struct ProfileUsage { - /// Times this profile has been connected. pub count: u32, - /// Unix timestamp (seconds) of the most recent connect. pub last_used: u64, } impl ProfileUsage { - /// A frecency score combining frequency (how often) with recency (how - /// recently), so the palette floats both heavily-used and just-used profiles - /// to the top. Recency decays smoothly over days; `now` is unix seconds. pub fn score(&self, now: u64) -> f64 { if self.count == 0 { return 0.0; } let age_days = now.saturating_sub(self.last_used) as f64 / 86_400.0; - // Frequency, discounted by how stale the last use is (halves ~weekly). self.count as f64 / (1.0 + age_days / 7.0) } } -/// The current unix time in whole seconds (0 before the epoch, which never -/// happens). Used to stamp [`ProfileUsage::last_used`]. pub fn unix_now() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -512,157 +242,90 @@ pub fn unix_now() -> u64 { .unwrap_or(0) } -/// Policy for a shell's starting directory (see [`Config::working_directory`]). #[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)] #[serde(default)] pub struct WorkingDirectory { - /// Which base directory to use. #[serde(deserialize_with = "de_lenient")] pub strategy: WdStrategy, - /// The directory used when `strategy` is [`WdStrategy::Custom`]. Kept even - /// while another strategy is active so toggling back restores the last path. pub path: String, } -/// The base-directory strategy for a freshly spawned shell. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)] #[serde(rename_all = "kebab-case")] pub enum WdStrategy { - /// Inherit the daemon's current directory (falling back to `$HOME` when it's - /// unavailable / a bare `/`). The current behavior. #[default] Inherit, - /// Always start in the user's home directory. Home, - /// Always start in [`WorkingDirectory::path`]. Custom, } -/// Window state applied when tty7 launches (see [`Config::startup_mode`]). #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)] #[serde(rename_all = "kebab-case")] pub enum StartupMode { - /// A regular centered window at the default size (the current behavior). #[default] Normal, - /// Maximized (zoomed) to fill the work area. Maximized, - /// Native fullscreen. Fullscreen, } -/// The shape drawn for the block cursor (see [`Config::cursor_style`]). #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)] #[serde(rename_all = "kebab-case")] pub enum CursorStyle { - /// A filled rectangle covering the whole cell (the classic block). #[default] Block, - /// A thin vertical bar at the cell's left edge (i-beam). Bar, - /// A thin horizontal line along the cell's baseline. Underline, } -/// Where [`Config::new_tab_position`] inserts a freshly opened tab. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)] #[serde(rename_all = "kebab-case")] pub enum NewTabPosition { - /// Immediately after the currently active tab (the current behavior). #[default] AfterCurrent, - /// At the very end of the tab strip. End, } -/// Where the tab bar is rendered (see [`Config::tab_bar_position`]). #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)] #[serde(rename_all = "kebab-case")] pub enum TabBarPosition { - /// A horizontal strip of chips in the title bar. Top, - /// A vertical list down the left side of the window (a tab sidebar). #[default] Left, } -/// How the vertical tab sidebar arranges its rows (see -/// [`Config::sidebar_grouping`]). #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)] #[serde(rename_all = "kebab-case")] pub enum SidebarGrouping { - /// Group tabs under a header per git repository (linked worktrees fold - /// into their main checkout's group), with non-repo tabs - /// collected in a trailing "Scratch" group. Branch changes and cds inside - /// a repo never move a tab; only changing repos does. #[default] Repo, - /// One flat list in tab order (the pre-grouping behavior). None, } -/// When tty7 posts a "command finished" desktop notification (see -/// [`Config::notify_on_command_finish`]). #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)] #[serde(rename_all = "kebab-case")] pub enum NotifyMode { - /// Never notify. Never, - /// Only when the window is not currently focused (the current behavior). #[default] Unfocused, - /// Always, even when the window is focused. Always, } -/// How the terminal bell (BEL / `^G`) is signalled (see [`Config::bell`]). #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)] #[serde(rename_all = "kebab-case")] pub enum BellMode { - /// Ignore the bell entirely — no flash, no sound. None, - /// A brief visual flash of the terminal (the current behavior). #[default] Visual, - /// Ring the system bell. On platforms without one, falls back to a flash so - /// an opted-in bell is never silent. Audible, } -/// A shell program plus its launch arguments. Mirrors `alacritty_terminal`'s -/// `tty::Shell`, but lives here so config has no dependency on the PTY crate and -/// the daemon can read it straight from `config.json`. #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] pub struct ShellConfig { - /// Executable to launch. Either a bare name resolved via `PATH` - /// (e.g. `"pwsh"`, `"bash"`) or an absolute path - /// (e.g. `"C:\\Windows\\System32\\cmd.exe"`, `"/usr/bin/fish"`). pub program: String, - /// Arguments passed to the shell on launch (e.g. `["-l"]` for a login shell, - /// or `["-NoLogo"]` for PowerShell). Empty by default. #[serde(default)] pub args: Vec, } -/// The default fallback chain for the platform we were built for. -/// -/// Fallbacks are resolved by *family name* against installed fonts, so a list -/// that reads well on one OS can miss entirely on another: every name in the -/// original list (Menlo, Apple Color Emoji) shipped with macOS, which left -/// Windows and Linux with a chain that matched nothing and fell straight -/// through to the platform's own cascade. -/// -/// That fall-through is not merely cosmetic. `element.rs` pins each wide cell -/// to `2 × cell_width`, and with the bundled Hack primary a cell is 0.60205em, -/// so a two-column slot is 1.2041em. The OS cascade serves a *1.0em* CJK face -/// (Microsoft YaHei, PingFang, Noto Sans CJK), and `force_width` left-aligns — -/// the ideograph hugs the left of its slot and dumps the whole 0.2em remainder -/// on the right. Naming a 1.2em face first (Maple Mono NF CN: 0.6em Latin, -/// 1.2em CJK — an exact two-cell fit) keeps the ink centered instead. -/// -/// Maple is referenced by name only, never bundled — it is ~20MB per weight. -/// Users who lack it land on the stock CJK face below, which still renders; -/// it just carries the left-hugging tracking described above. pub fn default_font_fallbacks() -> Vec { let names: &[&str] = if cfg!(target_os = "macos") { &[ @@ -690,14 +353,6 @@ pub fn default_font_fallbacks() -> Vec { names.iter().map(|n| n.to_string()).collect() } -/// Stock faces this platform is expected to ship, appended to whatever the user -/// configured (see `terminal::view::fallback_chain`). -/// -/// [`default_font_fallbacks`] only helps a *fresh* config. Anyone who already -/// has a `config.json` carries the old macOS-only list forever, so the same -/// repair has to happen at use time. Appending is safe by construction: a -/// fallback is consulted only once everything ahead of it has missed, so these -/// can never displace a face the user chose. pub fn platform_last_resort_fallbacks() -> &'static [&'static str] { if cfg!(target_os = "macos") { &["PingFang SC", "Apple Color Emoji"] @@ -710,12 +365,7 @@ pub fn platform_last_resort_fallbacks() -> &'static [&'static str] { impl Default for Config { fn default() -> Self { - // These defaults match the values that used to be hardcoded in - // `TerminalView::new` and `app::apply_theme`. Self { - // "Hack" is bundled with the app (see `register_bundled_fonts` in - // main.rs), so this default renders identically everywhere without - // relying on a system install. Menlo stays as a safety net. font_family: "Hack".to_string(), font_fallbacks: default_font_fallbacks(), font_family_bold: None, @@ -724,12 +374,8 @@ impl Default for Config { font_size: 15.0, line_height: 1.4, theme: "light".to_string(), - // The default theme id (mirrors `ui::presets::DEFAULT_ID`; core can't - // depend on ui). Unknown ids fall back to it anyway. theme_preset: "light".to_string(), theme_follow_system: false, - // The built-in light/dark pair; each side is user-swappable in - // Settings once "sync with system" is on. theme_preset_light: "light".to_string(), theme_preset_dark: "dark".to_string(), window_opacity: None, @@ -738,22 +384,13 @@ impl Default for Config { keybindings: HashMap::new(), keybinding_preset: default_preset(), prefix: default_prefix(), - // `None` → the platform default shell (login shell on Unix, - // 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 - // out: URL detection on, cursor blinking, 10k scrollback, new tabs - // after the active one, notify only while unfocused. link_url: true, link_file_command: None, ssh_loopback_forward: false, cursor_blink: true, scrollback_limit: 10_000, new_tab_position: NewTabPosition::AfterCurrent, - // Vertical sidebar down the left side; `top` opts back into the - // horizontal title-bar strip. tab_bar_position: TabBarPosition::Left, sidebar_width: default_sidebar_width(), sidebar_collapsed: false, @@ -761,27 +398,17 @@ impl Default for Config { right_panel_width: default_right_panel_width(), right_panel_tab: RightPanelTab::Info, sidebar_grouping: SidebarGrouping::Repo, - // Today's behaviour, unchanged: the counts open the overlay. sidebar_diff_preview: true, notify_on_command_finish: NotifyMode::Unfocused, - // Opt-out, not opt-in: a stale terminal that never tells you it's - // outdated is the status quo we're fixing. One cheap GET at startup. check_for_updates: true, notify_threshold_secs: default_notify_threshold_secs(), restore_session: true, show_tray_icon: true, confirm_window_close: true, - // Visual flash preserves the pre-config behavior (the bell always - // flashed); opting into None/Audible is a deliberate change. bell: BellMode::Visual, tab_completion: true, history_search: true, cursor_style: CursorStyle::Block, - // Input/mouse defaults preserve today's behavior: Option composes - // characters as macOS ships it (opt into Option-as-Meta); GPUI - // already hides the pointer while typing (its `CursorHideMode` - // default), so that starts `true`; no focus-follows-mouse, raw 1× - // scroll, no copy trim, a normal centered window. macos_option_as_alt: false, mouse_hide_while_typing: true, focus_follows_mouse: false, @@ -807,14 +434,11 @@ impl Default for Config { } impl Config { - /// Load the config, falling back to defaults if the file is absent or - /// unreadable, and to defaults (with a warning) if it fails to parse. pub fn load() -> Self { let Some(path) = Self::path() else { return Config::default(); }; let Ok(text) = std::fs::read_to_string(&path) else { - // Missing/unreadable config is the common case — start with defaults. return Config::default(); }; match serde_json::from_str::(strip_bom(&text)) { @@ -832,10 +456,6 @@ impl Config { } } - /// Clamp parsed values into sane ranges so a hand-edited or corrupt - /// `config.json` can't crash the renderer (e.g. `font_size: 0` or a tiny - /// `line_height` would round the row height to 0 → divide-by-zero → - /// `usize::MAX` rows → allocation panic on first paint). fn sanitize(&mut self) { if !self.font_size.is_finite() || self.font_size <= 0.0 { self.font_size = Config::default().font_size; @@ -845,27 +465,16 @@ impl Config { self.line_height = Config::default().line_height; } self.line_height = self.line_height.clamp(0.5, 4.0); - // Keep scrollback in a sane band: a floor so it's never uselessly tiny, - // and alacritty's own ceiling (a huge value would just balloon memory — - // the emulator caps history there anyway). self.scrollback_limit = self.scrollback_limit.clamp(100, MAX_SCROLLBACK); if !self.mouse_scroll_multiplier.is_finite() || self.mouse_scroll_multiplier <= 0.0 { self.mouse_scroll_multiplier = Config::default().mouse_scroll_multiplier; } self.mouse_scroll_multiplier = self.mouse_scroll_multiplier.clamp(0.1, 10.0); - // Keep the notify threshold in a usable band: a 1s floor so it can't fire - // on every trivial command, and a 1-hour ceiling above which "long - // command" stops meaning anything. self.notify_threshold_secs = self.notify_threshold_secs.clamp(1, 3600); - // A NaN override would make the whole window invisible or poison the - // alpha math; drop it. The floor keeps a hand-edited value from hiding - // the window entirely. self.window_opacity = self .window_opacity .filter(|o| o.is_finite()) .map(|o| o.clamp(0.2, 1.0)); - // A corrupt/NaN width would poison `w(px(..))`; keep it in a broad safe - // band (the live layout enforces the real `[180, window/2]` bounds). if !self.sidebar_width.is_finite() || self.sidebar_width <= 0.0 { self.sidebar_width = default_sidebar_width(); } @@ -874,9 +483,6 @@ impl Config { self.right_panel_width = default_right_panel_width(); } self.right_panel_width = self.right_panel_width.clamp(100.0, 2000.0); - // An empty or whitespace-only file-open command means "no override"; the - // settings text field yields `""` when cleared, so fold it back to `None` - // rather than trying to run an empty command. if let Some(command) = &self.link_file_command && command.trim().is_empty() { @@ -884,9 +490,6 @@ impl Config { } } - /// Write the current config back to disk, creating the parent directory if - /// needed. Used to persist runtime changes (theme toggle, font zoom) so they - /// survive a restart. Failures are logged, never fatal. pub fn save(&self) { let Some(path) = Self::path() else { return; @@ -904,28 +507,17 @@ impl Config { } } - /// `~/.config/tty7/config.json`. fn path() -> Option { config_path("config.json") } } -/// Process-wide override for the config directory. Set once at startup from the -/// `--config-dir` CLI flag (see `main`); `None` means "use the default". Lets a -/// dev build (`cargo dev`) keep its config/session/history out of the real -/// `~/.config/tty7/` so debugging never clobbers your live setup. static CONFIG_DIR_OVERRIDE: OnceLock = OnceLock::new(); -/// Pin the config directory for this process. Idempotent — only the first call -/// wins, so call it before any `config_path` use (i.e. before `Config::load`). pub fn set_config_dir(dir: PathBuf) { let _ = CONFIG_DIR_OVERRIDE.set(dir); } -/// The directory every config-dir file lives in. Resolution order: -/// 1. `--config-dir` override (via `set_config_dir`), -/// 2. `$TTY7_CONFIG_DIR` env var, -/// 3. the platform default (see [`default_config_dir`]). fn config_dir() -> Option { if let Some(dir) = CONFIG_DIR_OVERRIDE.get() { return Some(dir.clone()); @@ -936,23 +528,12 @@ fn config_dir() -> Option { default_config_dir() } -/// Default config directory on Unix: `$HOME/.config/tty7` (the XDG-ish location -/// tty7 has always used). -/// -/// Public so a test guard can ask "is the dir we'd write to the *user's real -/// one*?" without re-deriving the platform layout — see the `#[cfg(test)]` -/// `Config::save` in the GUI crate's `core::config`. #[cfg(not(windows))] pub fn default_config_dir() -> Option { let home = std::env::var_os("HOME").filter(|h| !h.is_empty())?; Some(PathBuf::from(home).join(".config/tty7")) } -/// Default config directory on Windows: `%APPDATA%\tty7` (the conventional -/// per-user roaming app-data location), falling back to -/// `%USERPROFILE%\.config\tty7` to mirror the Unix layout if `APPDATA` is unset. -/// -/// Public for the same reason as the Unix arm above. #[cfg(windows)] pub fn default_config_dir() -> Option { if let Some(appdata) = std::env::var_os("APPDATA").filter(|d| !d.is_empty()) { @@ -962,51 +543,18 @@ pub fn default_config_dir() -> Option { Some(PathBuf::from(profile).join(".config").join("tty7")) } -/// Resolve a file under the config directory (no `dirs` dep). Shared by every -/// config-dir file (`config.json`, `views.json`, `history`). pub fn config_path(file: &str) -> Option { Some(config_dir()?.join(file)) } -/// Drop a leading UTF-8 BOM so a hand-edited config still parses. -/// -/// `serde_json` rejects U+FEFF before the opening brace, and every config-dir -/// file is read by a loader that treats *any* parse error as "there is no -/// config" — so a BOM doesn't surface as an error, it silently resets the -/// user's settings. Windows makes that easy to hit by accident: PowerShell's -/// `>`, `Out-File` and `Set-Content -Encoding utf8` all write one, so a quick -/// `... | Set-Content config.json` is enough to lose every setting. -/// -/// `read_to_string` decodes the BOM to the single char U+FEFF, so this strips -/// the char rather than the three raw bytes. pub fn strip_bom(text: &str) -> &str { text.strip_prefix('\u{FEFF}').unwrap_or(text) } -/// Write `bytes` to `path` atomically: write to a sibling temp file, fsync, then -/// rename over the target. A crash/power-loss mid-write then leaves either the -/// old file or the new one intact — never a truncated/half-written file that -/// fails to parse and silently reverts the user's settings to defaults. The temp -/// lives in the same directory so the rename stays on one filesystem (atomic). -/// Shared by `Config::save` and `WindowViews::save`. pub fn write_atomic(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> { write_atomic_mode(path, bytes, false) } -/// [`write_atomic`], with the target owner-only from the first instant its final -/// name exists. -/// -/// The mode is set on the *temp* file, before the rename, for the same reason -/// [`bind_control_socket`](crate::host::server::bind_control_socket) tightens -/// the umask around its `bind` rather than chmod-ing afterwards: a fix-up on the -/// next line is a window in which the file is readable, and under a `umask 002` -/// — the default wherever user-private groups are configured — that window is -/// group-readable. For documents whose contents are the user's business alone: -/// `machine.json` names every workspace's directories, SSH users and hosts, and -/// agent session ids. -/// -/// A no-op difference on Windows, which has no mode bits: the config directory's -/// own ACL is the boundary there, as it is for the daemon's port file. pub fn write_atomic_private(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> { write_atomic_mode(path, bytes, true) } @@ -1014,9 +562,6 @@ pub fn write_atomic_private(path: &std::path::Path, bytes: &[u8]) -> std::io::Re fn write_atomic_mode(path: &std::path::Path, bytes: &[u8], private: bool) -> std::io::Result<()> { use std::io::Write as _; let dir = path.parent().unwrap_or_else(|| std::path::Path::new(".")); - // Per-process-unique temp name so two concurrent writers don't clobber the - // same scratch file (the final rename then resolves last-writer-wins, with no - // torn target either way). let tmp = dir.join(format!( ".{}.tmp.{}", path.file_name().and_then(|n| n.to_str()).unwrap_or("out"), @@ -1046,31 +591,14 @@ fn write_atomic_mode(path: &std::path::Path, bytes: &[u8], private: bool) -> std } } -/// The resolved config directory, exposed so the daemon spawner can forward it to -/// the detached child as `--config-dir`. We hand the child the *resolved* path -/// rather than rely on inheritance, so the spawned daemon lands in the exact dir -/// the GUI is using (dev and prod each get their own daemon — that isolation is -/// intentional). `None` only when nothing resolves (no override, no env var, no -/// `$HOME`); the caller then omits the flag and lets the child fall back to its -/// own default resolution. pub fn config_dir_path() -> Option { config_dir() } -/// 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 7 / Windows PowerShell -/// on Windows). pub fn shell_command() -> Option<(String, Vec)> { Config::load().shell.map(|s| (s.program, s.args)) } -/// The forced base directory for a spawned shell, per `working_directory`. -/// `Some(dir)` overrides the daemon's inherit fallback (but not an explicit -/// client-supplied cwd); `None` means "use the inherit fallback" (the default). -/// Read straight from `config.json` so the **daemon** can honor it. `Home`/an -/// empty `Custom` path resolve via `$HOME`. pub fn working_directory_base() -> Option { let wd = Config::load().working_directory; let home = || std::env::var_os("HOME").map(PathBuf::from); @@ -1088,17 +616,10 @@ pub fn working_directory_base() -> Option { } } -/// Extra environment variables to inject into every spawned shell, read from -/// `config.json` on the daemon side (which has no GPUI `Config` global). pub fn extra_env() -> HashMap { Config::load().env } -/// User-defined agent-detection rules (`agent_commands`), keys lowercased, -/// cached once per process. The daemon consults this from its 0.5 s foreground -/// poll on every pane, so it must not re-read `config.json` each time; the -/// trade-off is that rule edits apply on the next daemon start (the GUI's -/// "Restart daemon" command counts). pub fn agent_commands_cached() -> &'static HashMap { static CACHE: std::sync::OnceLock> = std::sync::OnceLock::new(); CACHE.get_or_init(|| { @@ -1110,72 +631,46 @@ pub fn agent_commands_cached() -> &'static HashMap { }) } -/// Serde default for [`Config::keybinding_preset`]: the no-op `"default"` preset. fn default_preset() -> String { "default".to_string() } -/// Serde default for the several `bool` fields that default to `true` (so a -/// config predating them, or one omitting them, keeps the on-by-default -/// behavior instead of deserializing to `false`). fn default_true() -> bool { true } -/// Serde default for [`Config::word_separators`]: alacritty's stock semantic -/// escape set, the boundary characters double-click word selection used -/// before this was configurable. fn default_word_separators() -> String { ",│`|:\"' ()[]{}<>\t".to_string() } -/// Serde default for [`Config::notify_threshold_secs`]: the 10-second floor a -/// command had to cross before this was configurable. fn default_notify_threshold_secs() -> u64 { 10 } -/// Serde default for [`Config::prefix`]: tmux's classic `C-b`. fn default_prefix() -> String { "ctrl-b".to_string() } -/// Which tab the right detail panel shows. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum RightPanelTab { - /// Session facts: cwd, shell, branch, agent. #[default] Info, - /// The pane's command history as a navigable outline (OSC 133 marks). Outline, - /// The pane's working-tree diff. Changes, - /// The file tree rooted at the pane's repository. Files, } -/// Serde default for [`Config::right_panel_width`]: wide enough for a file path -/// plus its `+N −M` counts without the tree turning into an ellipsis parade. fn default_right_panel_width() -> f32 { 260. } -/// Serde default for [`Config::sidebar_width`]: a comfortable rail width that -/// clears the tab labels without eating too much of the terminal. fn default_sidebar_width() -> f32 { 220.0 } -/// Upper bound on `scrollback_limit`. Matches alacritty_terminal's own history -/// ceiling — asking for more just wastes memory since the emulator caps there. pub const MAX_SCROLLBACK: usize = 100_000; -/// Deserialize a field leniently: if it's present but unparseable (e.g. a typo'd -/// enum string), fall back to `Default` with a warning instead of failing the -/// whole `config.json` parse — one bad entry must never reset every other -/// setting to its default. Missing fields are still handled by the container's -/// `#[serde(default)]`, which never calls this. pub(crate) fn de_lenient<'de, D, T>(deserializer: D) -> Result where D: serde::Deserializer<'de>, @@ -1196,9 +691,7 @@ mod tests { fn profile_usage_score_ranks_frequency_and_recency() { let now = 100_000_000u64; let day = 86_400u64; - // Never-used scores zero. assert_eq!(ProfileUsage::default().score(now), 0.0); - // Same recency, more uses ⇒ higher score. let a = ProfileUsage { count: 10, last_used: now, @@ -1208,7 +701,6 @@ mod tests { last_used: now, }; assert!(a.score(now) > b.score(now)); - // Same count, more recent ⇒ higher score (recency decays with age). let recent = ProfileUsage { count: 3, last_used: now, @@ -1239,9 +731,6 @@ mod tests { assert_eq!(back.ssh_profile_frecency.get(&id).unwrap().count, 4); } - /// The sidebar diff preview is opt-*out*: a config written before the switch - /// existed keeps today's clickable counts, and turning it off survives a - /// write/read cycle of `config.json` (issue #239). #[test] fn sidebar_diff_preview_defaults_on_and_round_trips() { assert!(Config::default().sidebar_diff_preview); @@ -1260,9 +749,6 @@ mod tests { assert!(!back.sidebar_diff_preview); } - /// Opt-*out*, unlike most flags here: a config written before this setting - /// existed must keep the prompt, or an update would silently take away the - /// one thing telling people their sessions survive a quit. #[test] fn confirm_window_close_defaults_on_and_round_trips() { assert!(Config::default().confirm_window_close); @@ -1276,20 +762,11 @@ mod tests { let back: Config = serde_json::from_str(&json).unwrap(); assert!(!back.confirm_window_close); - // ...and a key this build has never heard of — a config last written by - // a newer tty7, or hand-edited — must be ignored rather than failing the - // whole parse, which `Config::load` would swallow into *defaults*: the - // opt-out would come back on with nothing said. let newer: Config = serde_json::from_str(r#"{"confirm_window_close": false, "not_a_setting": 7}"#).unwrap(); assert!(!newer.confirm_window_close); } - /// Also opt-*out*: every config written before the switch existed predates - /// the choice, and those users have been looking at dimmed panes all along — - /// defaulting to `false` would silently change how every split tab looks on - /// upgrade. And once someone does turn it off, the `false` has to survive a - /// save/load cycle, or the effect they opted out of returns on next launch. #[test] fn dim_inactive_panes_defaults_on_and_round_trips() { assert!(Config::default().dim_inactive_panes); @@ -1306,8 +783,6 @@ mod tests { #[test] fn theme_follow_system_defaults_and_round_trips() { - // Old configs (no follow-system keys) must land on off + the built-in - // light/dark pair, so nothing changes until the user opts in. let cfg: Config = serde_json::from_str(r#"{"theme_preset":"dracula"}"#).unwrap(); assert!(!cfg.theme_follow_system); assert_eq!(cfg.theme_preset_light, "light"); @@ -1342,10 +817,6 @@ mod tests { assert!(default_cfg.font_features.is_none()); } - /// The wire format is a real key in the user's `config.json`, so it is - /// frozen: bools and integers both parse, both land as integers, and the - /// integer form re-parses to the same value. (Matches what - /// `gpui::FontFeatures` did when this field was typed as it.) #[test] fn font_features_round_trip_to_integer_valued_json() { let features: FontFeatures = @@ -1367,8 +838,6 @@ mod tests { assert_eq!(serde_json::to_string(&back).unwrap(), json); } - /// Junk inside the map is dropped, never fatal — one typo'd tag must not - /// reset the rest of `config.json`. #[test] fn font_features_skip_bad_tags_and_values_instead_of_failing() { let features: FontFeatures = serde_json::from_str( @@ -1378,8 +847,6 @@ mod tests { assert_eq!(features.tag_value_list(), &[("calt".to_string(), 1)]); } - /// A `font_features` key nested in a whole config survives a full - /// `Config` round-trip unchanged. #[test] fn font_features_survive_a_config_round_trip() { let cfg: Config = @@ -1395,7 +862,6 @@ mod tests { #[test] fn stale_override_keys_are_ignored() { - // Leftover keys from the retired override system are ignored, not fatal. let cfg: Config = serde_json::from_str( r##"{"font_size": 20.0, "colors": {"border": "#fff"}, "ansi_colors": {"color1": "#f00"}}"##, ) @@ -1406,8 +872,6 @@ mod tests { #[test] fn sanitize_clamps_degenerate_font_metrics() { - // A zero/negative/NaN font size or line height would round the row height - // to 0 and crash the renderer (divide-by-zero → usize::MAX rows). Clamp. let sanitized = |font_size: f32, line_height: f32| { let mut cfg = Config { font_size, @@ -1426,19 +890,14 @@ mod tests { assert!(fs.is_finite() && fs > 0.0); assert!(lh.is_finite() && lh > 0.0); - // A sane value is left untouched. assert_eq!(sanitized(15.0, 1.4), (15.0, 1.4)); } - /// Per-test scratch directory, unique per test name + PID and removed on - /// drop — cleanup runs even when an assertion panics mid-test, so a failed - /// run can't leak state into (or collide with) the next one. struct TestDir(std::path::PathBuf); impl TestDir { fn new(name: &str) -> Self { let dir = std::env::temp_dir().join(format!("tty7-test-{name}-{}", std::process::id())); - // A stale copy from a crashed earlier run would poison this one. let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); Self(dir) @@ -1461,11 +920,9 @@ mod tests { let target = dir.path().join("data.json"); write_atomic(&target, b"first").unwrap(); assert_eq!(std::fs::read_to_string(&target).unwrap(), "first"); - // Overwrite is atomic and complete (no truncation/append residue). write_atomic(&target, b"second-longer-and-then-short").unwrap(); write_atomic(&target, b"3rd").unwrap(); assert_eq!(std::fs::read_to_string(&target).unwrap(), "3rd"); - // The sibling temp file must not linger. let leftover: Vec<_> = std::fs::read_dir(dir.path()) .unwrap() .flatten() @@ -1476,8 +933,6 @@ mod tests { #[test] fn behavior_enums_fall_back_leniently_on_bad_values() { - // A typo'd enum string must NOT reset the whole config: font_size is kept, - // and only the bad field falls back to its default. let cfg: Config = serde_json::from_str( r#"{"font_size": 20.0, "new_tab_position": "middle", "notify_on_command_finish": "sometimes", "tab_bar_position": "diagonal"}"#, ) @@ -1487,8 +942,6 @@ mod tests { assert_eq!(cfg.notify_on_command_finish, NotifyMode::Unfocused); assert_eq!(cfg.tab_bar_position, TabBarPosition::Left); - // Valid kebab-case values round-trip. `top` is the non-default here, so - // the assert still proves the field parsed rather than fell back. let cfg: Config = serde_json::from_str( r#"{"new_tab_position": "end", "notify_on_command_finish": "always", "tab_bar_position": "top"}"#, ) @@ -1511,7 +964,6 @@ mod tests { assert_eq!(cfg.working_directory.strategy, WdStrategy::Custom); assert_eq!(cfg.working_directory.path, "/tmp/x"); - // A bad strategy value falls back to the default without failing the parse. let cfg: Config = serde_json::from_str(r#"{"working_directory": {"strategy": "elsewhere"}}"#).unwrap(); assert_eq!(cfg.working_directory.strategy, WdStrategy::Inherit); @@ -1528,14 +980,12 @@ mod tests { cfg.mouse_scroll_multiplier }; assert_eq!(clamp(1.0), 1.0); - assert_eq!(clamp(0.0), 1.0); // non-positive → default + assert_eq!(clamp(0.0), 1.0); assert_eq!(clamp(-3.0), 1.0); - assert_eq!(clamp(100.0), 10.0); // ceiling - assert_eq!(clamp(0.01), 0.1); // floor + assert_eq!(clamp(100.0), 10.0); + assert_eq!(clamp(0.01), 0.1); } - /// The window-opacity override is clamped into its usable band; a NaN is - /// dropped back to "follow theme" rather than poisoning the alpha math. #[test] fn sanitize_clamps_window_opacity_override() { let clamp = |o: Option| { @@ -1548,9 +998,9 @@ mod tests { }; assert_eq!(clamp(None), None); assert_eq!(clamp(Some(0.8)), Some(0.8)); - assert_eq!(clamp(Some(0.0)), Some(0.2)); // floor: never invisible - assert_eq!(clamp(Some(2.0)), Some(1.0)); // ceiling - assert_eq!(clamp(Some(f32::NAN)), None); // NaN → follow theme + assert_eq!(clamp(Some(0.0)), Some(0.2)); + assert_eq!(clamp(Some(2.0)), Some(1.0)); + assert_eq!(clamp(Some(f32::NAN)), None); } #[test] @@ -1563,15 +1013,13 @@ mod tests { cfg.sanitize(); cfg.scrollback_limit }; - assert_eq!(clamp(0), 100); // floor - assert_eq!(clamp(10_000), 10_000); // untouched in-band - assert_eq!(clamp(usize::MAX), MAX_SCROLLBACK); // ceiling + assert_eq!(clamp(0), 100); + assert_eq!(clamp(10_000), 10_000); + assert_eq!(clamp(usize::MAX), MAX_SCROLLBACK); } #[test] fn new_terminal_prefs_default_and_parse_leniently() { - // Defaults preserve the pre-config behavior: restore on, mouse reporting - // on, a 10s notify floor, and a visual bell. let cfg = Config::default(); assert!(cfg.restore_session); assert!(cfg.mouse_reporting); @@ -1580,8 +1028,6 @@ mod tests { assert_eq!(cfg.notify_threshold_secs, 10); assert_eq!(cfg.bell, BellMode::Visual); - // A config predating these fields keeps the on-by-default booleans (not - // `false`) and the 10s floor. let cfg: Config = serde_json::from_str(r#"{"font_size": 15.0}"#).unwrap(); assert!(cfg.restore_session); assert!(cfg.mouse_reporting); @@ -1590,14 +1036,11 @@ mod tests { assert_eq!(cfg.notify_threshold_secs, 10); assert_eq!(cfg.bell, BellMode::Visual); - // The opt-outs round-trip. let cfg: Config = serde_json::from_str(r#"{"tab_completion": false}"#).unwrap(); assert!(!cfg.tab_completion); let cfg: Config = serde_json::from_str(r#"{"history_search": false}"#).unwrap(); assert!(!cfg.history_search); - // Valid values round-trip; a bad bell string falls back without failing - // the whole parse. let cfg: Config = serde_json::from_str( r#"{"restore_session": false, "mouse_reporting": false, "bell": "audible"}"#, ) @@ -1620,14 +1063,13 @@ mod tests { cfg.sanitize(); cfg.notify_threshold_secs }; - assert_eq!(clamp(0), 1); // floor - assert_eq!(clamp(10), 10); // untouched in-band - assert_eq!(clamp(100_000), 3600); // ceiling + assert_eq!(clamp(0), 1); + assert_eq!(clamp(10), 10); + assert_eq!(clamp(100_000), 3600); } #[test] fn keybinding_preset_and_prefix_default_and_round_trip() { - // Missing fields fall back to the no-op preset and the tmux-classic prefix. let cfg = Config::default(); assert_eq!(cfg.keybinding_preset, "default"); assert_eq!(cfg.prefix, "ctrl-b"); @@ -1636,16 +1078,12 @@ mod tests { assert_eq!(cfg.keybinding_preset, "default"); assert_eq!(cfg.prefix, "ctrl-b"); - // Explicit values survive a parse. let cfg: Config = serde_json::from_str(r#"{"keybinding_preset": "tmux", "prefix": "ctrl-a"}"#).unwrap(); assert_eq!(cfg.keybinding_preset, "tmux"); assert_eq!(cfg.prefix, "ctrl-a"); } - /// A fresh config must name a CJK face the *host* platform actually ships. - /// The pre-fix list was macOS-only, so Windows and Linux wrote a chain that - /// matched nothing and left every ideograph to the OS cascade. #[test] fn default_font_fallbacks_are_platform_appropriate() { let defaults = default_font_fallbacks(); @@ -1658,9 +1096,6 @@ mod tests { ); } - // Maple Mono NF CN is the only face whose CJK advance (1.2em) is an exact - // two-cell fit against the bundled Hack primary, so it must be tried - // before the stock face on every platform. let maple = defaults.iter().position(|f| f == "Maple Mono NF CN"); let maple = maple.expect("the exact-fit CJK face must stay in the chain"); for name in platform_last_resort_fallbacks() { @@ -1668,7 +1103,6 @@ mod tests { assert!(maple < stock, "{name} must not preempt Maple Mono NF CN"); } - // macOS-only names must not leak into the other platforms' defaults. if !cfg!(target_os = "macos") { for name in ["Menlo", "Apple Color Emoji"] { assert!( @@ -1681,30 +1115,23 @@ mod tests { #[test] fn config_deserialize_fills_missing_fields_from_defaults() { - // Only one field present; the rest must fall back via #[serde(default)]. let cfg: Config = serde_json::from_str(r#"{"font_size": 20.0}"#).unwrap(); assert_eq!(cfg.font_size, 20.0); - assert_eq!(cfg.line_height, 1.4); // default preserved - assert_eq!(cfg.font_family, "Hack"); // default preserved + assert_eq!(cfg.line_height, 1.4); + assert_eq!(cfg.font_family, "Hack"); assert_eq!(cfg.theme_preset, "light"); assert!(cfg.keybindings.is_empty()); } - /// Pin the process config dir at a shared temp location so `load`/`save` never - /// touch the real `~/.config`. First-call-wins; every IO test uses the same path. fn pin_config_dir() { let dir = std::env::temp_dir().join(format!("tty7-covtest-{}", std::process::id())); std::fs::create_dir_all(&dir).ok(); set_config_dir(dir); } - /// Serialize the tests that write the shared `config.json`: they all resolve - /// the same pinned path, so without this they clobber each other's file. static CONFIG_FILE: std::sync::Mutex<()> = std::sync::Mutex::new(()); fn lock_config_file() -> std::sync::MutexGuard<'static, ()> { - // A poisoned lock only means another test failed mid-sequence; every - // holder rewrites the file from scratch, so the state is still sound. CONFIG_FILE.lock().unwrap_or_else(|e| e.into_inner()) } @@ -1712,8 +1139,6 @@ mod tests { fn save_load_and_shell_command_round_trip_through_disk() { let _guard = lock_config_file(); pin_config_dir(); - // Persist a config with a non-default shell + font + an SSH profile, then - // read it back. let mut cfg = Config { font_size: 18.0, ..Config::default() @@ -1739,11 +1164,8 @@ mod tests { loaded.shell.as_ref().map(|s| s.program.as_str()), Some("fish") ); - // The SSH profile round-trips byte-for-byte, id included, with no plaintext - // secret anywhere (only the credential *ref*). assert_eq!(loaded.ssh_profiles, vec![profile]); - // `shell_command` reads the same on-disk config for the daemon side. let (program, args) = shell_command().expect("shell override present"); assert_eq!(program, "fish"); assert_eq!(args, vec!["-l".to_string()]); @@ -1754,14 +1176,9 @@ mod tests { let _guard = lock_config_file(); pin_config_dir(); let path = Config::path().expect("pinned config dir"); - // Exactly what PowerShell's `>`, `Out-File` and `Set-Content -Encoding - // utf8` leave behind. let text = "\u{FEFF}{\"font_size\": 21.0, \"restore_session\": false}"; write_atomic(&path, text.as_bytes()).unwrap(); - // The failure this guards is silent by construction: `load` turns *any* - // parse error into defaults, so a BOM didn't report a bad config — it - // reported no config, and the user's settings appeared to vanish. let loaded = Config::load(); assert_eq!(loaded.font_size, 21.0); assert!(!loaded.restore_session); @@ -1773,12 +1190,7 @@ mod tests { fn strip_bom_only_removes_a_leading_marker() { assert_eq!(strip_bom("{}"), "{}"); assert_eq!(strip_bom("\u{FEFF}{}"), "{}"); - // Only the first U+FEFF is a marker. A second one is content, and - // content that happens to be a BOM is still invalid JSON — stripping - // it too would be guessing at a file we can't rescue. assert_eq!(strip_bom("\u{FEFF}\u{FEFF}{}"), "\u{FEFF}{}"); - // A BOM *inside* the document is data (U+FEFF is a legal string char), - // so it must survive untouched. let inner = "{\"tab_title\":\"\u{FEFF}\"}"; assert_eq!(strip_bom(inner), inner); assert_eq!(strip_bom(""), ""); @@ -1786,14 +1198,11 @@ mod tests { #[test] fn ssh_profiles_default_empty_and_parse_from_json() { - // Absent key → empty (a config predating profiles still loads). let cfg = Config::default(); assert!(cfg.ssh_profiles.is_empty()); let cfg: Config = serde_json::from_str(r#"{"font_size": 15.0}"#).unwrap(); assert!(cfg.ssh_profiles.is_empty()); - // A present profile array parses; a bad enum value inside one profile falls - // back leniently instead of failing the whole config parse. let cfg: Config = serde_json::from_str( r#"{"ssh_profiles":[{"name":"a","host":"h","auth":"bogus","port":2200}]}"#, ) @@ -1812,7 +1221,6 @@ mod tests { pin_config_dir(); let p = config_path("config.json").expect("config path resolves"); assert!(p.ends_with("config.json")); - // `config_dir_path` returns the same parent the files live under. assert_eq!(p.parent(), config_dir_path().as_deref()); } } diff --git a/crates/tty7-core/src/core/crash.rs b/crates/tty7-core/src/core/crash.rs index 971a3ae5..729b11dd 100644 --- a/crates/tty7-core/src/core/crash.rs +++ b/crates/tty7-core/src/core/crash.rs @@ -1,25 +1,8 @@ -//! Crash log — the panic message the OS crash reporter throws away. -//! -//! Most tty7 panics happen inside a gpui input callback, and those callbacks are -//! `extern "C"`: the panic can't unwind across them, so the runtime aborts. What -//! macOS then records is the *abort* — `panic_cannot_unwind` on top of -//! `handle_key_event` — with no message, no `file:line`, and the original frames -//! already unwound away. Reports like that are undiagnosable, and the GUI has no -//! logger and no terminal to print to. -//! -//! So we write the two lines that matter (message + location, plus a backtrace) -//! to `crash.log` in the config dir before the process goes down. - use std::fmt::Write as _; use std::path::PathBuf; -/// Rewrite the log once it passes this, so a panic loop can't grow it forever. const MAX_BYTES: u64 = 256 * 1024; -/// Install the panic hook for this process. `role` labels the records, since the -/// GUI and the daemon it spawns share one config dir. Chains to the previously -/// installed hook, so the usual stderr output still happens when there's a -/// terminal to see it. pub fn install(role: &'static str) { let previous = std::panic::take_hook(); std::panic::set_hook(Box::new(move |info| { @@ -28,16 +11,12 @@ pub fn install(role: &'static str) { })); } -/// Append one record. Every step is best-effort: a panic handler that panics -/// (or fails loudly) is worse than one that loses a log line. fn record(role: &str, info: &std::panic::PanicHookInfo<'_>) { let Some(path) = log_path() else { return; }; let thread = std::thread::current(); let mut record = String::new(); - // `info` renders as "panicked at :\n" — the exact - // pair the crash report is missing. let _ = write!( record, "\n=== {} {} v{} pid {} thread {:?}\n{info}\n{}\n", @@ -72,8 +51,6 @@ fn log_path() -> Option { crate::core::config::config_path("crash.log") } -/// `YYYY-MM-DD HH:MM:SS UTC` from the epoch seconds, so a record can be lined up -/// against an OS crash report without pulling in a date crate. fn utc_timestamp() -> String { let secs = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -89,7 +66,6 @@ fn utc_timestamp() -> String { ) } -/// Howard Hinnant's `civil_from_days`: days since the Unix epoch → (y, m, d). fn civil_from_days(z: i64) -> (i64, u32, u32) { let z = z + 719_468; let era = z.div_euclid(146_097); @@ -107,12 +83,8 @@ fn civil_from_days(z: i64) -> (i64, u32, u32) { mod tests { use super::{civil_from_days, install, log_path}; - /// The whole point of the hook: after a panic, the message and its location - /// are on disk. `catch_unwind` stands in for the abort — the hook runs - /// before either outcome. #[test] fn a_panic_lands_in_the_crash_log() { - // Same pinned temp dir the config tests use (set-once, first call wins). let dir = std::env::temp_dir().join(format!("tty7-covtest-{}", std::process::id())); std::fs::create_dir_all(&dir).ok(); crate::core::config::set_config_dir(dir); @@ -124,8 +96,6 @@ mod tests { let body = std::fs::read_to_string(&path).expect("the hook wrote a record"); assert!(body.contains("crash-log probe"), "message: {body}"); - // Bare file name: `panic!`'s location carries the platform's own - // separator (`src\core\crash.rs` on Windows). assert!(body.contains("crash.rs:"), "location: {body}"); assert!(body.contains("test v"), "role + version: {body}"); } @@ -134,7 +104,6 @@ mod tests { fn civil_from_days_matches_known_dates() { assert_eq!(civil_from_days(0), (1970, 1, 1)); assert_eq!(civil_from_days(20_660), (2026, 7, 26)); - // Leap day, and the day after it. assert_eq!(civil_from_days(19_782), (2024, 2, 29)); assert_eq!(civil_from_days(19_783), (2024, 3, 1)); } diff --git a/crates/tty7-core/src/core/git.rs b/crates/tty7-core/src/core/git.rs index e7cd224e..eda88e62 100644 --- a/crates/tty7-core/src/core/git.rs +++ b/crates/tty7-core/src/core/git.rs @@ -1,80 +1,25 @@ -//! Git, the way every part of tty7 reads it: one shell-out per field, always -//! `git -C `, always `GIT_OPTIONAL_LOCKS=0`. -//! -//! This is the shared bottom layer, not a feature: the sidebar's branch/diff -//! line, the diff overlay, and (from the remote-workspace work on) the server -//! side all go through the same [`git`] invocation, so a read tty7 performs can -//! never take `index.lock` and fight a real git command the user is running. -//! Deliberately shell-out simple — blocking, one process per question; callers -//! run it on a background thread so nothing UI-facing waits on a slow repo. -//! -//! The caching layer that fans one probe out to every pane in a repo is *not* -//! here: it is a gpui global, so it stays in the GUI crate -//! (`terminal::git_status::GitStatusCache`). - use std::io::{self, Read as _}; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use crate::host::{Host, Output}; -/// A repo's git snapshot: the branch it's on and how much the working tree has -/// changed against `HEAD`. `added`/`removed` sum the per-file line counts from -/// `git diff --numstat HEAD` (tracked staged + unstaged changes); binary files -/// and untracked files don't contribute a line count. #[derive(Clone, PartialEq, Eq, Debug)] pub struct GitStatus { - /// The branch name (`main`, `feat/x`), or a short commit sha when the HEAD - /// is detached. Never empty. pub branch: String, - /// Lines added across the working tree vs `HEAD`. pub added: u32, - /// Lines removed across the working tree vs `HEAD`. pub removed: u32, } -/// One raw probe result, before it's folded into the cache: which work tree -/// `cwd` belongs to, plus the fields probed there. `counts` is `None` when the -/// `git diff` invocation itself failed (e.g. it raced a concurrent git write) — -/// distinct from a clean tree's `Some((0, 0))`, so the cache can keep the -/// previous numbers instead of pretending the tree went clean. #[derive(Clone, PartialEq, Eq, Debug)] pub struct RepoSnapshot { - /// The work tree root (`git rev-parse --show-toplevel`) — the cache key - /// every pane inside this work tree shares. For a linked worktree this is - /// the worktree's own directory, not the main checkout's. pub root: PathBuf, - /// The *repository* the work tree belongs to: the main checkout's root - /// when `root` is a linked worktree, otherwise `root` itself. The - /// sidebar's grouping key — every worktree of one repo shares it, while - /// branch/diff state stays per work tree under `root`. pub home: PathBuf, pub branch: String, pub counts: Option<(u32, u32)>, } -/// Probe the git snapshot for `cwd` on `host`, or `None` when it isn't inside a -/// git work tree (or the path is gone, or the host can't be reached). -/// Blocking — the GUI calls it through `ui::host_ops`, never on the UI thread. -/// -/// Three invocations, and deliberately not fewer. The first `rev-parse` answers -/// every *path* question at once: the work-tree root (which doubles as the "is -/// this a git repo" gate — it fails outside a work tree) plus the -/// git-dir/common-dir pair that tells a linked worktree from a main checkout. -/// Asking those separately cost two process spawns per probe, which mattered -/// once probes stopped being rare: they now also fire on window activation and -/// on an agent's tool calls, across every pane. The branch cannot join them — -/// `symbolic-ref` is what names a branch before the first commit exists, and -/// folding `--abbrev-ref HEAD` into the `rev-parse` would make the whole -/// invocation fail on an unborn branch and lose the paths with it. -/// -/// On a remote host each of the three is a round trip; the throttle and the -/// in-flight dedup in the GUI's `GitStatusCache` are what keep that from being -/// three per pane per trigger. pub fn probe(host: &dyn Host, cwd: &Path) -> Option { - // No `exists` pre-check: a vanished cwd already fails `Host::git` with - // `NotFound`, which lands as `None` here — the same answer, one round trip - // cheaper. let paths = git( host, cwd, @@ -88,9 +33,6 @@ pub fn probe(host: &dyn Host, cwd: &Path) -> Option { )?; let mut lines = paths.lines().map(|l| l.trim_end_matches(['\n', '\r'])); let root = PathBuf::from(lines.next()?); - // A git old enough to reject `--path-format` fails the whole invocation - // above, so reaching here means the two dirs are present — but degrade to - // "main checkout" rather than trusting that, same as the old code did. let home = repo_home(&root, lines.next(), lines.next()); let branch = branch_name(host, cwd)?; Some(RepoSnapshot { @@ -101,13 +43,6 @@ pub fn probe(host: &dyn Host, cwd: &Path) -> Option { }) } -/// The repository "home" every checkout of one repo shares, from the work-tree -/// `root` and the `--git-dir` / `--git-common-dir` pair: for a linked worktree -/// (its git dir differs from the common git dir) the main work tree's root — -/// the parent of `
/.git`; for the main checkout itself, a submodule, or -/// any failure to tell, the work-tree root unchanged. A bare common dir (no -/// trailing `.git` component, the bare-repo-plus-worktrees layout) anchors on -/// the bare directory itself — still one shared key. fn repo_home(root: &Path, git_dir: Option<&str>, common_dir: Option<&str>) -> PathBuf { let (Some(git_dir), Some(common)) = (git_dir, common_dir) else { return root.to_path_buf(); @@ -122,26 +57,18 @@ fn repo_home(root: &Path, git_dir: Option<&str>, common_dir: Option<&str>) -> Pa } } -/// The current branch name, or a short sha for a detached HEAD. Shared with -/// `terminal::git_diff` in the GUI crate, which fronts its overlay with the -/// same branch label the sidebar row shows. pub fn branch_name(host: &dyn Host, cwd: &Path) -> Option { - // On a branch — even before the first commit — `symbolic-ref` names it. if let Some(out) = git(host, cwd, &["symbolic-ref", "--quiet", "--short", "HEAD"]) { let name = out.trim(); if !name.is_empty() { return Some(name.to_string()); } } - // Detached HEAD (or a rebase/bisect): fall back to the short commit sha. let sha = git(host, cwd, &["rev-parse", "--short", "HEAD"])?; let sha = sha.trim(); (!sha.is_empty()).then(|| sha.to_string()) } -/// Sum added/removed lines across the working tree vs `HEAD` from -/// `git diff --numstat HEAD`. Binary files (`-\t-`) contribute nothing. -/// `None` when the invocation itself failed — the caller keeps old counts. fn diff_numstat(host: &dyn Host, cwd: &Path) -> Option<(u32, u32)> { let out = git(host, cwd, &["diff", "--numstat", "HEAD"])?; let mut added = 0u32; @@ -158,19 +85,6 @@ fn diff_numstat(host: &dyn Host, cwd: &Path) -> Option<(u32, u32)> { Some((added, removed)) } -/// Run `git -C ` on `host` and return stdout on success, `None` on -/// a non-zero exit or a git that never ran. -/// -/// The projection of [`git_output`] the snapshot readers want: they treat "git -/// said no" and "git could not be asked" identically — a failed probe leaves -/// the previous snapshot standing rather than blanking the branch line — where -/// callers like `core::worktree` need the two kept apart, and the stderr with -/// them. Non-UTF-8 stdout is `None`: there is nothing sensible to parse out of -/// it. -/// -/// Which machine's `git` runs is the host's business; the *invariants* of the -/// invocation are [`git_output`]'s, and every implementation of -/// [`Host::git`] owes them. pub fn git(host: &dyn Host, cwd: &Path, args: &[&str]) -> Option { let out = host.git(cwd, args).ok()?; if !out.success() { @@ -179,35 +93,6 @@ pub fn git(host: &dyn Host, cwd: &Path, args: &[&str]) -> Option { String::from_utf8(out.stdout).ok() } -/// The full result of `git -C ` — exit code, stdout *and* stderr — -/// under the invariants every git invocation in tty7 shares. -/// -/// This is the bottom layer [`git`] is a projection of, and the one -/// [`crate::host::Host::git`] exposes: a `Host` has to answer for a remote -/// machine's git too, where "non-zero exit" and "git never ran" are genuinely -/// different outcomes and the caller needs stderr to say which. `Ok` means the -/// process ran (the exit code is in [`Output::status`]); `Err` means it could -/// not be run at all. -/// -/// The invariants, all of them load-bearing: -/// -/// - **`-C `**, never `Command::current_dir` — the working directory of -/// this process is not a thing a GUI with many panes can meaningfully set. -/// - **`GIT_OPTIONAL_LOCKS=0`**, so a background read can never take -/// `index.lock` and lose a race against a real git command the user is -/// running. It only suppresses *optional* locks; writes still lock normally. -/// - **stdin nulled**, so a misconfigured credential helper or a prompt-happy -/// subcommand fails immediately instead of hanging a background thread -/// forever on a terminal nobody is attached to. -/// - **`GIT_DIR` / `GIT_WORK_TREE` removed**, so a tty7 launched from inside a -/// git hook (or any shell that exported them) can't have that ambient -/// repository silently override the `-C` we just passed. -/// - **`hide_console` on Windows**, so probing a repo doesn't flash a console -/// window. -/// -/// A `cwd` that doesn't exist is [`io::ErrorKind::NotFound`] rather than git's -/// own exit 128: "the directory is gone" is not a question about the repository, -/// and callers (and the remote `Host` contract) distinguish the two. pub fn git_output(cwd: &Path, args: &[&str]) -> io::Result { if !cwd.exists() { return Err(io::Error::new( @@ -233,21 +118,6 @@ pub fn git_output(cwd: &Path, args: &[&str]) -> io::Result { }) } -/// [`git_output`], but handing stdout to `on_chunk` as it arrives instead of -/// buffering it whole. -/// -/// Every invariant [`git_output`] documents holds here too — this is the same -/// invocation, only read differently. What it buys is peak memory: `git diff -/// HEAD` on a large work tree prints tens of megabytes, and the caller keeps a -/// small fraction of it, so materialising the whole thing first is pure cost. -/// -/// Chunks are byte slices, not lines: a line can straddle two of them and -/// splitting is the caller's business (see [`LineSplitter`]). `on_chunk` -/// returning `false` stops the read early — the child's stdout is dropped, git -/// gets `EPIPE` and exits rather than blocking forever on a full pipe. -/// -/// Returns git's exit status once it has been reaped, `Err` if it could not be -/// run at all — the same split [`git_output`] makes. pub fn git_stream( cwd: &Path, args: &[&str], @@ -268,18 +138,13 @@ pub fn git_stream( .env_remove("GIT_WORK_TREE") .stdin(Stdio::null()) .stdout(Stdio::piped()) - // Dropped on the floor rather than piped: nothing reads it here, and an - // unread pipe is how you deadlock a child that decides to be chatty. .stderr(Stdio::null()); let mut child = crate::core::proc::hide_console(&mut cmd).spawn()?; - // Taken so the pipe can be closed before `wait`. let mut stdout = child .stdout .take() .ok_or_else(|| io::Error::other("git stdout was not piped"))?; - // 64 KiB: large enough that a multi-megabyte diff is a few hundred reads, - // small enough to stay a rounding error next to the parsed structure. let mut buf = vec![0u8; 64 * 1024]; let mut read_err = None; loop { @@ -305,55 +170,19 @@ pub fn git_stream( } } -/// Reassembles a byte stream into lines across chunk boundaries. -/// -/// Chunked transports — a pipe read, a wire frame — cut wherever they happen -/// to fill a buffer, so a diff line routinely spans two chunks. This holds the -/// partial tail and emits only whole lines, with the trailing `\n`/`\r` -/// stripped; [`finish`](Self::finish) releases a last line that had no -/// terminator. -/// -/// Invalid UTF-8 is replaced rather than fatal. The buffered path drops the -/// entire output on one bad byte, which for a diff of a latin-1 file means -/// showing nothing at all; a replacement character in one line is the better -/// answer. -/// -/// Bounded by [`MAX_LINE`], which is what makes "streaming" a claim about peak -/// memory rather than only about allocation count — see that constant. #[derive(Default)] pub struct LineSplitter { tail: Vec, - /// Bytes of the line in progress that were dropped for being past - /// [`MAX_LINE`]. Reported in the emitted line rather than swallowed. dropped: usize, } -/// Ceiling on one reassembled line. -/// -/// Without it "incremental" bounds the number of allocations but not the size -/// of any of them: a line is only complete at its `\n`, so a work tree holding -/// a minified bundle — one `+` line of many megabytes — accumulates that whole -/// line in `tail` before anything is emitted, on both ends of a remote link and -/// in the server's outgoing batch. That is the same peak the buffered read was -/// replaced to avoid, reached through the one input shape nobody bounds. -/// -/// A megabyte is far past anything a diff viewer can show (the overlay -/// truncates a *cell* long before this) and far past any line git prints about -/// its own state, so nothing legitimate is cut. What is cut says so in the line -/// itself: silent truncation is how a rendered diff quietly stops matching the -/// file. pub const MAX_LINE: usize = 1024 * 1024; impl LineSplitter { - /// Feed a chunk, calling `on_line` for every complete line it finishes. pub fn push(&mut self, chunk: &[u8], mut on_line: impl FnMut(&str)) { let mut rest = chunk; while let Some(nl) = rest.iter().position(|b| *b == b'\n') { let (line, after) = rest.split_at(nl); - // The common case by far — a whole line inside this chunk, with - // nothing held over — hands out a borrow of the chunk itself. Worth - // keeping as its own arm: it is one branch per line against a copy - // per line, on a path that runs ninety thousand times. if self.tail.is_empty() && self.dropped == 0 && line.len() <= MAX_LINE { on_line(&trim_cr(line)); } else { @@ -367,14 +196,12 @@ impl LineSplitter { self.keep(rest); } - /// Emit whatever is left when the stream ends without a final newline. pub fn finish(self, mut on_line: impl FnMut(&str)) { if !self.tail.is_empty() || self.dropped > 0 { emit(&self.tail, self.dropped, &mut on_line); } } - /// Append what still fits under [`MAX_LINE`], counting the rest as dropped. fn keep(&mut self, bytes: &[u8]) { let room = MAX_LINE.saturating_sub(self.tail.len()); let take = room.min(bytes.len()); @@ -383,14 +210,11 @@ impl LineSplitter { } } -/// Hand one reassembled line to the caller, saying so when it was cut. fn emit(line: &[u8], dropped: usize, on_line: &mut impl FnMut(&str)) { if dropped == 0 { on_line(&trim_cr(line)); return; } - // No `trim_cr` on a cut line: its last byte is one from the middle of the - // real line, and a `\r` that lands there is content, not a terminator. let mut text = String::from_utf8_lossy(line).into_owned(); text.push_str(&format!( " …[{dropped} more bytes on this line dropped: past tty7's {MAX_LINE}-byte line cap]" @@ -398,7 +222,6 @@ fn emit(line: &[u8], dropped: usize, on_line: &mut impl FnMut(&str)) { on_line(&text); } -/// Drop a trailing `\r` (git on Windows) and decode lossily. fn trim_cr(line: &[u8]) -> std::borrow::Cow<'_, str> { let line = match line.last() { Some(b'\r') => &line[..line.len() - 1], @@ -411,15 +234,10 @@ fn trim_cr(line: &[u8]) -> std::borrow::Cow<'_, str> { mod tests { use super::*; - /// The host these tests probe through: this machine, which is what the GUI - /// hands `probe` for a local pane. fn h() -> crate::host::SharedHost { crate::host::local::LocalHost::new() } - /// A line split across two chunks is rejoined, not cut in half — the whole - /// reason `LineSplitter` exists, since a 64 KiB read boundary lands mid-line - /// on essentially every large diff. #[test] fn line_splitter_rejoins_across_chunks() { let mut split = LineSplitter::default(); @@ -431,9 +249,6 @@ mod tests { assert_eq!(got, ["alpha", "beta", "gamma"]); } - /// A final line with no terminator still arrives, and `\r\n` endings are - /// normalised — git on Windows writes them and the parser must not see the - /// carriage return as content. #[test] fn line_splitter_handles_crlf_and_a_missing_final_newline() { let mut split = LineSplitter::default(); @@ -443,9 +258,6 @@ mod tests { assert_eq!(got, ["one", "two", "three"]); } - /// Invalid UTF-8 is replaced, not fatal: the buffered path drops the entire - /// output on one bad byte, which for a diff of a latin-1 file means showing - /// nothing at all. #[test] fn line_splitter_replaces_invalid_utf8() { let mut split = LineSplitter::default(); @@ -456,20 +268,12 @@ mod tests { assert!(got[0].starts_with("caf"), "{:?}", got[0]); } - /// A line past [`MAX_LINE`] is cut rather than accumulated, and says so. - /// - /// This is what makes the streaming read's memory claim true: a line is only - /// complete at its `\n`, so without a cap one minified-bundle line rebuilds - /// the whole-output peak that streaming exists to remove. The lines around - /// it are untouched, and the byte count in the notice is the *dropped* - /// remainder, so the reader can tell how much is missing. #[test] fn line_splitter_caps_one_absurd_line() { let mut split = LineSplitter::default(); let mut got = Vec::new(); let huge = vec![b'x'; MAX_LINE + 5_000]; split.push(b"before\n", |l| got.push(l.to_string())); - // Fed in pieces, so the cap has to hold across chunk boundaries too. for piece in huge.chunks(64 * 1024) { split.push(piece, |l| got.push(l.to_string())); } @@ -499,8 +303,6 @@ mod tests { ); } - /// A cut line with no trailing newline still comes out of `finish`, rather - /// than being dropped along with the bytes past the cap. #[test] fn line_splitter_caps_a_final_unterminated_line() { let mut split = LineSplitter::default(); @@ -511,14 +313,12 @@ mod tests { assert!(got[0].contains("7 more bytes"), "{}", got[0]); } - /// The streaming read and the buffered one must agree line for line — - /// overriding `git_lines` is an optimisation, never a behaviour change. #[test] fn streaming_and_buffered_reads_agree() { let here = Path::new(env!("CARGO_MANIFEST_DIR")); let args = ["log", "--oneline", "-n", "40"]; let Ok(code) = git_stream(here, &args, |_| true) else { - return; // no git, or not a work tree — nothing to compare + return; }; if code != Some(0) { return; @@ -541,7 +341,6 @@ mod tests { assert!(!streamed.is_empty(), "this repo has commits"); } - /// A tmp path that is not a git repo yields no snapshot (and never panics). #[test] fn non_repo_is_none() { let dir = std::env::temp_dir().join("tty7-git-status-not-a-repo-xyz"); @@ -549,14 +348,11 @@ mod tests { assert_eq!(probe(&*h(), &dir), None); } - /// A path that doesn't exist is `None`, not a panic. #[test] fn missing_path_is_none() { assert_eq!(probe(&*h(), Path::new("/no/such/tty7/path/here")), None); } - /// This repo (the crate root is inside the tty7 work tree) reports a branch - /// and a root, exercising the real `git` probe end-to-end. #[test] fn own_repo_has_a_branch_and_root() { let here = env!("CARGO_MANIFEST_DIR"); @@ -564,34 +360,23 @@ mod tests { assert!(!snap.branch.is_empty()); assert!(Path::new(here).starts_with(&snap.root)); } - // If the crate is built outside a work tree (e.g. a vendored tarball), - // `None` is the correct answer and the assertions above are skipped. } - /// The four shapes `repo_home` has to tell apart, straight from the - /// `--git-dir` / `--git-common-dir` pair the merged `rev-parse` returns. #[test] fn repo_home_resolves_worktree_layouts() { let root = Path::new("/repo/.wt/feat"); - // A main checkout: the two dirs agree, so the work tree is its own home. assert_eq!( repo_home(Path::new("/repo"), Some("/repo/.git"), Some("/repo/.git")), PathBuf::from("/repo") ); - // A linked worktree: the common dir is the main checkout's `.git`, so - // the home is that `.git`'s parent — the main work tree. assert_eq!( repo_home(root, Some("/repo/.git/worktrees/feat"), Some("/repo/.git")), PathBuf::from("/repo") ); - // A bare repo with worktrees hanging off it: no `.git` component to - // strip, so the bare dir itself is the shared key. assert_eq!( repo_home(root, Some("/bare.git/worktrees/feat"), Some("/bare.git")), PathBuf::from("/bare.git") ); - // A git too old (or too odd) to answer both: degrade to the work tree - // rather than guessing a grouping key. assert_eq!( repo_home(root, Some("/repo/.git"), None), root.to_path_buf() diff --git a/crates/tty7-core/src/core/gitignore.rs b/crates/tty7-core/src/core/gitignore.rs index 8b11df56..067aae66 100644 --- a/crates/tty7-core/src/core/gitignore.rs +++ b/crates/tty7-core/src/core/gitignore.rs @@ -1,47 +1,20 @@ -//! The `.gitignore` chain a directory listing is scored against. -//! -//! One matcher is compiled per directory that has a `.gitignore`, cached by -//! that directory's path, and a path is scored by walking the chain from the -//! tree root down to the path's own parent — **the deepest match wins**, so a -//! nested `.gitignore`'s whitelist (`!pattern`) can un-ignore what an ancestor -//! ignored, which is what git itself does. -//! -//! Lives in `tty7-core` rather than beside the file tree because the answer has -//! to be identical on both sides of a remote workspace: the GUI dims ignored -//! entries for a local tree, and the server has to dim exactly the same ones -//! for a remote tree. One implementation, no drift. -//! -//! Compiling is lazy and cached (including the negative case — a directory with -//! no `.gitignore` caches as `None`), so a chain that is carried across -//! listings pays for each directory once. `Arc`, so a chain can be cloned onto -//! a background thread and its compiled matchers shared rather than rebuilt. - use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::Arc; use ignore::gitignore::Gitignore; -/// Compiled `.gitignore` matchers, keyed by the directory each came from -/// (`None` = that directory has no `.gitignore`). #[derive(Default, Clone)] pub struct GitignoreChain { matchers: HashMap>>, } impl GitignoreChain { - /// Walk the `.gitignore` chain from `root` down to `path`'s directory and - /// report whether `path` ends up ignored; the deepest match wins - /// (whitelist `!patterns` un-ignore). - /// - /// `is_dir` matters because gitignore patterns can be directory-only - /// (`build/`). Paths outside `root` simply score against nothing. pub fn is_ignored(&mut self, path: &Path, is_dir: bool, root: &Path) -> bool { let Some(parent) = path.parent() else { return false; }; let mut state = false; - // Ancestor chain root → parent, in order. let mut chain: Vec<&Path> = parent .ancestors() .take_while(|a| a.starts_with(root)) @@ -72,25 +45,18 @@ impl GitignoreChain { state } - /// Fold another chain's compiled matchers in — how a background listing - /// hands back the ones it had to compile so the next listing re-uses them. pub fn absorb(&mut self, other: Self) { self.matchers.extend(other.matchers); } - /// Drop every compiled matcher, so the next scoring recompiles from disk. - /// The invalidation a `.gitignore` edit triggers. pub fn clear(&mut self) { self.matchers.clear(); } - /// How many directories have been scored (and so cached) so far — the - /// negative entries for directories without a `.gitignore` included. pub fn len(&self) -> usize { self.matchers.len() } - /// Whether nothing has been compiled or cached yet. pub fn is_empty(&self) -> bool { self.matchers.is_empty() } @@ -100,7 +66,6 @@ impl GitignoreChain { mod tests { use super::*; - /// Write a `.gitignore` into `dir` (creating it) with the given patterns. fn write_ignore(dir: &Path, body: &str) { std::fs::create_dir_all(dir).unwrap(); std::fs::write(dir.join(".gitignore"), body).unwrap(); @@ -114,8 +79,6 @@ mod tests { dir } - /// The deepest `.gitignore` wins, so a nested whitelist un-ignores what the - /// root ignored — the rule the file tree's dimming depends on. #[test] fn the_deepest_match_wins() { let root = scratch("deepest"); @@ -131,8 +94,6 @@ mod tests { let _ = std::fs::remove_dir_all(&root); } - /// A directory-only pattern (`build/`) matches the directory, not a file of - /// the same name — which is why scoring takes `is_dir`. #[test] fn directory_only_patterns_need_is_dir() { let root = scratch("dironly"); @@ -145,8 +106,6 @@ mod tests { let _ = std::fs::remove_dir_all(&root); } - /// `clear` forces a recompile, so an edited `.gitignore` takes effect; - /// without it the cached matcher would answer from the old patterns. #[test] fn clear_lets_an_edited_gitignore_take_effect() { let root = scratch("clear"); diff --git a/crates/tty7-core/src/core/keychain.rs b/crates/tty7-core/src/core/keychain.rs index ed341421..dcc60127 100644 --- a/crates/tty7-core/src/core/keychain.rs +++ b/crates/tty7-core/src/core/keychain.rs @@ -1,52 +1,18 @@ -//! The *naming* half of the SSH credential vault: how a keychain entry is -//! addressed, and the secret-free pointer that `config.json` persists. -//! -//! Secrets (passwords, private-key passphrases) live only in the platform secret -//! store — never in `config.json`. A profile persists at most a [`CredentialRef`], -//! which *names* a keychain entry but carries no secret. Per PRD §7.2 entries are -//! keyed by **endpoint**, not by profile: -//! -//! - passwords → service `tty7-ssh`, account `@:` -//! - key passphrases → service `tty7-ssh-key`, account `` -//! -//! Endpoint keying lets a QuickConnect (which has no profile) still "remember" a -//! password, lets several profiles pointing at one endpoint share one credential, -//! and means changing a password touches exactly one entry. -//! -//! **The store itself is not here.** The `CredentialStore` trait, its OS-keychain -//! backend and the in-memory test double live in the GUI crate -//! (`tty7::core::keychain`), because nothing in this crate reads or writes a -//! secret: the daemon receives already-resolved secrets on the wire (see -//! `daemon::protocol`'s `NativeSshSpec`) and the headless `tty7-server` runs on -//! boxes that have no OS keychain at all. Keeping `keyring` out of this crate's -//! manifest is what keeps a static `tty7-server` from linking the whole -//! `zbus`/`secret-service` stack it can never use. -//! -//! What has to stay is exactly what `Config` needs to parse `config.json` -//! identically on the server: the account-naming scheme and [`CredentialRef`]. - use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha512}; -/// Keychain service name for endpoint passwords. pub const SERVICE_PASSWORD: &str = "tty7-ssh"; -/// Keychain service name for private-key passphrases. pub const SERVICE_KEY_PASSPHRASE: &str = "tty7-ssh-key"; -/// Which kind of secret a [`CredentialRef`] points at. The kind selects the -/// keychain *service*; the ref's `account` selects the entry within it. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)] #[serde(rename_all = "kebab-case")] pub enum CredentialKind { - /// An endpoint password (`tty7-ssh` service, `user@host:port` account). #[default] Password, - /// A private-key passphrase (`tty7-ssh-key` service, key-sha512-hex account). KeyPassphrase, } impl CredentialKind { - /// The keychain service name this kind stores under. pub fn service(self) -> &'static str { match self { CredentialKind::Password => SERVICE_PASSWORD, @@ -55,17 +21,11 @@ impl CredentialKind { } } -/// A persisted, secret-free pointer to a keychain entry. This is the only -/// credential-related thing that ever lands in `config.json`. #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] #[serde(default)] pub struct CredentialRef { - /// Whether this names a password or a key passphrase. #[serde(deserialize_with = "crate::core::config::de_lenient")] pub kind: CredentialKind, - /// The keychain "account": `user@host:port` for [`CredentialKind::Password`], - /// or the sha512-hex of the key-file contents for - /// [`CredentialKind::KeyPassphrase`]. pub account: String, } @@ -79,7 +39,6 @@ impl Default for CredentialRef { } impl CredentialRef { - /// Reference the password entry for an endpoint. pub fn password(user: &str, host: &str, port: u16) -> Self { Self { kind: CredentialKind::Password, @@ -87,8 +46,6 @@ impl CredentialRef { } } - /// Reference the passphrase entry for a private key, given the sha512-hex of - /// its file contents (see [`key_account_from_contents`]). pub fn key_passphrase(key_sha512_hex: impl Into) -> Self { Self { kind: CredentialKind::KeyPassphrase, @@ -96,28 +53,17 @@ impl CredentialRef { } } - /// The keychain service this ref resolves under. pub fn service(&self) -> &'static str { self.kind.service() } } -/// The endpoint account string used to key a password entry: `user@host:port`. pub fn endpoint_account(user: &str, host: &str, port: u16) -> String { format!("{user}@{host}:{port}") } -/// The account string used to key a private-key passphrase entry: the lowercase -/// sha512-hex digest of the key file's raw contents. Endpoint-independent, so the -/// same encrypted key reused across hosts shares one stored passphrase. -/// -/// Only the GUI calls this — it is the side that reads the key file — but the -/// account *name* is part of the persisted config contract, the same as -/// [`endpoint_account`], so both halves of PRD §7.2's keying scheme stay in one -/// place rather than drifting apart across the crate boundary. pub fn key_account_from_contents(key_bytes: &[u8]) -> String { let digest = Sha512::digest(key_bytes); - // Lowercase hex, no separators. let mut hex = String::with_capacity(digest.len() * 2); for byte in digest { use std::fmt::Write as _; @@ -141,7 +87,6 @@ mod tests { "deploy@10.0.0.5:2222" ); - // sha512 hex is 128 chars, lowercase, and deterministic. let a = key_account_from_contents(b"-----BEGIN OPENSSH PRIVATE KEY-----\n"); let b = key_account_from_contents(b"-----BEGIN OPENSSH PRIVATE KEY-----\n"); assert_eq!(a, b); @@ -163,13 +108,11 @@ mod tests { fn credential_ref_round_trips_and_hides_secret() { let cref = CredentialRef::password("deploy", "10.0.0.5", 22); let json = serde_json::to_string(&cref).unwrap(); - // Only kind + account are serialized — never a secret. assert!(json.contains("deploy@10.0.0.5:22")); assert!(json.contains("password")); let back: CredentialRef = serde_json::from_str(&json).unwrap(); assert_eq!(back, cref); - // A bad `kind` value falls back to the default rather than failing the parse. let lenient: CredentialRef = serde_json::from_str(r#"{"kind":"bogus","account":"x"}"#).unwrap(); assert_eq!(lenient.kind, CredentialKind::Password); diff --git a/crates/tty7-core/src/core/logfile.rs b/crates/tty7-core/src/core/logfile.rs index f0cef49b..16bf0c31 100644 --- a/crates/tty7-core/src/core/logfile.rs +++ b/crates/tty7-core/src/core/logfile.rs @@ -1,39 +1,14 @@ -//! File logger — the `log::` records that otherwise go nowhere. -//! -//! tty7 depends on the `log` facade but shipped no backend, so every -//! `log::info!` / `log::warn!` in the tree was compiled in and then discarded. -//! That is survivable for the GUI, which can put a failure on screen. It is not -//! survivable for the **daemon**: [`crate::daemon::spawn`] detaches it with its -//! stdio pointed at `/dev/null`, so a remote install that refused, a connection -//! that dropped, or a pane that died left no trace anywhere — the only artifact -//! the process could produce was `crash.log`, and only if it panicked. -//! -//! So: one append-only file next to `crash.log`, same size cap and same -//! best-effort discipline. Logging must never be the reason something fails. -//! -//! ## Level -//! -//! `TTY7_LOG` (or `RUST_LOG`) sets it — `off` / `error` / `warn` / `info` / -//! `debug` / `trace`. **Default `off`**: this writes to a user's disk forever, -//! and a terminal that logs by default is a terminal that fills a disk while -//! nobody is watching. Ask for it when diagnosing, which is also the only time -//! the records are worth anything. - use std::fmt::Write as _; use std::path::PathBuf; use std::sync::{Mutex, OnceLock}; use log::{LevelFilter, Log, Metadata, Record}; -/// Rewrite the log once it passes this. Same cap as `crash.log`, larger because -/// a debug session produces many small lines rather than a few big backtraces. const MAX_BYTES: u64 = 4 * 1024 * 1024; struct FileLogger { role: &'static str, path: PathBuf, - /// Serializes writes so two threads cannot interleave halves of a line. - /// Contended only while logging is on, which is not the default. lock: Mutex<()>, } @@ -63,15 +38,6 @@ impl Log for FileLogger { fn flush(&self) {} } -/// Install the logger for this process, if the environment asks for one. -/// -/// `role` labels the records, since the GUI and the daemon it spawns share one -/// config dir and therefore one log file — the same convention `crash.log` -/// uses, and the reason a line can be attributed at all. -/// -/// Idempotent and silent on failure: a second call, a missing config dir, or a -/// read-only disk all leave the process running with no logger, which is -/// exactly what it had before. pub fn install(role: &'static str) { let level = level_from_env(); if level == LevelFilter::Off { @@ -80,10 +46,6 @@ pub fn install(role: &'static str) { let Some(path) = log_path() else { return; }; - // A `static` rather than `set_boxed_logger`, which needs `log`'s `std` - // feature — not enabled here, and not worth enabling for one allocation - // that lives for the whole process anyway. `OnceLock` is also what makes a - // second call harmless. static LOGGER: OnceLock = OnceLock::new(); let logger = LOGGER.get_or_init(|| FileLogger { role, @@ -95,12 +57,6 @@ pub fn install(role: &'static str) { } } -/// `TTY7_LOG` first, then `RUST_LOG` — the former so turning on tty7's logging -/// does not also turn on every library that reads `RUST_LOG`. -/// -/// Only a bare level is understood, not `RUST_LOG`'s per-module syntax: a -/// half-supported filter language is worse than an obvious one, because -/// `TTY7_LOG=tty7_core::daemon=debug` would silently mean "off". fn level_from_env() -> LevelFilter { let raw = std::env::var("TTY7_LOG") .or_else(|_| std::env::var("RUST_LOG")) @@ -139,9 +95,6 @@ fn log_path() -> Option { crate::core::config::config_path("tty7.log") } -/// `HH:MM:SS.mmm` — the time of day, which is what you compare against "I -/// clicked it just now". The date is in `crash.log`'s records and in the file's -/// own mtime; repeating it on every line would cost more than it tells. fn timestamp() -> String { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -161,18 +114,11 @@ mod tests { use super::*; use log::Level; - /// The default has to be `Off`. A terminal that logs to disk unasked fills - /// a disk on a machine nobody is watching — and the daemon outlives every - /// window, so there is no session boundary to bound it. #[test] fn logging_is_off_unless_asked_for() { - // Not via the environment: mutating it is `unsafe` in edition 2024 and - // races every other test in the binary. The parser is the whole - // decision, so it is what gets tested. assert_eq!(parse_level(""), LevelFilter::Off); assert_eq!(parse_level(" "), LevelFilter::Off); assert_eq!(parse_level("nonsense"), LevelFilter::Off); - // `RUST_LOG`'s per-module syntax is deliberately *not* half-supported. assert_eq!(parse_level("tty7_core::daemon=debug"), LevelFilter::Off); } @@ -185,8 +131,6 @@ mod tests { assert_eq!(parse_level("TRACE"), LevelFilter::Trace); } - /// A run away log must not grow without bound: past the cap the file is - /// rewritten rather than appended to. #[test] fn the_file_is_rewritten_once_it_passes_the_cap() { let path = std::env::temp_dir().join(format!("tty7-logfile-{}.log", std::process::id())); @@ -205,9 +149,6 @@ mod tests { let _ = std::fs::remove_file(&path); } - /// Records name which process wrote them: the GUI and the daemon it spawns - /// share one config dir, so an unattributed line is ambiguous exactly when - /// it matters (which side dropped the connection?). #[test] fn a_record_names_its_role_and_target() { let path = std::env::temp_dir().join(format!("tty7-logrec-{}.log", std::process::id())); diff --git a/crates/tty7-core/src/core/machine.rs b/crates/tty7-core/src/core/machine.rs index 881920c6..7a0173cc 100644 --- a/crates/tty7-core/src/core/machine.rs +++ b/crates/tty7-core/src/core/machine.rs @@ -1,81 +1,3 @@ -//! The machine's workspace tree, owned by the daemon: the tmux model. -//! -//! # What this replaces, and why -//! -//! The previous design (`core::workspace_store`, since deleted) was an -//! *opaque* record store, where the client owned the schema and the server -//! filed JSON blobs it never read. That shape was right when there was -//! exactly one writer (the GUI) -//! and the server's only job was to make a laptop's layout visible from a -//! desktop. It stops being right the moment two clients — a GUI and a CLI, or -//! two GUIs — write concurrently: whole-record `Put` is last-writer-wins, and -//! a lost update's only symptom is a tab that quietly un-moves itself. -//! -//! So the daemon now owns the tree outright, the way tmux's server owns its -//! sessions: clients send *semantic operations* ("split this pane", "rename -//! that tab"), the daemon validates each against the tree it holds, persists, -//! and broadcasts an incremental [`LayoutDelta`] to every other client. Two -//! clients editing different corners of one workspace both land; a client that -//! falls behind re-pulls the tree it fell behind on. -//! -//! # The shape of the tree -//! -//! ```text -//! Machine -//! ├── workspaces: Vec }> -//! └── panes: Vec ← the pane registry -//! ``` -//! -//! A [`PaneNode::Leaf`] holds a **pane id and nothing else**. Everything that -//! used to ride the client's leaf — cwd, ssh spec, agent identity — is a fact -//! *about the pane*, observed by the daemon itself (OSC 7, the agent hooks, -//! the spawn request), and lives once in the pane registry rather than being a -//! snapshot some client remembered. That is what makes revival sound: after a -//! daemon restart the tree still names its panes, every named pane is known -//! dead (see below), and the pane's own record carries exactly what a client -//! needs to start its successor — the cwd to spawn in, the SSH spec to -//! reconnect, the agent session to `--resume`. -//! -//! # Restart means every pane is dead, and the tree says so -//! -//! PTYs die with the daemon process, so [`load_machine`] force-clears every -//! [`PaneRecord::live`] flag: a freshly-opened store *cannot* claim a live -//! pane, and a leaf whose record answers `live == false` is by construction -//! "awaiting revival". No client-side instance stamps, no id-reuse heuristics -//! — the process that owns the PTYs is the process answering the question, so -//! the answer is a fact rather than a guess. -//! -//! # Paths are `String` here -//! -//! The tree crosses the control wire (replies and [`LayoutDelta`] events), and -//! the dialect's rule is that paths travel as `String` — `PathBuf`'s serde -//! form for a non-UTF-8 path is platform-dependent and unencodable as JSON, -//! and one such cwd must not make a whole workspace unreadable. Lossy -//! conversion happens where the fact is recorded, which is also where the loss -//! is visible in a log. -//! -//! # Concurrency -//! -//! One mutex over the tree *and* the file write, exactly like the store this -//! replaces: the on-disk order is the in-memory order. Deltas are delivered -//! outside the lock, and a subscriber's callback must only enqueue — a peer -//! that stopped reading its socket must not stall another peer's edit. -//! -//! # Two durabilities, because two kinds of change -//! -//! A *structural* edit is persisted before its delta goes out: a change nobody -//! can re-read must be a change nobody was told about ([`Persist::Now`]). -//! -//! An *observation* — a pane's cwd, its agent, its liveness, a workspace's -//! focus stamp — takes [`Persist::Soon`] instead: the delta goes out at once -//! and the file catches up within [`FACT_FLUSH_INTERVAL`]. These arrive from -//! the PTY reader threads, one per OSC 7 report, i.e. once per prompt per pane; -//! writing the whole document (and `fsync`ing it) on each would put a disk -//! stall in the pane's own output path and, because the write happens under -//! `notify_order`, would serialize every other client's edits behind it. What -//! is risked by deferring is at most [`FACT_FLUSH_INTERVAL`] of observations on -//! a `SIGKILL`; the layout itself is never deferred. - use std::io; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; @@ -88,55 +10,20 @@ use crate::core::cli_agent::CLIAgent; use crate::core::session::WorkspaceId; use crate::daemon::protocol::NativeSshSpec; -/// The file's name under the data directory ([`DATA_DIR_ENV`] resolves where -/// that is). -/// -/// Deliberately **not** `workspaces.json`: that name belonged to the retired -/// opaque-record store, whose reader quarantined anything it could not parse. -/// A build downgraded across that refactor must find its old file untouched, -/// and this build's tree must not be "repaired" away by the old reader. pub const MACHINE_FILE: &str = "machine.json"; -/// Overrides where the machine's data directory lives. Set by tests and by a -/// second server on a shared box — the same escape hatch -/// [`CONTROL_SOCK_ENV`](crate::host::server::CONTROL_SOCK_ENV) is for the -/// socket. pub const DATA_DIR_ENV: &str = "TTY7_DATA_DIR"; -/// Ceiling on workspaces, carried over from the old store: a client looping on -/// "create workspace" should hit a named error rather than grow the file until -/// the disk fills. pub const MAX_WORKSPACES: usize = 1024; -/// Ceiling on panes the registry will hold. Panes are bounded by what a machine -/// can actually run, so this only ever catches a client gone wrong. pub const MAX_PANES: usize = 16 * 1024; -/// How long an observation ([`Persist::Soon`]) may sit in memory before the -/// flusher writes it out. -/// -/// Short enough that a crash costs a stale cwd rather than a stale layout, long -/// enough that a shell looping over directories — a `cd` per iteration, per -/// pane — costs one write rather than one per iteration. #[cfg(not(test))] pub const FACT_FLUSH_INTERVAL: Duration = Duration::from_secs(2); -/// Out of reach under test, so the assertions about *what defers* are not also -/// assertions about how fast the suite runs: a test that wants the write calls -/// [`MachineStore::flush`], which is the same code path the timer takes. #[cfg(test)] pub const FACT_FLUSH_INTERVAL: Duration = Duration::from_secs(600); -// --------------------------------------------------------------------------- -// Identity -// --------------------------------------------------------------------------- - -/// Stable identity for one tab, minted by the daemon when the tab is created -/// and carried across restarts. -/// -/// Tabs need an identity of their own because operations address them across -/// reorders: "rename tab 2" from a client that has not yet heard about another -/// client's move would rename the wrong tab, while "rename tab `t-…`" cannot. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(transparent)] pub struct TabId(uuid::Uuid); @@ -159,9 +46,6 @@ impl std::fmt::Display for TabId { } } -/// Split orientation. Its own enum rather than a reuse of the client session -/// model's, because this schema is the daemon's to evolve and must not be -/// coupled to a file format that is on its way out. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum Axis { @@ -169,7 +53,6 @@ pub enum Axis { Vertical, } -/// Which child of a [`PaneNode::Split`] a path step descends into. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum Side { @@ -177,48 +60,22 @@ pub enum Side { B, } -// --------------------------------------------------------------------------- -// The tree -// --------------------------------------------------------------------------- - -/// Everything one machine's daemon knows about its workspaces. The document -/// [`MachineStore`] persists, and the payload a full pull returns. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub struct Machine { #[serde(default)] pub workspaces: Vec, - /// The pane registry: every pane the tree references, by id. Facts about - /// panes live here exactly once — see the module header. #[serde(default)] pub panes: Vec, } -/// Who is currently attached to a workspace. -/// -/// **Data only.** The takeover behaviour — push `Preempted { by }` to the old -/// session, close its streams, offer a take-back button — lives in the control -/// server. What is here is the record that machinery needs to exist before it -/// can be written: the random token that tells two connections from the same -/// client apart, and the hostname that fills in "already open on ". Both -/// arrive in the [`ControlHello`](crate::daemon::control::ControlHello). -/// -/// **Never persisted** (the field carrying it is `#[serde(skip)]`): an -/// attachment describes a live connection; after a server restart there are -/// none, and a stale one on disk would report a takeover against a client -/// that no longer exists. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Attachment { - /// The client's per-session random token, from `ControlHello::client_token`. pub token: String, - /// The client machine's hostname, shown to the user in the preempted - /// window's status bar. pub hostname: String, - /// Unix seconds when the attach happened. pub since: u64, } impl Attachment { - /// An attachment stamped now. pub fn new(token: impl Into, hostname: impl Into) -> Attachment { Attachment { token: token.into(), @@ -228,27 +85,18 @@ impl Attachment { } } -/// One workspace: a named group of tabs. The unit a window shows and a client -/// attaches to. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Workspace { #[serde(default)] pub id: WorkspaceId, - /// User-set name. `None` lets clients derive one from the tabs' repo/cwd. #[serde(default)] pub name: Option, - /// Unix seconds when a client last focused this workspace. 0 == never. #[serde(default)] pub last_active: u64, #[serde(default)] pub tabs: Vec, - /// Which tab is active. `None` for a workspace with no tabs (a real state: - /// the home page), and healed to a real tab whenever one exists. #[serde(default)] pub active_tab: Option, - /// Who is attached right now. **Runtime only** — an attachment describes a - /// live connection, and a stale one on disk would report a takeover - /// against a client that no longer exists. #[serde(skip)] pub attachment: Option, } @@ -266,25 +114,18 @@ impl Default for Workspace { } } -/// One tab: a pane tree plus its labels. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Tab { #[serde(default)] pub id: TabId, - /// User-set name from "Rename Tab". `None` falls back to a title-derived - /// label at render time, on the client. #[serde(default)] pub name: Option, - /// The tab's sidebar repo group (its repository home), as the client that - /// resolved it reported. A path in the *machine's* namespace, as a string - /// for the same reason every other path here is. #[serde(default)] pub sidebar_group: Option, pub root: PaneNode, } impl Tab { - /// A tab holding exactly `pane`. pub fn leaf(pane: u64) -> Tab { Tab { id: TabId::new(), @@ -295,8 +136,6 @@ impl Tab { } } -/// A tab's split structure. Leaves hold a pane **id and nothing else**; every -/// fact about the pane lives in the registry ([`PaneRecord`]). #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum PaneNode { Leaf { @@ -316,7 +155,6 @@ fn default_ratio() -> f32 { } impl PaneNode { - /// Every pane id under this node, in layout order. pub fn pane_ids(&self) -> Vec { let mut out = Vec::new(); self.collect_panes(&mut out); @@ -333,7 +171,6 @@ impl PaneNode { } } - /// Whether `pane` appears as a leaf under this node. pub fn contains(&self, pane: u64) -> bool { match self { PaneNode::Leaf { pane: p } => *p == pane, @@ -341,9 +178,6 @@ impl PaneNode { } } - /// The node a split path resolves to, if the path is still valid. Public - /// for the same reason the surgery methods are: a client applying a - /// [`LayoutDelta::RatioChanged`] resolves the identical path. pub fn descend_mut(&mut self, path: &[Side]) -> Option<&mut PaneNode> { match path.split_first() { None => Some(self), @@ -357,13 +191,6 @@ impl PaneNode { } } - /// Replace the leaf holding `pane` with a split of it and `new`, answering - /// whether the leaf was found. - /// - /// Public (as are [`remove_leaf`](PaneNode::remove_leaf) and - /// [`replace_leaf`](PaneNode::replace_leaf)) because a client predicting the - /// outcome of its own operation must run *this* surgery, not a - /// reimplementation that could disagree with the server's. pub fn split_leaf(&mut self, pane: u64, new: u64, axis: Axis, ratio: f32, first: bool) -> bool { match self { PaneNode::Leaf { pane: p } if *p == pane => { @@ -386,9 +213,6 @@ impl PaneNode { } } - /// Remove the leaf holding `pane`, collapsing its parent split so the - /// sibling takes the whole space. `None` when the node *is* that leaf — - /// the caller then removes the tab. `Some(found)` otherwise. pub fn remove_leaf(&mut self, pane: u64) -> Option { match self { PaneNode::Leaf { pane: p } => { @@ -410,15 +234,12 @@ impl PaneNode { match a.remove_leaf(pane) { Some(true) => Some(true), Some(false) => b.remove_leaf(pane), - // A whole subtree cannot be the leaf; unreachable because - // leaf children are handled above, but total anyway. None => Some(false), } } } } - /// Rebind the leaf holding `old` to `new`, answering whether it was found. pub fn replace_leaf(&mut self, old: u64, new: u64) -> bool { match self { PaneNode::Leaf { pane } if *pane == old => { @@ -431,45 +252,20 @@ impl PaneNode { } } -/// One pane, as the daemon knows it: identity, liveness, and the facts a dead -/// pane's successor is started from. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct PaneRecord { - /// The daemon's pane id — the same number the pane protocol's `Spawn` - /// answered with. One id space, so a leaf, a `PaneInfo` and this record - /// can only ever mean the same pane. pub id: u64, - /// Working directory, from OSC 7 (or the spawn request until the first - /// report). The machine's own namespace. #[serde(default)] pub cwd: Option, - // No `title` field, deliberately. The pane's title is a *live* answer (a - // foreground-process query at `PaneInfo` time), not tracked state the - // reader loop observes — so a record field for it was never written, and - // a field that is always empty is a standing invitation to trust it. - // Revival labels derive from `cwd` and `agent` instead. - /// The native-SSH spec this pane ran, **secrets stripped** - /// ([`NativeSshSpec::without_secrets`]). What a revival reconnects with. #[serde(default)] pub ssh_spec: Option>, - /// The coding agent running in this pane, if the hooks reported one. #[serde(default)] pub agent: Option, - /// Whether a PTY for this pane exists **in this daemon process**. - /// - /// Serialized, because clients read it off the wire — `false` on a leaf's - /// record *is* the "awaiting revival" state a client renders and revives. - /// But it is a fact about a *process*, so [`load_machine`] force-clears it - /// on open: PTYs die with the daemon, and whatever the file claims, a - /// freshly-started process has none. No client-side instance stamp or - /// id-reuse heuristic is needed, because the process that owns the PTYs is - /// the one answering. #[serde(default)] pub live: bool, } impl PaneRecord { - /// A bare record for `id`, with no facts yet. pub fn new(id: u64) -> PaneRecord { PaneRecord { id, @@ -481,29 +277,17 @@ impl PaneRecord { } } -/// What the daemon knows about the agent a pane runs — enough to resume the -/// conversation in a successor pane after the original dies. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct AgentFacts { pub agent: CLIAgent, - /// The agent's own session id, from its `session-start` hook. What - /// `claude --resume ` (and each agent's equivalent) takes. #[serde(default)] pub session_id: Option, - /// The argv the agent was launched with, so a resume carries the user's - /// flags (`--dangerously-skip-permissions`, …) instead of resuming bare. #[serde(default)] pub launch_argv: Option>, - /// Latest coarse status the daemon's sniffer folded from the agent's - /// hook events. Display only; never load-bearing. #[serde(default)] pub status: Option, } -/// The facts a client hands over when an operation introduces a pane the store -/// has not seen — a new tab's pane, a split's second pane, a revival's -/// replacement. The pane itself was spawned over the pane protocol (that is -/// where PTYs come from); this is its birth certificate for the tree. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct PaneSeed { pub pane: u64, @@ -516,7 +300,6 @@ pub struct PaneSeed { } impl PaneSeed { - /// A seed carrying only the id. pub fn bare(pane: u64) -> PaneSeed { PaneSeed { pane, @@ -537,23 +320,9 @@ impl PaneSeed { } } -// --------------------------------------------------------------------------- -// Deltas -// --------------------------------------------------------------------------- - -/// One incremental change to one workspace's tree, as broadcast to every -/// client but the writer. -/// -/// The granularity rule: label changes are carried field-by-field, structural -/// changes carry the whole affected [`Tab`]. A tab is small (a few hundred -/// bytes), and shipping it whole means a client applies structure by -/// *replacement* instead of by re-implementing the server's tree surgery — -/// the class of client/server divergence that cannot happen is the class that -/// was never written. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum LayoutDelta { - /// A workspace appeared. Carries it whole (it is newborn, so small). WorkspaceCreated { workspace: Workspace, }, @@ -564,16 +333,9 @@ pub enum LayoutDelta { WorkspaceTouched { last_active: u64, }, - /// Which tab is active changed — by an explicit set, by a created tab - /// becoming active, or by the close paths healing a dangling active id. - /// Emitted for every *implicit* change too, so a mirroring client never - /// has to re-implement the server's heal rule; the one inexpressible case - /// (a workspace losing its last tab has no active tab) needs no delta, - /// because "no tabs → no active tab" is a fact, not surgery. ActiveTabChanged { tab: TabId, }, - /// A tab appeared at `at`. Structural, so it carries the tab whole. TabCreated { at: usize, tab: Tab, @@ -593,45 +355,31 @@ pub enum LayoutDelta { tab: TabId, group: Option, }, - /// A tab's pane structure changed (split, close, revival rebind). The tab - /// is carried whole — see the enum's granularity rule. `pane` names the - /// registry record that changed alongside, when one did. TabRestructured { tab: Tab, pane: Option, }, - /// One split's divider moved. Fine-grained because ratio drags are the - /// hottest structural edit and the only one where shipping a whole tab - /// per event would be felt. RatioChanged { tab: TabId, path: Vec, ratio: f32, }, - /// A pane's facts changed (cwd, agent, liveness). Not a layout change, - /// but clients rendering "awaiting revival" or an agent chip need it. PaneFacts { pane: PaneRecord, }, } -/// Identifies one subscriber, so a writer is excluded from its own echo. -/// Same shape as the old store's, for the same reason. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct SubscriberId(pub u64); -/// What a subscriber receives: which workspace, and what changed. Runs on the -/// writer's thread — enqueue and return. pub type Notify = Arc; -/// A live subscription; dropping it unsubscribes. pub struct Subscription { store: Arc, id: SubscriberId, } impl Subscription { - /// This subscriber's id — pass it as the `origin` of your own writes. pub fn id(&self) -> SubscriberId { self.id } @@ -643,53 +391,25 @@ impl Drop for Subscription { } } -// --------------------------------------------------------------------------- -// The store -// --------------------------------------------------------------------------- - -/// How the store asks the process serving panes whether an id has a live PTY -/// *right now* — see [`MachineStore::set_liveness_probe`]. pub type LivenessProbe = Arc bool + Send + Sync>; -/// The daemon's tree, and the one writer to its file. pub struct MachineStore { path: PathBuf, state: Mutex, - /// Answers "does this pane have a live PTY right now", installed by the - /// daemon's pane server. `None` (a store opened by tests, or before the - /// pane listener is wired) trusts the seed. See - /// [`set_liveness_probe`](MachineStore::set_liveness_probe). liveness: Mutex>, - /// Serializes each mutation *with its own delivery*. The state lock alone - /// orders the mutations, but deltas are delivered after it is released — - /// without this, writer B's deltas could overtake writer A's and every - /// subscriber would apply the store's history in the wrong order, ending - /// on the losing state with no error to trigger a re-pull. Cheap to hold - /// across delivery because a subscriber's callback is enqueue-only by - /// contract. Always taken before `state`, never inside it. notify_order: Mutex<()>, subscribers: Mutex>, next_subscriber: AtomicU64, - /// Set by a [`Persist::Soon`] mutation, cleared by every write — the - /// flusher's whole state. Never a reason to write on its own: a store that - /// only ever sees structural edits has no flusher at all. unwritten: AtomicBool, - /// Whether the flusher thread has been started, so the first observation - /// starts it and the rest cost one atomic load. flushing: AtomicBool, } -/// When an operation's change has to be on disk. See the module header. #[derive(Clone, Copy, PartialEq, Eq, Debug)] enum Persist { - /// Before the deltas go out — every structural edit. Now, - /// Within [`FACT_FLUSH_INTERVAL`] — the machine's own observations. Soon, } -/// The error every invalid operation answers with. `InvalidInput` so the wire -/// layer maps it to a client-visible refusal rather than a server fault. fn refuse(msg: impl Into) -> io::Error { io::Error::new(io::ErrorKind::InvalidInput, msg.into()) } @@ -699,12 +419,6 @@ fn not_found(msg: impl Into) -> io::Error { } impl MachineStore { - /// Open the store at `path`, reading whatever is there. - /// - /// Infallible by design: a machine whose tree file is missing or - /// unreadable must still serve panes and files. A file that does not parse - /// is copied aside as `machine.json.corrupt` before anything overwrites - /// it, so "the tree came up empty" is recoverable by hand. pub fn open(path: impl Into) -> Arc { let path = path.into(); let machine = load_machine(&path); @@ -720,21 +434,10 @@ impl MachineStore { }) } - /// Install the pane server's answer to "is this pane alive right now", - /// consulted whenever a seed introduces a pane to the registry. - /// - /// A seed used to enter the registry `live: true` unconditionally — but a - /// pane that died between its spawn and its adopting operation had its - /// death observation dropped ([`MachineStore::note_pane_facts`] ignores - /// panes the tree does not hold), and nothing ever flipped the record back: - /// the leaf claimed a live pane forever and revival never offered. Asking - /// the process that owns the PTYs at registration time closes the window. pub fn set_liveness_probe(&self, probe: LivenessProbe) { *self.liveness.lock().unwrap_or_else(|e| e.into_inner()) = Some(probe); } - /// Whether a seeded pane is alive, per the installed probe. Without one - /// the seed is trusted (`true`): the seeding client just spawned it. fn seed_is_live(&self, pane: u64) -> bool { let probe = self .liveness @@ -747,24 +450,18 @@ impl MachineStore { } } - /// Open the store at its default location under the data directory. pub fn shared() -> io::Result> { Ok(MachineStore::open(default_machine_path()?)) } - /// Where this store is persisted. pub fn path(&self) -> &Path { &self.path } - // ----- reads ----------------------------------------------------------- - - /// A snapshot of the whole tree. What a full pull answers with. pub fn machine(&self) -> Machine { self.locked().clone() } - /// One workspace, whole. `NotFound` when there is no such workspace. pub fn workspace(&self, id: WorkspaceId) -> io::Result { self.locked() .workspaces @@ -774,23 +471,10 @@ impl MachineStore { .ok_or_else(|| not_found(format!("no workspace {id} on this machine"))) } - /// One pane's record. pub fn pane(&self, id: u64) -> Option { self.locked().panes.iter().find(|p| p.id == id).cloned() } - // ----- workspace operations -------------------------------------------- - - /// Create a workspace (empty — its first tab arrives as its own op). - /// - /// `id` lets the *client* mint the identity. A window exists before its - /// first round trip completes — the window registry, the view file and - /// every queued operation already name the workspace — so making the - /// daemon the minter would force every client to hold its ops until a - /// reply carried the "real" id back. Ids are uuids, so a client-minted one - /// is as unique as a daemon-minted one; a collision with an existing - /// workspace is refused rather than adopted, because "create" answering an - /// unrelated workspace's tree would hand one client another's tabs. pub fn workspace_create( &self, id: Option, @@ -827,7 +511,6 @@ impl MachineStore { Ok(created) } - /// Set (or clear) a workspace's user-chosen name. pub fn workspace_rename( &self, id: WorkspaceId, @@ -841,10 +524,6 @@ impl MachineStore { }) } - /// Forget a workspace and every pane record only it referenced. - /// - /// Answers the ids of the panes that went with it, so the caller can kill - /// their PTYs — the store never touches a process, only bookkeeping. pub fn workspace_delete( &self, id: WorkspaceId, @@ -863,11 +542,6 @@ impl MachineStore { }) } - /// Stamp a workspace as just-focused. - /// - /// An observation, not a structural edit ([`Persist::Soon`]): every window - /// focus change on every client lands one, and a picker's ordering is not - /// worth a `fsync` per keystroke-of-attention. pub fn workspace_touch( self: &Arc, id: WorkspaceId, @@ -885,7 +559,6 @@ impl MachineStore { }) } - /// Change which tab is active. pub fn workspace_set_active_tab( &self, id: WorkspaceId, @@ -902,16 +575,6 @@ impl MachineStore { }) } - // ----- tab operations -------------------------------------------------- - - /// Create a tab holding `pane`, at `at` (clamped; `None` appends), and make - /// it active — a created tab is one the user is about to type into. - /// - /// `id` is client-mintable for the same reason - /// [`workspace_create`](MachineStore::workspace_create)'s is: the client's - /// window holds the tab (and may already have queued operations against it) - /// before the reply lands, and a uuid minted there is as good as one minted - /// here. A duplicate is refused, never adopted. pub fn tab_create( &self, workspace: WorkspaceId, @@ -950,8 +613,6 @@ impl MachineStore { }) } - /// Close a tab, answering the pane ids that left the tree with it (for the - /// caller to kill — see [`MachineStore::workspace_delete`]). pub fn tab_close( &self, workspace: WorkspaceId, @@ -976,7 +637,6 @@ impl MachineStore { }) } - /// Set (or clear) a tab's user-chosen name. pub fn tab_rename( &self, workspace: WorkspaceId, @@ -991,7 +651,6 @@ impl MachineStore { }) } - /// Move a tab to position `to` (clamped). pub fn tab_move( &self, workspace: WorkspaceId, @@ -1013,7 +672,6 @@ impl MachineStore { }) } - /// Record which repo group a tab belongs to in the sidebar. pub fn tab_set_group( &self, workspace: WorkspaceId, @@ -1031,10 +689,6 @@ impl MachineStore { }) } - // ----- pane operations ------------------------------------------------- - - /// Split the leaf holding `pane`: the new pane takes the `first` (upper / - /// left) or second position, at `ratio`. pub fn pane_split( &self, workspace: WorkspaceId, @@ -1072,8 +726,6 @@ impl MachineStore { }) } - /// Remove the leaf holding `pane`. When it was the tab's last pane the tab - /// closes with it. Answers the pane ids that left the tree. pub fn pane_close( &self, workspace: WorkspaceId, @@ -1089,7 +741,6 @@ impl MachineStore { .ok_or_else(|| not_found(format!("workspace {workspace} has no pane {pane}")))?; let mut deltas = Vec::new(); match ws.tabs[index].root.remove_leaf(pane) { - // The tab was that one leaf: the tab goes. None => { let closed = ws.tabs.remove(index); deltas.push((workspace, LayoutDelta::TabClosed { tab: closed.id })); @@ -1112,7 +763,6 @@ impl MachineStore { }) } - /// Move a split's divider. `path` addresses the split from the tab root. pub fn pane_set_ratio( &self, workspace: WorkspaceId, @@ -1139,9 +789,6 @@ impl MachineStore { }) } - /// Move the leaf holding `pane` next to `to`, splitting it along `axis`. - /// The tmux `move-pane`: remove from where it is (collapsing that split), - /// then re-split at the destination. pub fn pane_move( &self, workspace: WorkspaceId, @@ -1170,8 +817,6 @@ impl MachineStore { let mut deltas: Vec<(WorkspaceId, LayoutDelta)> = Vec::new(); match ws.tabs[from].root.remove_leaf(pane) { None => { - // The pane was a whole tab; that tab dissolves into the - // destination. if from == dest { return Err(refuse("a pane cannot be moved next to itself".to_string())); } @@ -1192,7 +837,6 @@ impl MachineStore { } Some(false) => unreachable!("the tab was chosen because it contains the pane"), } - // Indices may have shifted if a tab was removed above. let dest_tab = ws .tabs .iter_mut() @@ -1210,8 +854,6 @@ impl MachineStore { }) } - /// Rebind the leaf holding `old` to a freshly-spawned successor — the - /// revival op. The old record leaves the registry with its facts spent. pub fn pane_replace( &self, workspace: WorkspaceId, @@ -1244,18 +886,6 @@ impl MachineStore { }) } - // ----- pane facts (the daemon's own observations) ---------------------- - - /// Record facts the daemon observed about `pane` — OSC 7 cwd, agent hook - /// events, liveness. Unknown panes are ignored (a pane - /// the tree never adopted is not the tree's business). The delta is - /// attributed to no origin: facts come from the machine, so *every* - /// client hears them. - /// - /// Called from the pane reader threads, once per prompt per pane, so the - /// write is deferred ([`Persist::Soon`]) while the delta is not: what a - /// client renders stays current, and the disk catches up on the flusher's - /// tick. pub fn note_pane_facts(self: &Arc, pane: u64, update: impl FnOnce(&mut PaneRecord)) { self.ensure_flusher(); let result: io::Result<()> = self.mutate_with(None, Persist::Soon, |m| { @@ -1295,18 +925,12 @@ impl MachineStore { } } - // ----- attachment (runtime; never persisted) ---------------------------- - - /// Record `who` as the workspace's current session and answer whoever held - /// it before — the data half of the takeover, unchanged in meaning from - /// the old store's. pub fn attach(&self, workspace: WorkspaceId, who: Attachment) -> Option { let mut m = self.locked(); let ws = m.workspaces.iter_mut().find(|w| w.id == workspace)?; ws.attachment.replace(who) } - /// Who is attached to `workspace`, if anyone. pub fn attachment(&self, workspace: WorkspaceId) -> Option { self.locked() .workspaces @@ -1315,8 +939,6 @@ impl MachineStore { .and_then(|w| w.attachment.clone()) } - /// Release `workspace`, but **only if `token` still holds it** — the guard - /// that keeps a preempted client's teardown from evicting its usurper. pub fn detach(&self, workspace: WorkspaceId, token: &str) -> bool { let mut m = self.locked(); let Some(ws) = m.workspaces.iter_mut().find(|w| w.id == workspace) else { @@ -1330,10 +952,6 @@ impl MachineStore { } } - // ----- change notification --------------------------------------------- - - /// Be told about every delta. Dropping the [`Subscription`] unsubscribes. - /// The callback runs on the writer's thread: enqueue and return. pub fn subscribe(self: &Arc, f: Notify) -> Subscription { let id = SubscriberId(self.next_subscriber.fetch_add(1, Ordering::Relaxed)); self.subscribers @@ -1353,20 +971,10 @@ impl MachineStore { .retain(|(sid, _)| *sid != id); } - // ----- internals ------------------------------------------------------- - fn locked(&self) -> std::sync::MutexGuard<'_, Machine> { - // A poisoned lock means a panic mid-mutation. Every *fallible* path - // rolls back before releasing the lock (see `mutate`); the only - // panics inside an op are `unreachable!`/`expect`s on invariants the - // same op just established, so a poisoned tree is still the pre- or - // post-images of some operation. Carrying on beats taking the daemon - // — and every pane on the machine — down with a bookkeeping panic. self.state.lock().unwrap_or_else(|e| e.into_inner()) } - /// [`mutate_with`](Self::mutate_with) at [`Persist::Now`] — every - /// structural operation. fn mutate( &self, origin: Option, @@ -1375,19 +983,6 @@ impl MachineStore { self.mutate_with(origin, Persist::Now, op) } - /// Run one operation: mutate under the lock, persist, and — only if the - /// disk said yes — deliver the deltas outside the state lock. - /// - /// A failed persist rolls the tree back to the pre-mutation clone, so the - /// in-memory state never claims something the file does not, and a change - /// nobody can re-read is a change nobody is told about. At - /// [`Persist::Soon`] there is no disk to fail: the change is flagged - /// unwritten and the flusher carries it, which is sound only because what - /// takes that path is the machine re-observable rather than the layout — - /// see the module header. - /// - /// `notify_order` is held across the whole thing — see the field — so - /// subscribers receive deltas in exactly the order the mutations landed. fn mutate_with( &self, origin: Option, @@ -1404,9 +999,6 @@ impl MachineStore { if *m != before { match persist { Persist::Now => self.persist(&m)?, - // Ordered with every other write by `notify_order`, - // which the flusher takes too: the file still moves - // through the states the tree moved through. Persist::Soon => self.unwritten.store(true, Ordering::Release), } } @@ -1428,20 +1020,12 @@ impl MachineStore { Ok(value) } - /// Write out anything a [`Persist::Soon`] mutation left in memory. A no-op - /// when there is nothing owed, so it is cheap to call on a timer. - /// - /// Public for the daemon's shutdown path: the observations of the last two - /// seconds are worth one write on the way out. pub fn flush(&self) { if !self.unwritten.load(Ordering::Acquire) { return; } let _order = self.notify_order.lock().unwrap_or_else(|e| e.into_inner()); let m = self.locked(); - // Cleared before the write, not after: a fact landing *during* it is - // owed another write, and losing that flag would strand it until the - // next one. `persist` failing sets it again below. self.unwritten.store(false, Ordering::Release); if let Err(e) = self.persist(&m) { log::warn!("could not write {}: {e}", self.path.display()); @@ -1449,12 +1033,6 @@ impl MachineStore { } } - /// Start the flusher, once, on the first observation that owes a write. - /// - /// Weak, so the thread is the store's dependent rather than its owner: a - /// dropped store (every test that makes one) ends the thread at its next - /// tick instead of keeping the file — and the file's handle — alive for the - /// process's life. fn ensure_flusher(self: &Arc) { if self.flushing.swap(true, Ordering::AcqRel) { return; @@ -1470,24 +1048,12 @@ impl MachineStore { } }); if let Err(e) = spawned { - // Fall back to writing observations synchronously: the flag says - // one is owed, and clearing `flushing` lets the next one retry the - // spawn. Slow beats silently losing every cwd on the machine. log::warn!("could not start the machine-tree flusher ({e}); writing facts inline"); self.flushing.store(false, Ordering::Release); self.flush(); } } - /// Serialize the whole document and replace the file atomically. The - /// pretty form, so a human can read and repair it — this file is the - /// machine's memory of every workspace on it. - /// - /// Owner-only: the document names every workspace's directories, the SSH - /// user and host of every native-SSH pane, and each agent's session id. A - /// remote box running `tty7-server` is exactly where other logins are - /// likeliest, so the file must not be created world-readable and fixed up - /// afterwards — see [`write_atomic_private`](crate::core::config::write_atomic_private). fn persist(&self, m: &Machine) -> io::Result<()> { let bytes = serde_json::to_vec_pretty(m).map_err(io::Error::other)?; if let Some(parent) = self.path.parent() { @@ -1496,8 +1062,6 @@ impl MachineStore { crate::core::config::write_atomic_private(&self.path, &bytes) } - /// Fan the deltas out, skipping the subscriber that caused them. Called - /// with no lock held. fn notify_all(&self, deltas: &[(WorkspaceId, LayoutDelta)], origin: Option) { let subscribers: Vec<(SubscriberId, Notify)> = self .subscribers @@ -1515,7 +1079,6 @@ impl MachineStore { } } -/// Find a workspace or answer the `NotFound` every op shares. fn find_workspace(m: &mut Machine, id: WorkspaceId) -> io::Result<&mut Workspace> { m.workspaces .iter_mut() @@ -1531,14 +1094,6 @@ fn find_tab(m: &mut Machine, workspace: WorkspaceId, tab: TabId) -> io::Result<& .ok_or_else(|| not_found(format!("workspace {workspace} has no tab {tab}"))) } -/// Keep `active_tab` naming a real tab after the tab at `removed` left. -/// -/// The replacement is the neighbour that slid into the removed tab's place -/// (or the new last tab), which is what every tab strip does on close. -/// -/// Answers the tab that became active when the heal actually re-pointed it, -/// so the caller can broadcast the change — a client mirroring by deltas must -/// not have to re-implement this rule (see [`LayoutDelta::ActiveTabChanged`]). fn heal_active_tab(ws: &mut Workspace, removed: usize) -> Option { let named = ws .active_tab @@ -1552,17 +1107,6 @@ fn heal_active_tab(ws: &mut Workspace, removed: usize) -> Option { Some(active) } -/// Adopt a seed into the registry. -/// -/// A pane already shown anywhere in the tree is **refused**: one pane has one -/// stream and one subscriber, so a second leaf on the same id would be two -/// windows silently fighting over one PTY — the exact corruption the old -/// client-side `dedupe_pane_ids` pass existed to mop up after the fact. The -/// daemon owning the tree means it can simply not happen. -/// -/// Every registry record is referenced by some leaf (the close paths collect -/// orphans), so "known pane, not in any tree" cannot arise and needs no merge -/// path. fn register_pane(m: &mut Machine, seed: PaneSeed, live: bool) -> io::Result<()> { let shown = m .workspaces @@ -1583,9 +1127,6 @@ fn register_pane(m: &mut Machine, seed: PaneSeed, live: bool) -> io::Result<()> Ok(()) } -/// The pane ids no leaf references any more. Computed over the whole machine -/// because a pane id means one pane — it must not be forgotten while any -/// workspace still shows it. fn collect_orphan_panes(m: &Machine) -> Vec { m.panes .iter() @@ -1605,19 +1146,11 @@ fn clamp_ratio(ratio: f32) -> io::Result { Ok(ratio.clamp(0.05, 0.95)) } -/// Read the file, or start empty. A file that cannot be honoured — whether it -/// fails to parse or to *read* — is quarantined first, so the user's tree is -/// recoverable by hand rather than silently overwritten: either way the store -/// proceeds empty, and its first mutation writes the file anew. fn load_machine(path: &Path) -> Machine { let text = match std::fs::read_to_string(path) { Ok(t) => t, Err(e) if e.kind() == io::ErrorKind::NotFound => return Machine::default(), Err(e) => { - // Same isolation as the parse failure below, by rename rather - // than copy: a copy re-reads the very file that just refused to - // be read, while a rename needs only the directory — which the - // store can evidently write, since it is about to persist there. log::warn!("could not read {}; quarantining it: {e}", path.display()); quarantine_by_rename(path); return Machine::default(); @@ -1625,10 +1158,6 @@ fn load_machine(path: &Path) -> Machine { }; match serde_json::from_str::(crate::core::config::strip_bom(&text)) { Ok(mut machine) => { - // PTYs die with the daemon process, so whatever the file says, - // nothing is live in a store that was just opened. This line is - // the whole of the restart semantic: every leaf is now "awaiting - // revival" simply because its pane's record says so. for pane in &mut machine.panes { pane.live = false; } @@ -1642,34 +1171,12 @@ fn load_machine(path: &Path) -> Machine { } } -// --------------------------------------------------------------------------- -// The daemon's own observations -// --------------------------------------------------------------------------- - -/// The store the running daemon's pane server publishes its observations into. -/// -/// A process-wide slot rather than a parameter threaded through `DaemonPane`, -/// for the same reason the control dialect's event observer is one: the -/// observers (every pane's reader thread) and the owner (the control listener -/// the daemon starts) come up independently in code that long predates the -/// tree, and each of the three pane-spawn paths would otherwise have to be -/// taught to carry an `Option>` it never reads. Last install -/// wins; `None` — a process serving panes with no tree, or a unit test — -/// simply drops observations. static OBSERVED: Mutex>> = Mutex::new(None); -/// Install `store` as where [`observe_pane`] lands. The daemon calls this once -/// while wiring its control services. pub fn publish_observations(store: &Arc) { *OBSERVED.lock().unwrap_or_else(|e| e.into_inner()) = Some(Arc::clone(store)); } -/// Record an observation about `pane` — a cwd the shell reported, an agent the -/// sniffer identified, a death — in the installed store, if there is one. -/// -/// Facts about panes the tree never adopted are dropped by the store itself -/// (see [`MachineStore::note_pane_facts`]), so callers report unconditionally -/// and pay nothing for a pane that is nobody's business. pub fn observe_pane(pane: u64, f: impl FnOnce(&mut PaneRecord)) { let store = OBSERVED.lock().unwrap_or_else(|e| e.into_inner()).clone(); if let Some(store) = store { @@ -1677,27 +1184,18 @@ pub fn observe_pane(pane: u64, f: impl FnOnce(&mut PaneRecord)) { } } -/// The installed observation store, if any — for daemon-side code (the orphan -/// sweep) that wants to *read* the tree the pane server publishes into. pub fn observed_store() -> Option> { OBSERVED.lock().unwrap_or_else(|e| e.into_inner()).clone() } -/// Test-only: clear the slot again, so one test's store cannot swallow the -/// observations of unrelated tests running later in the same binary. #[cfg(test)] pub(crate) fn withdraw_observations() { *OBSERVED.lock().unwrap_or_else(|e| e.into_inner()) = None; } -/// Test-only: [`OBSERVED`] is one slot for the whole process, so a test that -/// installs a store must hold this for as long as it needs its observations to -/// land there — otherwise a test elsewhere in the binary withdraws the store -/// mid-run and the observation is silently dropped. #[cfg(test)] pub(crate) static OBSERVE_SLOT: Mutex<()> = Mutex::new(()); -/// Copy a file we are about to stop honouring somewhere the user can find it. fn quarantine(path: &Path) { let aside = quarantine_path(path); match std::fs::copy(path, &aside) { @@ -1706,8 +1204,6 @@ fn quarantine(path: &Path) { } } -/// [`quarantine`] for a file that cannot be read: move it aside whole instead -/// of copying (a copy needs the read permission that just failed). fn quarantine_by_rename(path: &Path) { let aside = quarantine_path(path); match std::fs::rename(path, &aside) { @@ -1716,15 +1212,7 @@ fn quarantine_by_rename(path: &Path) { } } -/// Where a file we are about to stop honouring is kept. -/// -/// `machine.json.corrupt` when that name is free, `…corrupt.1`, `…corrupt.2` … -/// when it is not: the second corruption in a machine's life must not overwrite -/// the rescue copy of the first, which is the one with the user's tree in it. -/// After [`MAX_QUARANTINED`] the oldest name is reused — an unbounded fan of -/// files nobody reads is its own kind of mess. fn quarantine_path(path: &Path) -> PathBuf { - /// How many quarantined generations to keep before reusing the base name. const MAX_QUARANTINED: u32 = 8; let base = path.with_extension("json.corrupt"); @@ -1737,18 +1225,6 @@ fn quarantine_path(path: &Path) -> PathBuf { .unwrap_or(base) } -/// `/machine.json`. -/// -/// | Order | Directory | Why | -/// |---|---|---| -/// | 1 | `$TTY7_DATA_DIR` | Explicit wins; how tests and a second server get their own file | -/// | 2 | `$XDG_DATA_HOME/tty7` | The location the design names, spelled the way XDG spells it | -/// | 3 | `$HOME/.local/share/tty7` | No `XDG_DATA_HOME` — the literal fallback path | -/// -/// Deliberately **not** under the config dir. `views.json` there is the -/// *client's* view state, and a box that is both someone's laptop and someone -/// else's remote must keep the two files apart or one role would overwrite the -/// other's idea of which workspaces exist. pub fn default_machine_path() -> io::Result { Ok(data_dir()?.join(MACHINE_FILE)) } @@ -1802,7 +1278,6 @@ mod tests { } } - /// A store, one workspace, one tab on pane 1. fn store_with_tab() -> (Arc, tempfile::TempDir, WorkspaceId, Tab) { let (store, dir) = store(); let ws = store @@ -1814,7 +1289,6 @@ mod tests { (store, dir, ws.id, tab) } - /// Record every delta a subscriber hears, as `(workspace-key, delta)`. fn recorded( store: &Arc, ) -> (Subscription, Arc>>) { @@ -1826,8 +1300,6 @@ mod tests { (sub, heard) } - // ── Client-minted identities ─────────────────────────────────────────── - #[test] fn a_client_minted_workspace_id_is_kept_and_a_duplicate_is_refused() { let (store, _dir) = store(); @@ -1857,8 +1329,6 @@ mod tests { .unwrap(); assert_eq!(tab.id, id); - // Refused even from another workspace: tab ids are one namespace, so a - // delta about a tab can never be ambiguous about which tab it means. let other = store.workspace_create(None, None, None).unwrap(); let refused = store .tab_create(other.id, None, seed(3, "/c"), Some(id), None) @@ -1870,8 +1340,6 @@ mod tests { ); } - // ── The tree survives the file ───────────────────────────────────────── - #[test] fn the_tree_round_trips_through_the_file() { let (store, dir) = store(); @@ -1916,12 +1384,6 @@ mod tests { assert_eq!(machine.panes[0].cwd.as_deref(), Some("/work")); } - /// **The revival contract.** After a restart every pane the tree names is - /// dead — PTYs die with the process — and the tree must say so on its own, - /// with no client-side instance stamp to consult. The leaf stays (the - /// layout is the thing being revived), the record keeps the facts a - /// successor is started from, and `live` is false because it cannot be - /// anything else in a process that spawned nothing yet. #[test] fn a_reopened_store_marks_every_pane_awaiting_revival() { let (store, dir) = store(); @@ -1949,13 +1411,6 @@ mod tests { ); } - /// The daemon's registration-time liveness check. A pane that dies - /// between its spawn and its adopting operation has its death observation - /// dropped (`note_pane_facts` ignores panes the tree does not hold), so a - /// seed filed `live: true` unconditionally would claim a live pane for - /// ever — no revival offered, nothing left to flip the flag. With the - /// probe installed, the process that owns the PTYs answers at the moment - /// the record is born. #[test] fn a_seed_for_an_already_dead_pane_registers_as_awaiting_revival() { let (store, _dir) = store(); @@ -1983,8 +1438,6 @@ mod tests { ); } - /// The revival itself: a fresh pane takes the leaf over, the spent record - /// leaves the registry, and everyone else hears the whole tab. #[test] fn replacing_a_dead_pane_rebinds_the_leaf_and_spends_the_record() { let (store, dir) = store(); @@ -2016,8 +1469,6 @@ mod tests { } } - // ── Workspace ops ────────────────────────────────────────────────────── - #[test] fn workspace_create_rename_touch_delete_land_and_broadcast() { let (store, _dir) = store(); @@ -2055,8 +1506,6 @@ mod tests { assert!(store.pane(1).is_none()); } - // ── Tab ops ──────────────────────────────────────────────────────────── - #[test] fn a_created_tab_lands_at_its_position_and_becomes_active() { let (store, _dir, ws, first) = store_with_tab(); @@ -2072,8 +1521,6 @@ mod tests { assert_eq!(order, vec![first.id, between.id, second.id]); assert_eq!(workspace.active_tab, Some(between.id)); - // An out-of-range position clamps rather than refusing: the client's - // idea of "after the last tab" can be stale by one concurrent close. let clamped = store .tab_create(ws, Some(99), seed(4, "/d"), None, None) .unwrap(); @@ -2101,8 +1548,6 @@ mod tests { Some(first.id), "the active tab may not dangle on a closed id" ); - // The heal is broadcast, not left for clients to re-derive: after the - // `TabClosed` comes an `ActiveTabChanged` naming the survivor. assert!( matches!( heard.lock().unwrap().as_slice(), @@ -2154,8 +1599,6 @@ mod tests { ); } - // ── Pane ops ─────────────────────────────────────────────────────────── - #[test] fn splitting_and_closing_panes_reshapes_the_tree() { let (store, _dir, ws, tab) = store_with_tab(); @@ -2171,7 +1614,6 @@ mod tests { "`first` puts the new pane on the a side" ); - // Closing a middle pane collapses its split; the sibling takes over. let dropped = store.pane_close(ws, 3, None).unwrap(); assert_eq!(dropped, vec![3]); assert_eq!( @@ -2179,14 +1621,12 @@ mod tests { vec![1, 2] ); - // Closing down to one pane leaves a plain leaf, not a degenerate split. store.pane_close(ws, 2, None).unwrap(); assert!(matches!( store.workspace(ws).unwrap().tabs[0].root, PaneNode::Leaf { pane: 1 } )); - // Closing the last pane closes the tab itself. let (_sub, heard) = recorded(&store); store.pane_close(ws, 1, None).unwrap(); assert!(store.workspace(ws).unwrap().tabs.is_empty()); @@ -2206,7 +1646,6 @@ mod tests { .pane_split(ws, 2, Axis::Vertical, 0.5, seed(3, "/c"), false, None) .unwrap(); - // The nested split lives on the b side of the root. store .pane_set_ratio(ws, tab.id, vec![Side::B], 0.7, None) .unwrap(); @@ -2222,14 +1661,11 @@ mod tests { PaneNode::Leaf { .. } => panic!("the root split is gone"), } - // A path that no longer names a split refuses rather than guessing — - // the client falls back to a full re-pull. let err = store .pane_set_ratio(ws, tab.id, vec![Side::A], 0.6, None) .unwrap_err(); assert_eq!(err.kind(), io::ErrorKind::InvalidInput); - // Ratios clamp to sane bounds instead of letting a pane vanish. store .pane_set_ratio(ws, tab.id, vec![], 0.0001, None) .unwrap(); @@ -2259,18 +1695,12 @@ mod tests { ); assert!(store.pane(2).is_some(), "the pane moved; it did not die"); - // Moving a pane next to itself is meaningless and refused. let err = store .pane_move(ws, 2, 2, Axis::Vertical, false, None) .unwrap_err(); assert_eq!(err.kind(), io::ErrorKind::InvalidInput); } - // ── Validation is refusal, not corruption ────────────────────────────── - - /// A refused operation leaves the tree byte-for-byte what it was and - /// tells nobody anything — a delta for a change that did not happen would - /// desynchronize every listening client at once. #[test] fn a_refused_operation_changes_nothing_and_notifies_nobody() { let (store, _dir, ws, tab) = store_with_tab(); @@ -2311,11 +1741,6 @@ mod tests { ); } - // ── Origin exclusion ─────────────────────────────────────────────────── - - /// The writer does not hear its own echo; everyone else does. This is the - /// mechanism that lets a client apply its own edit optimistically and - /// apply everyone else's from deltas without double-applying its own. #[test] fn a_delta_reaches_every_subscriber_but_its_author() { let (store, _dir, ws, _tab) = store_with_tab(); @@ -2328,7 +1753,6 @@ mod tests { assert!(heard_by_author.lock().unwrap().is_empty()); assert_eq!(heard_by_other.lock().unwrap().len(), 1); - // A write with no origin reaches all. store.workspace_rename(ws, None, None).unwrap(); assert_eq!(heard_by_author.lock().unwrap().len(), 1); assert_eq!(heard_by_other.lock().unwrap().len(), 2); @@ -2345,11 +1769,6 @@ mod tests { assert_eq!(heard.lock().unwrap().len(), 1); } - // ── Pane facts ───────────────────────────────────────────────────────── - - /// The daemon's own observations reach every client of every workspace - /// showing the pane — origin exclusion does not apply, because the machine - /// is the author and the machine is nobody's echo. #[test] fn pane_facts_update_the_record_and_reach_every_client() { let (store, _dir, ws, _tab) = store_with_tab(); @@ -2367,16 +1786,12 @@ mod tests { assert!(matches!(&heard[0].1, LayoutDelta::PaneFacts { pane } if pane.id == 1)); } - // No change, no noise; an unknown pane is nobody's business. store.note_pane_facts(1, |_| {}); store.note_pane_facts(999, |p| p.cwd = Some("/ghost".into())); assert_eq!(heard.lock().unwrap().len(), 1); drop(sub); } - /// One pane, one leaf. A second adoption of a pane already shown is the - /// two-windows-one-PTY corruption the old client-side dedupe pass mopped - /// up after the fact; the daemon owning the tree refuses it up front. #[test] fn a_pane_already_in_the_tree_cannot_be_adopted_again() { let (store, _dir, ws, _tab) = store_with_tab(); @@ -2399,10 +1814,6 @@ mod tests { let _ = ws; } - /// The pane server's side door: once a store is installed, an observation - /// lands on the record like any other fact — and before/without one, - /// observing is a quiet no-op, which is what lets the pane code report - /// unconditionally. #[test] fn published_observations_land_in_the_installed_store() { let _slot = OBSERVE_SLOT.lock().unwrap_or_else(|e| e.into_inner()); @@ -2418,8 +1829,6 @@ mod tests { withdraw_observations(); } - // ── Attachment ───────────────────────────────────────────────────────── - #[test] fn attachments_takeover_and_are_never_persisted() { let (store, dir, ws, _tab) = store_with_tab(); @@ -2430,17 +1839,12 @@ mod tests { let desktop = Attachment::new("tok-2", "desktop"); assert_eq!(store.attach(ws, desktop.clone()), Some(laptop.clone())); - // The preempted client tidying up must not evict the new owner. assert!(!store.detach(ws, &laptop.token)); assert_eq!(store.attachment(ws).unwrap().hostname, "desktop"); assert!(store.detach(ws, &desktop.token)); assert_eq!(store.attachment(ws), None); - // Attachments describe live connections; a restarted daemon has none. store.attach(ws, Attachment::new("secret-token", "laptop")); - // A structural op that really changes something, to force the write: - // `workspace_touch` is an observation (deferred to the flusher), and an - // op that changes nothing does not write at all. store .workspace_rename(ws, Some("web".into()), None) .unwrap(); @@ -2452,10 +1856,6 @@ mod tests { ); } - /// An attachment is a field of its workspace, so deleting the workspace - /// takes it along — there is no table it could go stale in. The retired - /// record store kept a separate attachment list and had to clear it by - /// hand; this pins the structural guarantee that replaced that code. #[test] fn an_attachment_dies_with_its_workspace() { let (store, _dir, ws, _tab) = store_with_tab(); @@ -2465,8 +1865,6 @@ mod tests { assert_eq!(store.attachment(ws), None); } - /// The default path ends at the documented file under the data directory — - /// the resolution the retired record store defined and the tree inherited. #[test] fn the_default_path_ends_at_the_documented_file() { match default_machine_path() { @@ -2474,20 +1872,10 @@ mod tests { path.file_name().and_then(|n| n.to_str()), Some(MACHINE_FILE) ), - // No home at all (a bare CI container): the error names the - // escape hatch rather than being a mystery. Err(e) => assert!(e.to_string().contains(DATA_DIR_ENV)), } } - // ── Durability ───────────────────────────────────────────────────────── - - /// An observation reaches every client at once but does **not** write the - /// file: these arrive per prompt per pane from the PTY reader threads, and - /// a whole-document `fsync` each would put a disk stall in the pane's own - /// output path and serialize every other client's edit behind it. The - /// flusher (or the next structural edit, or an explicit `flush`) carries - /// it to disk. #[test] fn an_observation_is_broadcast_at_once_and_written_a_little_later() { let (store, dir, ws, _tab) = store_with_tab(); @@ -2510,16 +1898,10 @@ mod tests { std::fs::read_to_string(&path).unwrap().contains("deeper"), "…and the flush is what puts it on disk" ); - // Nothing owed, nothing written: the flusher's tick is free on an idle - // machine. let before = std::fs::metadata(&path).unwrap().len(); store.flush(); assert_eq!(std::fs::metadata(&path).unwrap().len(), before); - // A structural edit persists the whole document, deferred facts and - // all — so an observation can never outlive the layout change after it. - // (Renamed to something it is not already called: an operation that - // changes nothing writes nothing, which every path here goes through.) store.note_pane_facts(1, |p| p.cwd = Some("/work/deepest".into())); store .workspace_rename(ws, Some("web".into()), None) @@ -2530,9 +1912,6 @@ mod tests { ); } - /// The layout itself is never deferred: a structural edit is on disk before - /// its delta goes out, so a client can never be told about a change a - /// restart would lose. #[test] fn a_structural_edit_is_on_disk_before_anyone_hears_about_it() { let (store, dir) = store(); @@ -2541,8 +1920,6 @@ mod tests { let sink = Arc::clone(&seen); let path_in_callback = path.clone(); let _sub = store.subscribe(Arc::new(move |_ws: &str, _delta: &LayoutDelta| { - // Read from *inside* the delivery: the file has to already say - // what this delta is about. sink.lock() .unwrap() .push(std::fs::read_to_string(&path_in_callback).unwrap_or_default()); @@ -2561,8 +1938,6 @@ mod tests { ); } - // ── Corruption ───────────────────────────────────────────────────────── - #[test] fn a_corrupt_file_is_quarantined_rather_than_overwritten() { let dir = tempfile::TempDir::new().unwrap(); @@ -2575,8 +1950,6 @@ mod tests { let aside = std::fs::read_to_string(path.with_extension("json.corrupt")).unwrap(); assert_eq!(aside, "{ this is not json"); - // A second corruption gets its own name. Overwriting would spend the - // rescue copy that has the user's tree in it on one that has garbage. std::fs::write(&path, b"corrupt again").unwrap(); let store = MachineStore::open(&path); store.workspace_create(None, None, None).unwrap(); @@ -2591,10 +1964,6 @@ mod tests { ); } - /// The document names directories, SSH users and hosts, and agent session - /// ids. On a shared box — which a `tty7-server` machine is likeliest to be - /// — that is nobody else's business, and it must be private from the first - /// instant the file exists rather than chmod-ed on the next line. #[cfg(unix)] #[test] fn the_document_is_written_owner_only() { @@ -2609,11 +1978,6 @@ mod tests { assert_eq!(mode & 0o777, 0o600, "mode was {:o}", mode & 0o777); } - /// An *unreadable* file gets the same isolation as an unparseable one. - /// Before this, only the parse path quarantined: a read failure logged, - /// started empty — and the first mutation then overwrote the very file - /// that could not be read. Quarantine here is by rename (a copy would - /// need the read permission that just failed), so the bytes survive. #[cfg(unix)] #[test] fn an_unreadable_file_is_moved_aside_rather_than_overwritten() { @@ -2624,8 +1988,6 @@ mod tests { std::fs::write(&path, b"{\"workspaces\":[]}").unwrap(); std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000)).unwrap(); if std::fs::read_to_string(&path).is_ok() { - // Running as root (some CI containers): the permission bits do - // not bite and the scenario cannot be staged. return; } @@ -2642,9 +2004,6 @@ mod tests { ); } - /// Fields this build has never heard of survive nothing — but fields it - /// *lacks* must not fail the parse: the schema is `#[serde(default)]` - /// throughout so the daemon can keep evolving it. #[test] fn a_sparse_document_decodes_with_defaults() { let machine: Machine = diff --git a/crates/tty7-core/src/core/mod.rs b/crates/tty7-core/src/core/mod.rs index 701e200b..1f0d2dee 100644 --- a/crates/tty7-core/src/core/mod.rs +++ b/crates/tty7-core/src/core/mod.rs @@ -1,28 +1,13 @@ -//! Domain core: the configuration model, session persistence, the streaming -//! OSC tokenizer shared by the daemon- and client-side output scanners, and the -//! shell / agent / git knowledge the daemon and the GUI have to share. -//! -//! These modules are framework-light and depend on neither `ui` nor `terminal` -//! — the dependency arrow always points *inward* to here. That is what let them -//! lift out of the GUI binary into this crate without untangling view code. -//! -//! The GUI crate re-exports this module as `crate::core`, adding its own -//! gpui-facing modules (`actions`, `update`, …) and thin gpui layers over -//! `config`, `session` and `window_state`, so call sites there are unchanged. - pub mod agent_hooks; pub mod cli_agent; pub mod config; pub mod crash; pub mod git; pub mod gitignore; -pub mod logfile; -pub mod machine; -// SSH connection-manager data layer (WS1). Its public API is consumed by the -// daemon-session, auth, forwarding, and UI workstreams, which land separately — -// so parts of it read as dead code until those merge. #[allow(dead_code)] pub mod keychain; +pub mod logfile; +pub mod machine; pub mod osc; pub mod proc; pub mod session; diff --git a/crates/tty7-core/src/core/osc.rs b/crates/tty7-core/src/core/osc.rs index 871fd477..f6431cd2 100644 --- a/crates/tty7-core/src/core/osc.rs +++ b/crates/tty7-core/src/core/osc.rs @@ -1,56 +1,19 @@ -//! Streaming OSC (Operating System Command) extractor. -//! -//! The one implementation of OSC wire framing, shared by both byte-stream -//! consumers: the daemon-side cwd/prompt sniffer (`daemon::pane`, OSC 7/133) -//! and the client-side notification scanner (`terminal::remote`, OSC 9/777). -//! The framing rules — `ESC ]` opens, `BEL` or `ESC \` (ST) terminates, a bare -//! `ESC ]` inside an unterminated sequence re-opens a fresh one, oversized -//! payloads are abandoned — are subtle enough that both sites needed the same -//! resync bugfix when each carried its own copy. Keeping the state machine -//! here means a framing change can't silently apply to one consumer and not -//! the other. -//! -//! This is deliberately *not* a full VT parser (the grid has an -//! `ansi::Processor` for that). It tracks just enough state to hand complete -//! payloads of the OSC identifiers a consumer cares about to its callback, -//! bailing out cheaply on any other OSC (e.g. a multi-megabyte OSC 52 -//! clipboard write) without buffering it. - -/// Cap on how many bytes of a single OSC payload we'll buffer before giving up -/// on it — a guard against an unterminated or absurdly long sequence growing -/// the buffer without bound. Real cwd/prompt/notification payloads are far -/// shorter. const MAX_PAYLOAD: usize = 8192; -/// A streaming tokenizer for the OSC sequences whose identifiers are listed in -/// `ids`. Feed it raw output bytes; it invokes a callback with each complete -/// payload. State persists across `feed` calls, so a sequence split over -/// multiple reads is still recognized. pub struct OscTokenizer { - /// OSC identifiers (the digits before the first `;`) the consumer wants - /// buffered and delivered; every other OSC is discarded unbuffered. ids: &'static [&'static [u8]], - /// Payload bytes accumulated after `ESC ]` while the identifier can still - /// match `ids`. Cleared whenever a sequence finishes or is abandoned. buf: Vec, state: State, } #[derive(Default, Clone, Copy)] enum State { - /// Not inside an escape sequence. #[default] Ground, - /// Saw `ESC` in ground state; a following `]` opens an OSC. Esc, - /// Inside an OSC whose identifier still matches (a prefix of) `ids`; - /// buffering the payload. Osc, - /// Saw `ESC` while buffering an OSC — a following `\` is the `ST` terminator. OscEsc, - /// Inside an OSC we've decided to ignore; discard bytes until the terminator. Ignore, - /// Saw `ESC` while ignoring an OSC — a following `\` is the `ST` terminator. IgnoreEsc, } @@ -63,22 +26,11 @@ impl OscTokenizer { } } - /// Feed one chunk of output; invoke `on_payload` with the complete payload - /// (identifier included, terminator excluded — e.g. `7;file://…`) of every - /// interesting OSC that completes within the chunk. - /// - /// The tokenizer sits on the full-throughput output stream (both the - /// daemon's PTY reader and the client's socket reader run it over every - /// byte), so the two states that dominate real streams — `Ground` between - /// sequences, `Ignore` inside a discarded OSC (e.g. a multi-MB OSC 52) — - /// skip ahead with SIMD `memchr` instead of stepping per byte. Everything - /// else is rare enough to stay a plain per-byte state machine. pub fn feed(&mut self, bytes: &[u8], mut on_payload: impl FnMut(&[u8])) { let mut i = 0; while i < bytes.len() { match self.state { State::Ground => { - // Nothing before the next ESC can matter. let Some(off) = memchr::memchr(0x1b, &bytes[i..]) else { return; }; @@ -87,8 +39,6 @@ impl OscTokenizer { continue; } State::Ignore => { - // Only BEL (terminates) or ESC (may terminate or fork) can - // end a discarded payload. let Some(off) = memchr::memchr2(0x07, 0x1b, &bytes[i..]) else { return; }; @@ -104,23 +54,20 @@ impl OscTokenizer { } let b = bytes[i]; match self.state { - // Handled by the skip-ahead arms above. State::Ground | State::Ignore => unreachable!(), State::Esc => match b { b']' => { self.buf.clear(); self.state = State::Osc; } - 0x1b => {} // a run of ESCs; keep waiting for the next byte + 0x1b => {} _ => self.state = State::Ground, }, State::Osc => match b { - 0x07 => self.finish(&mut on_payload), // BEL terminator + 0x07 => self.finish(&mut on_payload), 0x1b => self.state = State::OscEsc, _ => { self.buf.push(b); - // Abandon as soon as the identifier can't be one of - // `ids`, or the payload grows unreasonably large. if self.buf.len() > MAX_PAYLOAD || !self.identifier_could_match() { self.buf.clear(); self.state = State::Ignore; @@ -128,28 +75,20 @@ impl OscTokenizer { } }, State::OscEsc => match b { - b'\\' => self.finish(&mut on_payload), // ST terminator - 0x1b => {} // another ESC: stay poised for the `\` - // The ESC began a *new* OSC, aborting this unterminated one. - // Re-open a fresh OSC instead of dropping the `]` into - // Ground — otherwise a well-formed sequence directly - // following an unterminated one would be silently lost. + b'\\' => self.finish(&mut on_payload), + 0x1b => {} b']' => { self.buf.clear(); self.state = State::Osc; } _ => { - // ESC began some other (non-OSC) escape: abandon this OSC. self.buf.clear(); self.state = State::Ground; } }, State::IgnoreEsc => match b { b'\\' => self.state = State::Ground, - 0x1b => {} // stay, another ESC - // Same resync as `OscEsc`: the ESC opened a new OSC — scan - // it rather than missing the sequence that follows an - // unterminated, ignored one (e.g. a title OSC). + 0x1b => {} b']' => { self.buf.clear(); self.state = State::Osc; @@ -161,9 +100,6 @@ impl OscTokenizer { } } - /// Whether the identifier accumulated so far can still become one of `ids`. - /// Before the first `;` it is a prefix being built up; once the `;` arrives - /// it must match exactly. fn identifier_could_match(&self) -> bool { match self.buf.iter().position(|&b| b == b';') { Some(pos) => self.ids.iter().any(|&id| id == &self.buf[..pos]), @@ -171,7 +107,6 @@ impl OscTokenizer { } } - /// A complete, interesting OSC payload arrived: hand it to the consumer. fn finish(&mut self, on_payload: &mut impl FnMut(&[u8])) { on_payload(&self.buf); self.buf.clear(); @@ -179,23 +114,8 @@ impl OscTokenizer { } } -/// Parse a buffered OSC payload (the bytes after `ESC ]`, e.g. `9;Build done` -/// or `777;notify;Title;Body`) into a `(title, body)` desktop notification, or -/// `None` if it isn't one. Shared by the client's notification toaster -/// (`terminal::remote`) and the daemon's agent-status sniffer (`daemon::pane`), -/// so ConEmu's OSC 9 subcommand quirks are handled in exactly one place. -/// -/// tty7's own agent-event sentinel (`777;notify;tty7://cli-agent;{json}` — see -/// [`crate::core::cli_agent::AGENT_EVENT_SENTINEL`]) parses as a notification -/// *shape*, but it is machine-to-machine traffic: callers that surface toasts -/// must check for it first (via [`crate::core::cli_agent::parse_agent_event`]) -/// rather than showing the raw JSON to the user. pub fn parse_notification(payload: &[u8]) -> Option<(Option, String)> { - // OSC 9 ; — iTerm2 / growl style; title-less, body is the text. if let Some(rest) = payload.strip_prefix(b"9;") { - // ConEmu overloads OSC 9 with numeric subcommands (`9;4;…` progress, - // `9;9;`, …); those aren't notifications, so skip a `;`/`` - // leading field. A real message rarely starts with a bare single digit. let first = rest.split(|&b| b == b';').next().unwrap_or(rest); if first.len() == 1 && first[0].is_ascii_digit() { return None; @@ -203,15 +123,12 @@ pub fn parse_notification(payload: &[u8]) -> Option<(Option, String)> { let body = String::from_utf8_lossy(rest).into_owned(); return (!body.is_empty()).then_some((None, body)); } - // OSC 777 ; notify ; ; <body> — urxvt style. if let Some(rest) = payload.strip_prefix(b"777;notify;") { let mut parts = rest.splitn(2, |&b| b == b';'); let first = String::from_utf8_lossy(parts.next().unwrap_or(b"")).into_owned(); let second = parts .next() .map(|b| String::from_utf8_lossy(b).into_owned()); - // With both fields present it's title + body; with only one it's a body-only - // notification (some senders omit the title). let (title, body) = match second { Some(body) if !body.is_empty() => (Some(first), body), _ => (None, first), @@ -225,7 +142,6 @@ pub fn parse_notification(payload: &[u8]) -> Option<(Option<String>, String)> { mod tests { use super::*; - /// Run a tokenizer for `ids` over the chunks and collect delivered payloads. fn collect(ids: &'static [&'static [u8]], chunks: &[&[u8]]) -> Vec<Vec<u8>> { let mut tok = OscTokenizer::new(ids); let mut out = Vec::new(); @@ -249,7 +165,6 @@ mod tests { #[test] fn sequence_split_across_reads_is_reassembled() { - // Torn mid-payload and between the ESC and its ST backslash. assert_eq!( collect(&[b"7"], &[b"\x1b]7;file:", b"//h/x", b"\x07"]), vec![b"7;file://h/x".to_vec()] @@ -262,8 +177,6 @@ mod tests { #[test] fn uninteresting_identifiers_are_skipped_and_state_recovers() { - // OSC 0 (title) and OSC 52 (clipboard) are not in `ids`: nothing is - // delivered, and an interesting OSC right after is still caught. assert_eq!( collect( &[b"9"], @@ -275,10 +188,6 @@ mod tests { #[test] fn resyncs_on_new_osc_after_an_unterminated_one() { - // Regression (fixed independently in both pre-extraction copies): the - // ESC that aborts an unterminated OSC may itself open the next one; the - // `]` must re-open a fresh OSC rather than fall into Ground. Covers - // both the buffering path and the ignore path. assert_eq!( collect(&[b"9"], &[b"\x1b]9;dropped\x1b]9;kept\x07"]), vec![b"9;kept".to_vec()] @@ -291,22 +200,16 @@ mod tests { #[test] fn identifier_prefix_matching_buffers_only_possible_ids() { - // `77` is a prefix of `777` but `78` can no longer match: only the - // former's completed sequence is delivered. let ids: &'static [&'static [u8]] = &[b"777"]; assert_eq!( collect(ids, &[b"\x1b]78;x\x07\x1b]777;y\x07"]), vec![b"777;y".to_vec()] ); - // After the `;` the identifier must match exactly: `77;` is not `777`. assert_eq!(collect(ids, &[b"\x1b]77;x\x07"]), Vec::<Vec<u8>>::new()); } #[test] fn oversized_payload_is_abandoned_not_truncated() { - // A payload past the cap is dropped entirely (delivering a truncated - // cwd or notification would be worse than delivering none), and the - // stream recovers for the next sequence. let mut big = b"\x1b]9;".to_vec(); big.extend(std::iter::repeat_n(b'x', MAX_PAYLOAD + 1)); big.extend_from_slice(b"\x07\x1b]9;next\x07"); @@ -315,8 +218,6 @@ mod tests { #[test] fn byte_at_a_time_delivery_reassembles_every_state_transition() { - // The harshest tearing: one byte per `feed` call, crossing every state - // boundary (ESC/], identifier, payload, ESC/\ terminator) between reads. let stream = b"\x1b]0;title\x07\x1b]133;A\x1b\\plain\x1b]7;file://h/x\x07"; let chunks: Vec<&[u8]> = stream.chunks(1).collect(); assert_eq!( @@ -327,8 +228,6 @@ mod tests { #[test] fn ignored_sequence_split_across_reads_still_recovers() { - // An uninteresting OSC torn across chunks must keep being discarded - // (state persists across `feed`s), and the next interesting one lands. assert_eq!( collect( &[b"9"], @@ -340,12 +239,10 @@ mod tests { #[test] fn esc_runs_and_non_osc_escapes_do_not_confuse_the_scanner() { - // ESC ESC ] still opens an OSC (the last ESC wins). assert_eq!( collect(&[b"9"], &[b"\x1b\x1b]9;ok\x07"]), vec![b"9;ok".to_vec()] ); - // An ESC inside an OSC followed by a non-OSC escape abandons cleanly. assert_eq!( collect(&[b"9"], &[b"\x1b]9;half\x1b[0m\x1b]9;whole\x07"]), vec![b"9;whole".to_vec()] diff --git a/crates/tty7-core/src/core/proc.rs b/crates/tty7-core/src/core/proc.rs index a23bdb0e..6b1d7acb 100644 --- a/crates/tty7-core/src/core/proc.rs +++ b/crates/tty7-core/src/core/proc.rs @@ -1,24 +1,8 @@ -//! One place for the Windows subprocess flag every helper shell-out needs. -//! -//! tty7 is a GUI process with no console of its own, so launching a console -//! subsystem program (`git.exe`, `wsl.exe`, …) makes Windows allocate a fresh -//! console for it — a black window that pops up and vanishes. That is invisible -//! on a one-off invocation and very visible on the git-status probe, which runs -//! four `git` calls every time a pane's cwd changes or a command ends. -//! -//! `CREATE_NO_WINDOW` suppresses the console entirely; stdout/stderr pipes are -//! unaffected, so output capture keeps working. Every non-PTY `Command` in the -//! app should go through [`hide_console`] (or [`hide_console_tokio`] for the -//! async flavor) before it runs. PTY children are not in scope — the daemon -//! owns those and passes its own flags (see [`crate::daemon::spawn`]). - use std::process::Command; #[cfg(windows)] const CREATE_NO_WINDOW: u32 = 0x0800_0000; -/// Suppress the console window Windows would otherwise allocate for a console -/// subsystem child. No-op on Unix, so callers stay `cfg`-free. pub fn hide_console(cmd: &mut Command) -> &mut Command { #[cfg(windows)] { @@ -28,9 +12,6 @@ pub fn hide_console(cmd: &mut Command) -> &mut Command { cmd } -/// [`hide_console`] for `tokio::process::Command`. Separate because tokio's -/// builder is a distinct type with its own `creation_flags`, not a `Deref` to -/// the std one. pub fn hide_console_tokio(cmd: &mut tokio::process::Command) -> &mut tokio::process::Command { #[cfg(windows)] { diff --git a/crates/tty7-core/src/core/session.rs b/crates/tty7-core/src/core/session.rs index ca9efde6..ed3f01f3 100644 --- a/crates/tty7-core/src/core/session.rs +++ b/crates/tty7-core/src/core/session.rs @@ -1,70 +1,31 @@ -//! The client's workspace bookkeeping: the in-memory [`Session`] shape a -//! window is built from, and the persisted [`WindowView`] entries — pure view -//! state, because the layout itself lives in each machine's daemon-owned tree -//! (`core::machine`). -//! -//! [`Session`] / [`SessionTab`] / [`SessionPane`] mirror the live `Pane` tree -//! without GPUI types. They are **not persisted any more**: the window builder -//! consumes them, the tree hydration produces them, and the closed-tab stack -//! holds them, all in memory. -//! -//! [`WindowViews`] is the file — `~/.config/tty7/views.json`, alongside -//! `config.json`. All IO is best-effort: a missing/corrupt file just means "no -//! views to restore", and write failures are logged rather than fatal — the -//! app must never crash or stall over view bookkeeping. - use std::path::PathBuf; use serde::{Deserialize, Serialize}; use crate::daemon::protocol::NativeSshSpec; -/// Split orientation, mirroring `gpui::Axis` (which isn't `Serialize`). #[derive(Debug, Clone, Copy, Serialize, Deserialize)] pub enum SessionAxis { Horizontal, Vertical, } -/// A serializable mirror of one tab's `Pane` tree. #[derive(Debug, Clone, Serialize, Deserialize)] pub enum SessionPane { - /// A single terminal, restored in `cwd` (or the default dir if `None`). Leaf { #[serde(default)] cwd: Option<PathBuf>, - /// Daemon pane id this leaf was mirroring. On restore we re-`attach` to - /// it when the daemon still has it alive (process + scrollback intact), - /// else fall back to spawning a fresh shell in `cwd`. `None` for sessions - /// written by an older build (they just spawn fresh). #[serde(default)] pane_id: Option<u64>, - /// The native-SSH spec this leaf ran, **with secrets stripped** - /// ([`NativeSshSpec::without_secrets`]). Persisted so a *dead* native-SSH - /// pane can be respawned (reconnected) on restore rather than falling back - /// to a local shell — the reconnection UX itself is WS6's. A live pane - /// reattaches for free and needs none of this. `None` for local panes and - /// for sessions written before this field existed. #[serde(default)] ssh_spec: Option<Box<NativeSshSpec>>, - /// The coding agent this leaf was running at save time, plus its native - /// session id (from the agent's own `session-start` event). When the - /// pane can't re-attach on restore, these drive the cmux-style resume: - /// the fresh shell is handed the agent's resume command - /// (`claude --resume <id>`, …) so the conversation continues. `None` - /// for panes without an agent, agents without hooks, or old sessions. #[serde(default)] agent: Option<crate::core::cli_agent::CLIAgent>, #[serde(default)] agent_session_id: Option<String>, - /// The argv the agent was launched with, as the daemon observed it — - /// lets the resume command carry the user's launch flags - /// (`--dangerously-skip-permissions`, …) instead of resuming bare. - /// `None` for old sessions or when nothing was captured. #[serde(default)] agent_launch_argv: Option<Vec<String>>, }, - /// A split of two subtrees along `axis`, with `a` taking `ratio` of space. Split { axis: SessionAxis, #[serde(default = "default_ratio")] @@ -78,47 +39,17 @@ fn default_ratio() -> f32 { 0.5 } -/// A serializable mirror of one tab: its pane tree plus an optional user-set -/// name (from "Rename Tab"). A missing `name` falls back to the title-derived -/// label at render time. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SessionTab { #[serde(default)] pub name: Option<String>, pub pane: SessionPane, - /// The tab's last-known sidebar repo group (its repository home — the - /// main checkout's root, shared by all its linked worktrees), so a - /// restored session renders grouped immediately instead of starting flat - /// and reshuffling as git probes land. `None` = Scratch / never resolved. - /// - /// **A bare path, and that is sound.** A path alone cannot say *which* - /// machine it is on, and [`HostId`](crate::host::HostId) — which could — - /// is deliberately not persistable. The qualifier is not missing, it is - /// factored out: a tab always belongs to exactly one workspace, a - /// workspace names exactly one machine in [`WindowView::host`], and a - /// window shows exactly one workspace — mixing local and remote tabs in one - /// window is the thing tty7 never does. So the fully-qualified group key - /// is `(view.host_id(), tab.sidebar_group)`, with the host half - /// stored once per workspace instead of once per tab. Two machines whose - /// repos share a root path can only collide inside one window, which the - /// model does not permit. #[serde(default, skip_serializing_if = "Option::is_none")] pub sidebar_group: Option<std::path::PathBuf>, - /// The tab's identity in the daemon's machine tree, when this session was - /// derived *from* that tree — so a window rebuilt from it addresses the - /// daemon's tabs rather than minting new ids and churning them. **Never - /// persisted**: the tree is the authority on its own ids, and a stale one - /// written to disk would collide with a tab the daemon has since reused it - /// for. `None` (every other source) mints a fresh id. #[serde(skip)] pub tree_id: Option<crate::core::machine::TabId>, } -/// One workspace's contents: the open tabs and which one was active. -/// -/// This is the unit a single window displays — the in-memory shape a window -/// is built from and lowered into, never persisted (the machine's tree is the -/// layout's home). #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(default)] pub struct Session { @@ -126,9 +57,6 @@ pub struct Session { pub tabs: Vec<SessionTab>, } -/// Stable identity for a workspace, minted once when it is first created and -/// carried across restarts. Windows are transient views; *this* is what the -/// workspace picker reopens and what a window handle maps back to. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(transparent)] pub struct WorkspaceId(uuid::Uuid); @@ -138,8 +66,6 @@ impl WorkspaceId { Self(uuid::Uuid::new_v4()) } - /// A stable numeric key for gpui element ids, which need something - /// hashable and cheap rather than a freshly formatted string each frame. pub fn element_key(&self) -> u64 { self.0.as_u64_pair().0 } @@ -160,78 +86,34 @@ impl std::fmt::Display for WorkspaceId { impl std::str::FromStr for WorkspaceId { type Err = uuid::Error; - /// The inverse of `Display`, for the places a workspace id crosses a - /// string-keyed boundary (the control dialect's attach verbs, which - /// predate the typed tree) and has to come back out as itself. fn from_str(s: &str) -> Result<Self, Self::Err> { s.parse().map(WorkspaceId) } } -// --------------------------------------------------------------------------- -// Remote references -// --------------------------------------------------------------------------- - -/// The machine a remote workspace lives on, named the way the user already -/// named it. -/// -/// **This is a pointer, never a configuration.** It is a hard rule that a -/// machine is configured once and that remote workspaces reuse what is already -/// there — the profile's keys, its jump host, its `ProxyCommand` — so this type -/// has exactly one job: say *which* existing entry to connect through. The -/// three variants are the three places an SSH target can already have been -/// spelled out in tty7 today. -/// -/// | Variant | Where it came from | Connection key | -/// |---|---|---| -/// | [`Profile`](RemoteTarget::Profile) | A saved [`SshProfile`](crate::core::ssh_profile::SshProfile), by its stable uuid | `ssh-profile:<uuid>` | -/// | [`Alias`](RemoteTarget::Alias) | A `Host` stanza in `~/.ssh/config` | `ssh-alias:<alias>` | -/// | [`Direct`](RemoteTarget::Direct) | A typed `user@host:port` (QuickConnect) | `ssh-direct:<user>@<host>:<port>` | -/// | [`Wsl`](RemoteTarget::Wsl) | A distribution installed on this computer, as `wsl.exe -l -q` names it | `wsl:<distro>` | -/// -/// Persisted, unlike [`HostId`](crate::host::HostId): this is what survives a -/// restart, and the id is derived from it at connect time. #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum RemoteTarget { - /// A saved SSH profile, referenced by [`SshProfile::id`](crate::core::ssh_profile::SshProfile::id). - Profile { id: uuid::Uuid }, - /// A `Host` alias from `~/.ssh/config`. Kept verbatim — OpenSSH matches - /// alias names case-sensitively, so folding case here would point at a - /// different stanza than `ssh <alias>` would. - Alias { alias: String }, - /// A target typed straight in, as `parse_quick_connect` understands it. + Profile { + id: uuid::Uuid, + }, + Alias { + alias: String, + }, Direct { - /// The login user. Empty means "whatever this client's SSH would use", - /// which is a *different* connection key than a spelled-out user — see - /// [`RemoteTarget::connection_key`]. #[serde(default)] user: String, - /// Hostname or IP, lowercased (DNS is case-insensitive). host: String, #[serde(default = "default_ssh_port")] port: u16, }, - /// A WSL distribution, named exactly as `wsl -d` takes it. - /// - /// **The one machine that is configured zero times**: it is reached by - /// spawning `wsl.exe`, so there is no address, no credential and no host - /// key to spell out anywhere. The picker - /// (`ui::remote_connect::available_hosts`) therefore offers every - /// distribution installed on this computer rather than reading a store. - Wsl { distro: String }, - /// A `tty7-server --stdio` child process on *this* machine — the workspace - /// mirror of [`RouteTarget::LocalStdio`](crate::daemon::router::RouteTarget::LocalStdio), - /// and the only way to exercise a real remote workspace end to end without - /// an sshd. - /// - /// **Never offered by the picker.** It is reachable only when - /// `TTY7_LOCAL_STDIO_SERVER` names a server binary, which is how the - /// end-to-end tests and a developer's `dev-verify` run stand a machine up. - /// It grants no authority the socket did not already have: a pane's - /// `ClientMsg::Spawn` already runs an arbitrary program as this user over - /// that same user-private socket. - LocalStdio { program: String, args: Vec<String> }, + Wsl { + distro: String, + }, + LocalStdio { + program: String, + args: Vec<String>, + }, } fn default_ssh_port() -> u16 { @@ -239,11 +121,6 @@ fn default_ssh_port() -> u16 { } impl RemoteTarget { - /// A `user@host:port` target, normalized. - /// - /// The host is lowercased here *and* in [`connection_key`](Self::connection_key) - /// — here so two equal targets compare equal, there so a hand-edited - /// `views.json` with `Box.Local` still derives the same id as `box.local`. pub fn direct(user: impl Into<String>, host: impl Into<String>, port: u16) -> RemoteTarget { RemoteTarget::Direct { user: user.into(), @@ -252,15 +129,6 @@ impl RemoteTarget { } } - /// Parse `[ssh://]user@host[:port]` into a [`Direct`](RemoteTarget::Direct) - /// target. - /// - /// Deliberately delegates to - /// [`parse_quick_connect`](crate::core::ssh_profile::parse_quick_connect) - /// rather than parsing again: "the same string the connection manager - /// already accepts" is the whole promise of this variant, and a second - /// parser would be a second opinion about IPv6 brackets and `@` in - /// usernames. `None` for anything that parser rejects. pub fn parse_direct(input: &str) -> Option<RemoteTarget> { let q = crate::core::ssh_profile::parse_quick_connect(input)?; let port = q.port_or_default(); @@ -271,17 +139,6 @@ impl RemoteTarget { )) } - /// The canonical connection string this target hashes to. - /// - /// **Contains no workspace id.** Several workspaces on one box share a key, - /// and therefore share a [`HostId`](crate::host::HostId) and the one SSH - /// connection underneath it — the granularity the whole design assumes. - /// - /// One conservative case worth knowing: `me@box` and a bare `box` are - /// different keys even when the client's SSH would resolve them to the same - /// login. That costs a second connection, never a wrong one; merging them - /// would require resolving `~/.ssh/config` here, and getting *that* wrong - /// would point two machines at one cache. pub fn connection_key(&self) -> String { match self { RemoteTarget::Profile { id } => format!("ssh-profile:{id}"), @@ -296,22 +153,6 @@ impl RemoteTarget { } } - /// Whether this machine is reached over SSH. - /// - /// The question "Restart Server" asks, and the answer - /// [`router::restart_server`](crate::daemon::router) already gives: it routes - /// the action for SSH machines and refuses the other two. A `LocalStdio` - /// machine is a child process per connection, so there is nothing there to - /// stop and start; a WSL distribution's server is started by this client, - /// which makes "stop it and reconnect" the whole of the verb and not - /// something a routed action has to carry out. Asked here rather than - /// re-spelled at each call site, so the UI that offers the verb and the - /// router that carries it out cannot disagree about who has it. - /// - /// Spelled out variant by variant rather than as a `matches!` of the three - /// that say yes: this gates an action that ends every session on a machine, - /// and a new [`RemoteTarget`] must not inherit an answer to that by falling - /// off the end of a pattern. The compiler asks instead. pub fn is_ssh(&self) -> bool { match self { RemoteTarget::Profile { .. } @@ -321,23 +162,12 @@ impl RemoteTarget { } } - /// The in-process id this target resolves to. - /// - /// This is the **only** bridge between the persisted world and the runtime - /// one: `RemoteRef` is what survives a restart, `HostId` is what the - /// in-memory tables key on, and this function is how you get from the first - /// to the second. There is deliberately no inverse — an id is a hash, and a - /// structure that wanted to persist "which host" must persist a - /// [`RemoteTarget`]. pub fn host_id(&self) -> crate::host::HostId { crate::host::HostId::from_connection_key(&self.connection_key()) } } impl std::fmt::Display for RemoteTarget { - /// A label for a status bar or a picker row. A profile shows as its uuid - /// because the name lives in the profile store, which this type - /// deliberately does not reach into. fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { RemoteTarget::Profile { id } => write!(f, "{id}"), @@ -353,8 +183,6 @@ impl std::fmt::Display for RemoteTarget { Ok(()) } RemoteTarget::Wsl { distro } => write!(f, "wsl:{distro}"), - // The path, not the argv: this is a status-bar label, and the - // arguments are `--stdio` boilerplate that says nothing useful. RemoteTarget::LocalStdio { program, .. } => { let name = std::path::Path::new(program) .file_name() @@ -366,19 +194,9 @@ impl std::fmt::Display for RemoteTarget { } } -/// A workspace that lives on another machine: which machine, and which -/// workspace over there. -/// -/// The `workspace` id is the **remote's**, minted once and then used as the -/// workspace's id in that machine's daemon-owned tree -/// ([`crate::core::machine`]). A client-side [`WindowView`] carrying one of -/// these is a *view*, not the record: the layout lives on the remote, which -/// owns it. #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct RemoteRef { - /// Which machine, in terms of a configuration that already exists. pub target: RemoteTarget, - /// The workspace's id **on that machine**. pub workspace: WorkspaceId, } @@ -387,66 +205,29 @@ impl RemoteRef { RemoteRef { target, workspace } } - /// The id of the machine this points at. Two refs to different workspaces - /// on one box answer the same id. pub fn host_id(&self) -> crate::host::HostId { self.target.host_id() } - /// The wire key for this workspace — the form the string-keyed control - /// verbs (the attach family) and the `ControlEvent::Layout` events carry. pub fn store_key(&self) -> String { self.workspace.to_string() } } -/// One workspace's **view state** on this client: which workspace (and on -/// which machine), where its window last was, whether it was on screen, and -/// when it was last focused. The layout itself lives in the machine's tree — -/// this entry is deliberately only what the tree cannot know, the facts about -/// *this client's windows*. Closing a window is a *detach*: the panes keep -/// running in the daemon and the entry stays here with `open: false`. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WindowView { #[serde(default)] pub id: WorkspaceId, - /// Geometry this workspace's window last occupied, so reopening it lands - /// where the user left it rather than at the shared default. `None` for a - /// workspace that has never been on screen. #[serde(default, skip_serializing_if = "Option::is_none")] pub window: Option<crate::core::window_state::WindowState>, - /// Whether a window was showing this workspace at quit. Launch reopens - /// exactly one of the `open` ones; the rest wait in the picker. #[serde(default)] pub open: bool, - /// Unix seconds when this workspace was last focused, for "2 minutes ago" - /// in the picker and for ordering it. 0 == never recorded. - /// - /// The machine's tree keeps its own recency; this copy exists because - /// launch has to order entries before any tree has been pulled. #[serde(default)] pub last_active: u64, - /// The machine this workspace's panes and files live on. `None` means this - /// one. A `Some` entry keeps its own client-side `id` (the window - /// registry's handle) while `host.workspace` names the workspace on that - /// machine — see [`RemoteRef`]. #[serde(default, skip_serializing_if = "Option::is_none")] pub host: Option<RemoteRef>, - /// What this workspace was *called* the last time its machine answered, and - /// the path it was about — the picker's two lines. - /// - /// **A render hint, never an authority.** The machine's tree owns both (it - /// derives them from the tabs' repo groups and its panes' cwds), and - /// whenever the tree answers, the tree wins. This copy exists because the - /// picker's whole job is choosing among machines that are *not* answering: - /// a laptop that has been shut since Friday still has to be listed as - /// "tty7 — ~/repo/tty7" rather than as "Untitled" with a blank subtitle, - /// which is a row nobody can act on. Stamped on every save (and on the way - /// out, when a window closes), so what is on file is the last thing the - /// user actually saw. #[serde(default, skip_serializing_if = "Option::is_none")] pub label: Option<String>, - /// The subject path behind [`label`](Self::label) — see there. #[serde(default, skip_serializing_if = "Option::is_none")] pub subject: Option<String>, } @@ -466,14 +247,10 @@ impl Default for WindowView { } impl WindowView { - /// Stamp this workspace as just-focused. pub fn touch(&mut self) { self.last_active = now_secs(); } - // ----- the local / remote split ---------------------------------------- - - /// A client-side entry for a workspace that lives on another machine. pub fn on_remote(host: RemoteRef) -> WindowView { WindowView { host: Some(host), @@ -481,19 +258,10 @@ impl WindowView { } } - /// Whether this workspace lives on another machine. pub fn is_remote(&self) -> bool { self.host.is_some() } - /// The id of the machine this workspace's panes are on. - /// - /// This is the qualifier that turns a bare path or a bare `pane_id` into - /// something globally meaningful: `pane_id` is unique only within one remote - /// server, so the client's pane identity is `(host_id, pane_id)`, and a - /// repo root is unique only within one machine, so a sidebar group key is - /// `(host_id, sidebar_group)`. Storing it once per workspace rather than - /// once per pane is exactly what the one-window-one-machine rule buys. pub fn host_id(&self) -> crate::host::HostId { match &self.host { Some(r) => r.host_id(), @@ -502,8 +270,6 @@ impl WindowView { } } -/// The whole `views.json`: every workspace tty7 knows about, plus which one -/// had focus at quit. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(default)] pub struct WindowViews { @@ -513,9 +279,6 @@ pub struct WindowViews { } impl WindowViews { - /// Load every saved view. Returns `None` when the file is absent or - /// unreadable (normal first run), and `None` with a warning when it fails - /// to parse — never panics. pub fn load() -> Option<Self> { let path = Self::path()?; let text = std::fs::read_to_string(&path).ok()?; @@ -536,44 +299,10 @@ impl WindowViews { self.views.iter_mut().find(|w| w.id == id) } - /// The workspaces that had a window at the last quit, in their saved order. - /// - /// Note that launch does **not** restore all of these — see - /// [`workspace_to_restore`](Self::workspace_to_restore). They are still the - /// set that matters here, because every one of them is holding live daemon - /// panes and none of them may be forgotten. pub fn open_views(&self) -> impl Iterator<Item = &WindowView> { self.views.iter().filter(|w| w.open) } - /// The one workspace launch comes up on: whichever the user was last in. - /// - /// Deliberately one, not all of them. Restoring every window that existed - /// at quit means a four-window session costs four windows, four daemon - /// attaches and four layout restores before the user has said what they - /// want to do — and in practice they came back for *one* of them. The - /// others are not lost by any measure that matters: their panes never - /// stopped running in the daemon, and the switcher lists them a click away. - /// - /// Three answers, in order: - /// - /// 1. [`active`](Self::active) while it is still open — written on every - /// focus change, so it names the window that had the user's attention - /// last. - /// 2. the most recently active *open* workspace, for a store written by a - /// build that did not track focus, or one whose active workspace was - /// closed before quitting. - /// 3. the most recently active workspace of any kind, open or not. - /// - /// That last one is why closing every window and quitting still comes back - /// somewhere. Closing a window here is a *detach*: the panes keep running in - /// the daemon, so the workspace behind them is every bit as much "where the - /// user left off" as one that still had a window — and `close_window` - /// touches it on the way out, which makes the most recent of them the one - /// closed last. Only the explicit *Close Workspace* drops an entry from the - /// file, and that is the one gesture that means "I am done with this". - /// - /// `None` therefore means one thing: no workspaces at all, i.e. a first run. pub fn workspace_to_restore(&self) -> Option<WorkspaceId> { let focused = self .active @@ -592,9 +321,6 @@ impl WindowViews { }) } - /// Persist as JSON, creating the parent directory if needed. Any - /// IO/serialization error is logged and swallowed — the app must never - /// crash or stall over view bookkeeping. pub fn save(&self) { let Some(path) = Self::path() else { return; @@ -617,18 +343,11 @@ impl WindowViews { } } - /// `~/.config/tty7/views.json`, alongside `config.json`. - /// - /// A fresh name, not `session.json`: that file's document embedded whole - /// layouts, this one is pure view state, and the migration policy for the - /// tree refactor is deliberately none — an old file is simply ignored. fn path() -> Option<PathBuf> { crate::core::config::config_path("views.json") } } -/// Seconds since the Unix epoch, or 0 if the clock is before it (which only a -/// badly misconfigured machine reports — "never active" is a fine reading). fn now_secs() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -636,11 +355,6 @@ fn now_secs() -> u64 { .unwrap_or(0) } -/// Helpers for every test that touches the on-disk `views.json`. The -/// config-dir pin is process-wide (`set_config_dir` is first-call-wins), so -/// the file is process-wide too — any test that reads or writes it must hold -/// [`lock_session_file`] across the whole read/write sequence, or parallel -/// tests clobber each other's file. #[cfg(test)] pub(crate) mod test_support { use std::path::PathBuf; @@ -648,16 +362,10 @@ pub(crate) mod test_support { static SESSION_FILE: Mutex<()> = Mutex::new(()); - /// Serialize access to the shared `views.json`. pub(crate) fn lock_session_file() -> MutexGuard<'static, ()> { - // A poisoned lock just means another test failed mid-sequence; every - // holder rewrites the file from scratch, so the state is still sound. SESSION_FILE.lock().unwrap_or_else(|e| e.into_inner()) } - /// Pin the process config dir at a shared temp location so `save`/`load` - /// (which resolve `views.json` under it) never touch the real `~/.config`. - /// `set_config_dir` is first-call-wins; every caller computes the same path. pub(crate) fn pin_config_dir() -> PathBuf { let dir = std::env::temp_dir().join(format!("tty7-covtest-{}", std::process::id())); std::fs::create_dir_all(&dir).ok(); @@ -710,9 +418,6 @@ mod tests { assert_eq!(loaded.active, Some(id)); } - /// The migration policy for the tree refactor is deliberately none: an old - /// `session.json` (whatever its shape) is not read, and a `views.json` - /// missing every field still decodes rather than erroring a launch. #[test] fn an_empty_or_partial_file_decodes_to_defaults() { let empty: WindowViews = serde_json::from_str("{}").unwrap(); @@ -723,11 +428,6 @@ mod tests { assert!(!partial.views[0].is_remote()); } - // ── Remote references ─────────────────────────────────────────────────── - - /// The four key formats of the connection key, verbatim. These strings are a - /// wire contract in all but name: change one and every workspace on that - /// machine gets a different `HostId` than the connection pool minted. #[test] fn connection_keys_match_the_contract_table() { let uuid = uuid::Uuid::parse_str("6a8f2a1e-1c1b-4f7a-9d3e-2b5c8e4a7f01").unwrap(); @@ -759,11 +459,6 @@ mod tests { ); } - /// Which machines can be told to restart their server. The two that cannot - /// are not an omission: their server is this client's own doing, so there is - /// nothing on the far side to stop and start, and the router refuses the - /// action for exactly the same reason. A new variant has to answer this - /// question rather than inherit an answer. #[test] fn only_ssh_machines_have_a_server_to_restart() { assert!( @@ -798,8 +493,6 @@ mod tests { #[test] fn direct_targets_normalize_and_reuse_the_quick_connect_parser() { - // The port defaults to 22, the scheme is optional, and the host folds - // case — all of it the connection manager's existing behaviour. assert_eq!( RemoteTarget::parse_direct("ssh://me@Box.Local"), Some(RemoteTarget::direct("me", "box.local", 22)) @@ -808,7 +501,6 @@ mod tests { RemoteTarget::parse_direct("me@box.local:2222"), Some(RemoteTarget::direct("me", "box.local", 2222)) ); - // A hand-edited file with an uppercase host still derives one id. let shouty = RemoteTarget::Direct { user: "me".into(), host: "BOX.LOCAL".into(), @@ -818,11 +510,8 @@ mod tests { shouty.host_id(), RemoteTarget::direct("me", "box.local", 22).host_id() ); - // Rejected inputs stay rejected rather than becoming a half-target. assert_eq!(RemoteTarget::parse_direct(""), None); assert_eq!(RemoteTarget::parse_direct("me@box:0"), None); - // An alias is *not* case-folded: `ssh Devbox` and `ssh devbox` match - // different stanzas, and so must these. assert_ne!( RemoteTarget::Alias { alias: "Devbox".into() @@ -835,13 +524,6 @@ mod tests { ); } - /// The dev-only `--stdio` target is a *machine*, not a variation on local: - /// its key is distinct, its id is not [`HostId::LOCAL`](crate::host::HostId::LOCAL), - /// and two different server binaries are two different machines. - /// - /// That last part matters because everything keyed by `HostId` — the - /// connection pool, the git-status cache, the auth queue — would otherwise - /// merge two servers that share nothing. #[test] fn a_local_stdio_target_is_its_own_machine() { let a = RemoteTarget::LocalStdio { @@ -858,13 +540,9 @@ mod tests { !a.host_id().is_local(), "a routed target is never the local host" ); - // The label is the binary's name, not the argv: the flags say nothing a - // status bar can use. assert_eq!(a.to_string(), "local:tty7-server"); } - /// The granularity the connection pool depends on: one box, one id, however - /// many workspaces — and never `HostId::LOCAL`. #[test] fn views_on_one_box_share_a_host_id() { let target = RemoteTarget::Alias { @@ -879,11 +557,9 @@ mod tests { assert_eq!(a.host_id(), b.host_id(), "same machine, one HostId"); assert!(!a.host_id().is_local()); - // A different machine is a different id. let other = remote_view("other"); assert_ne!(a.host_id(), other.host_id()); - // And the local shape answers LOCAL, with nothing derived. assert_eq!(view().host_id(), crate::host::HostId::LOCAL); assert_eq!( a.host.as_ref().unwrap().store_key(), @@ -891,8 +567,6 @@ mod tests { ); } - // ── Launch ────────────────────────────────────────────────────────────── - #[test] fn open_views_partition_by_flag() { let mut open_one = view(); @@ -910,12 +584,6 @@ mod tests { ); } - /// Launch restores exactly one window, and it is the one the user was in. - /// - /// Pinned because the two inputs disagree on purpose: `active` is written on - /// every focus change, so it is the truth even when some *other* window saw - /// more recent activity (an agent finishing a build touches `last_active` - /// without anybody looking at it). #[test] fn launch_restores_the_focused_workspace_not_the_most_recently_touched() { let mut focused = view(); @@ -937,16 +605,12 @@ mod tests { "the others stay open in the store — launch detaches them, this does not" ); - // No focus recorded (or it named a workspace that was closed first): - // recency is the fallback, not a coin toss. let all = WindowViews { active: None, ..all }; assert_eq!(all.workspace_to_restore(), Some(busier_id)); - // `active` pointing at a *detached* workspace must not resurrect it — - // the user closed that window on purpose. let mut closed = view(); closed.open = false; let closed_id = closed.id; @@ -959,10 +623,6 @@ mod tests { }; assert_eq!(all.workspace_to_restore(), Some(open_id)); - // Nothing open at all — the user closed every window before quitting. - // Launch still comes back to the one closed last, because a detached - // workspace's panes are still running and `close_window` touches it on - // the way out. let mut first_closed = view(); first_closed.open = false; first_closed.last_active = 100; @@ -976,25 +636,15 @@ mod tests { }; assert_eq!(all.workspace_to_restore(), Some(closed_last_id)); - // A stale `active` naming a workspace that is gone from the file does - // not stop the fallback from answering. let all = WindowViews { active: Some(WorkspaceId::new()), ..all }; assert_eq!(all.workspace_to_restore(), Some(closed_last_id)); - // The only `None` left is a genuine first run. assert_eq!(WindowViews::default().workspace_to_restore(), None); } - /// An open workspace outranks a detached one even when the detached one saw - /// activity more recently — the fallback is for when *nothing* is open, not - /// a recency race across the two states. - /// - /// Without this, a background agent touching a detached workspace after the - /// user's last keystroke would have launch reopen that one instead of the - /// window that was actually on screen at quit. #[test] fn an_open_workspace_outranks_a_more_recently_touched_detached_one() { let mut open_one = view(); diff --git a/crates/tty7-core/src/core/shells.rs b/crates/tty7-core/src/core/shells.rs index d9fc165a..74287dba 100644 --- a/crates/tty7-core/src/core/shells.rs +++ b/crates/tty7-core/src/core/shells.rs @@ -1,43 +1,11 @@ -//! 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. -//! -//! 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. 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 -//! 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; use serde::{Deserialize, Serialize}; -/// 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. -/// -/// Serializable because the dropdown of a **remote** workspace's window lists -/// the shells of the machine that workspace lives on, not this one's: the list -/// crosses the control dialect as [`ShellInventory`]. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct DetectedShell { - /// Human-readable menu label, e.g. `zsh`, `PowerShell 7`, `WSL · Ubuntu`. pub label: String, pub program: String, pub args: Vec<String>, @@ -53,29 +21,12 @@ impl DetectedShell { } } -/// What one machine can launch: its shells, plus which of them a plain new tab -/// lands on. The unit the new-tab dropdown is built from. -/// -/// Both halves have to come from the *same* machine. A remote workspace's -/// window that listed this computer's shells would offer `/bin/zsh` on a box -/// whose zsh is at `/usr/bin/zsh` — a picker whose every entry fails to spawn. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct ShellInventory { pub shells: Vec<DetectedShell>, - /// Short name of the shell a *default* spawn resolves to (`zsh`, - /// `PowerShell 7`), for the menu's `default` tag. pub default_name: String, } -/// This machine's [`ShellInventory`], honoring the `shell` override in the -/// config file *this process* reads. -/// -/// The config lookup goes through [`crate::core::config::shell_command`] rather -/// than a GPUI global on purpose: the remote `tty7-server` answers this on the -/// far side of an SSH connection with no GUI in the process, and the override -/// that matters there is the one in *its* `config.json`. -/// -/// Runs filesystem probes — call off the UI thread. pub fn inventory() -> ShellInventory { let configured = crate::core::config::shell_command(); ShellInventory { @@ -84,9 +35,6 @@ pub fn inventory() -> ShellInventory { } } -/// 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)] { @@ -98,9 +46,6 @@ pub fn detect_shells() -> Vec<DetectedShell> { } } -/// 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(), @@ -118,9 +63,6 @@ pub fn default_shell_name(configured: Option<&str>) -> 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() @@ -134,12 +76,6 @@ fn basename(program: &str) -> String { } } -// --------------------------------------------------------------------------- -// 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 @@ -150,9 +86,6 @@ fn parse_etc_shells(content: &str) -> Vec<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>, @@ -172,20 +105,9 @@ 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<String> { let dirs: Vec<&str> = path_var.split(':').filter(|d| d.starts_with('/')).collect(); @@ -200,12 +122,6 @@ fn path_shell_candidates(path_var: &str) -> Vec<String> { #[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). - // 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 path_var = std::env::var("PATH").unwrap_or_default(); @@ -216,13 +132,6 @@ fn detect_unix() -> Vec<DetectedShell> { 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; @@ -234,9 +143,6 @@ pub fn windows_default_shell() -> &'static str { }) } -/// Locate PowerShell 7: 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(); @@ -259,14 +165,11 @@ fn find_pwsh7() -> Option<PathBuf> { .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")?; @@ -315,11 +218,6 @@ fn detect_windows() -> Vec<DetectedShell> { out.push(DetectedShell { label: "Git Bash".into(), program: bash.to_string_lossy().into_owned(), - // Interactive login shell — matches Git Bash's own launcher. These - // are tty7's args, not the user's, so shell integration may replace - // them with its own spelling of the same thing (see - // `protocol::ShellSpec::args_are_tty7_defaults`); they stand as the - // fallback for when integration doesn't apply or fails to set up. args: vec!["-i".into(), "-l".into()], }); } @@ -328,8 +226,6 @@ fn detect_windows() -> Vec<DetectedShell> { 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()], }); } @@ -337,38 +233,15 @@ fn detect_windows() -> Vec<DetectedShell> { out } -/// Git Bash's `bash.exe`, if Git for Windows is installed. Exposed only to -/// tests, so `daemon::shell_integration`'s live-PTY check can spawn the same -/// binary the dropdown does (and skip itself when there is none). #[cfg(all(windows, test))] pub fn git_bash_path() -> Option<PathBuf> { find_git_bash() } -/// Installed WSL distribution names, empty when WSL is absent — and always -/// empty off Windows, so callers need no `cfg` of their own. -/// -/// Two callers want the same list for different reasons. [`detect_shells`] -/// offers a distro as a **shell** to launch in a pane; the workspace switcher -/// offers it as a **machine** that can host a remote workspace -/// (`ui::remote_connect::available_hosts`). Same enumeration, so the two lists -/// can never disagree about which distros exist. -/// -/// Spawns `wsl.exe` on Windows — the same rule as [`detect_shells`]: call it -/// off the UI thread. pub fn wsl_distros() -> Vec<String> { wsl_distros_probed().unwrap_or_default() } -/// [`wsl_distros`], keeping the difference between *nothing is installed* and -/// *the probe could not answer*. -/// -/// `Some(vec![])` is an answer — WSL is present and has no distributions, or this -/// is not Windows at all, where there can never be one. `None` means the probe -/// itself failed, and a caller holding a previous list should keep it rather than -/// report that the user's distributions have gone away: `wsl.exe` refuses while a -/// `wsl --shutdown` is in flight, which is a routine thing to run and a terrible -/// reason to empty the machine picker. pub fn wsl_distros_probed() -> Option<Vec<String>> { #[cfg(windows)] { @@ -380,8 +253,6 @@ pub fn wsl_distros_probed() -> Option<Vec<String>> { } } -/// 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(); @@ -402,14 +273,6 @@ fn find_git_bash() -> Option<PathBuf> { pick_first_existing(candidates) } -/// Installed WSL distribution names via `wsl.exe -l -q`. -/// -/// **`None` is "the probe could not answer", not "there are none"** — no -/// `wsl.exe`, or one that failed, which is what a distribution mid-`wsl -/// --shutdown` or a broken WSL install looks like. `Some(vec![])` is the -/// authoritative empty answer: WSL is there and nothing is registered. -/// [`hide_console`](crate::core::proc::hide_console) keeps the probe from -/// flashing a console window (we're a GUI process). #[cfg(windows)] fn list_wsl_distros() -> Option<Vec<String>> { let mut cmd = std::process::Command::new("wsl.exe"); @@ -421,11 +284,8 @@ fn list_wsl_distros() -> Option<Vec<String>> { Some(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]])) @@ -453,8 +313,6 @@ mod tests { #[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", @@ -476,8 +334,6 @@ 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())); @@ -487,10 +343,6 @@ mod tests { #[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() @@ -514,7 +366,6 @@ mod tests { #[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"]); @@ -543,8 +394,6 @@ mod tests { 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()); } diff --git a/crates/tty7-core/src/core/ssh_profile.rs b/crates/tty7-core/src/core/ssh_profile.rs index 272921b2..8f5db096 100644 --- a/crates/tty7-core/src/core/ssh_profile.rs +++ b/crates/tty7-core/src/core/ssh_profile.rs @@ -1,95 +1,44 @@ -//! The SSH connection-manager profile model (PRD §7.1) plus QuickConnect parsing. -//! -//! A [`SshProfile`] is a full, user-editable connection definition persisted in -//! `config.json` (`Config::ssh_profiles`). Secrets never live here: a profile only -//! carries a [`CredentialRef`] naming its OS keychain entry. -//! -//! This is distinct from [`crate::core::ssh_config`], which does live *discovery* -//! of `~/.ssh/config` aliases for the palette. Profiles are owned by tty7 and can -//! be imported from `ssh_config` (see [`crate::core::ssh_config::import_profiles`]). - use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::core::keychain::CredentialRef; -/// A saved SSH connection profile. See PRD §7.1 for the field-by-field rationale. #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] #[serde(default)] pub struct SshProfile { - /// Stable identity. Referenced by [`SshProfile::jump_host`] on other profiles. #[serde(default = "new_id")] pub id: Uuid, - /// Display name (also the `ssh_config` alias for imported profiles). pub name: String, - /// Optional group/folder label. Imported profiles use - /// [`crate::core::ssh_config::IMPORTED_GROUP`]. pub group: Option<String>, - // ── Connection ─────────────────────────────────────────────────────────── - /// Target host (an IP or DNS name). pub host: String, - /// TCP port. Defaults to 22. #[serde(default = "default_port")] pub port: u16, - /// Login user. Empty means "resolve at connect time". pub user: String, - /// Jump host: the id of another profile to tunnel through (multi-level chains - /// resolve by following each hop's own `jump_host`). pub jump_host: Option<Uuid>, - /// A `ProxyCommand` to spawn as the transport. `%h`/`%p` tokens are substituted - /// at connect time (not here) — see PRD FR-C1. pub proxy_command: Option<String>, - /// A SOCKS5 proxy to dial through. pub socks_proxy: Option<HostPort>, - /// An HTTP `CONNECT` proxy to dial through. pub http_proxy: Option<HostPort>, - // ── Authentication ─────────────────────────────────────────────────────── - /// How to authenticate. `Auto` (the default) tries every method in order. #[serde(deserialize_with = "crate::core::config::de_lenient")] pub auth: AuthMode, - /// Private-key files to try, in order. Each supports `%h`/`%r` placeholders - /// (see [`expand_identity_placeholders`]). pub identity_files: Vec<String>, - /// Enable ssh-agent forwarding for the session. pub agent_forward: bool, - /// Pointer to the keychain entry holding this profile's saved secret. Never a - /// secret itself. pub credential_ref: Option<CredentialRef>, - // ── Forwarding ─────────────────────────────────────────────────────────── - /// Port forwards established automatically once connected. pub forwards: Vec<ForwardRule>, - // ── Session ────────────────────────────────────────────────────────────── - /// Keepalive interval in seconds (`None` = library default). pub keepalive_interval_s: Option<u32>, - /// Max missed keepalives before the connection is considered dead. pub keepalive_count_max: Option<u32>, - /// Connection timeout in seconds. pub connect_timeout_s: Option<u32>, - /// Per-profile override for the "confirm before closing" prompt (`None` = - /// follow the global setting). pub warn_on_close: Option<bool>, - /// Suppress the server login banner. pub skip_banner: bool, - /// Bootstrap tty7's shell integration into the remote shell (prompt marks, - /// exit codes, cwd — what the inline line editor runs on). On by default; - /// a remote we can't integrate declines itself, so this is the escape hatch - /// for one we *can* but shouldn't. #[serde(default = "default_true")] pub shell_integration: bool, - /// Commands sent automatically right after the shell opens. pub login_scripts: Vec<String>, - /// Request X11 forwarding. pub x11: bool, - // ── Advanced ───────────────────────────────────────────────────────────── - /// Preferred algorithm lists (empty list = library default for that category). pub algorithms: Algorithms, - /// Per-profile override for host-key verification (`None` = follow the global - /// setting; `Some(false)` disables verification for this profile). pub verify_host_keys: Option<bool>, } @@ -126,7 +75,6 @@ impl Default for SshProfile { } impl SshProfile { - /// A fresh profile with a new id and the given name; all else default. pub fn new(name: impl Into<String>) -> Self { Self { name: name.into(), @@ -134,8 +82,6 @@ impl SshProfile { } } - /// This profile's `identity_files` with `%h`/`%r` expanded against its own - /// host/user (see [`expand_identity_placeholders`]). pub fn expanded_identity_files(&self) -> Vec<String> { self.identity_files .iter() @@ -143,20 +89,15 @@ impl SshProfile { .collect() } - /// The `user@host:port` connect string for this profile (see - /// [`to_connect_string`]). pub fn connect_string(&self) -> String { to_connect_string(self) } } -/// A host + port pair (used for SOCKS/HTTP proxies and forward endpoints). #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] #[serde(default)] pub struct HostPort { - /// Hostname or IP. pub host: String, - /// Port number. pub port: u16, } @@ -170,7 +111,6 @@ impl Default for HostPort { } impl HostPort { - /// Construct a `HostPort`. pub fn new(host: impl Into<String>, port: u16) -> Self { Self { host: host.into(), @@ -179,51 +119,34 @@ impl HostPort { } } -/// How a profile authenticates. `Auto` tries every applicable method in order. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)] #[serde(rename_all = "kebab-case")] pub enum AuthMode { - /// Try public-key, agent, saved password, keyboard-interactive, prompt — in - /// order (the default). #[default] Auto, - /// GSSAPI with MIC only (Kerberos-style SSO). Gssapi, - /// Password only (saved, then prompted). Password, - /// Public-key only. PublicKey, - /// ssh-agent only. Agent, - /// keyboard-interactive only (2FA rides this path). KeyboardInteractive, } -/// The direction of a port forward. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)] #[serde(rename_all = "kebab-case")] pub enum ForwardKind { - /// Local (`-L`): listen locally, tunnel to `target` via the server. #[default] Local, - /// Remote (`-R`): the server listens, tunnels back to `target` on our side. Remote, - /// Dynamic (`-D`): a local SOCKS proxy; `target` is unused. Dynamic, } -/// One preconfigured port forward (PRD §7.1 `forwards`). #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] #[serde(default)] pub struct ForwardRule { - /// Local / Remote / Dynamic. #[serde(deserialize_with = "crate::core::config::de_lenient")] pub kind: ForwardKind, - /// The listener endpoint (local side for Local/Dynamic, remote side for Remote). pub bind: HostPort, - /// The endpoint traffic is delivered to. Ignored for [`ForwardKind::Dynamic`]. pub target: HostPort, - /// Optional human-readable label. pub description: String, } @@ -238,64 +161,39 @@ impl Default for ForwardRule { } } -/// Preferred algorithm lists per category. An empty list means "use the library -/// default set for this category" (PRD §7.1: `空=默认`). #[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)] #[serde(default)] pub struct Algorithms { - /// Key-exchange algorithms. pub kex: Vec<String>, - /// Symmetric ciphers. pub cipher: Vec<String>, - /// MAC algorithms. pub mac: Vec<String>, - /// Host-key algorithms. pub hostkey: Vec<String>, - /// Compression algorithms. pub compression: Vec<String>, } -/// A parsed QuickConnect target (PRD FR-P4). `user`/`port` are `None` when the -/// input omitted them; callers apply their own defaults (typically `port` → 22). #[derive(Debug, Clone, PartialEq, Eq)] pub struct QuickConnect { - /// The login user, if the input specified one (text before the last `@`). pub user: Option<String>, - /// The host (IPv6 addresses returned without their surrounding brackets). pub host: String, - /// The port, if the input specified one. pub port: Option<u16>, } impl QuickConnect { - /// The port, or 22 when unspecified. pub fn port_or_default(&self) -> u16 { self.port.unwrap_or(22) } } -/// Parse a QuickConnect string: `[ssh://]user@host[:port]`, with IPv6 in bracket -/// form `[::1]:2222` (PRD FR-P4). Mirrors Tabby's semantics (brief §8): -/// -/// - the `ssh://` scheme prefix is optional and stripped; -/// - `user` is everything before the **last** `@`, so `@` in usernames works; an -/// empty user (leading `@`) yields `user: None`; -/// - IPv6 must be bracketed; `host` is returned unbracketed; -/// - a port segment that isn't a valid `1..=65535` fails the whole parse (`None`). -/// -/// Returns `None` when the host is empty or the port is invalid. pub fn parse_quick_connect(input: &str) -> Option<QuickConnect> { let trimmed = input.trim(); if trimmed.is_empty() { return None; } - // Optional scheme. let body = trimmed .strip_prefix("ssh://") .or_else(|| trimmed.strip_prefix("SSH://")) .unwrap_or(trimmed); - // Split user off at the LAST '@' so '@' inside a username is preserved. let (user, hostport) = match body.rfind('@') { Some(ix) => { let u = &body[..ix]; @@ -316,13 +214,10 @@ pub fn parse_quick_connect(input: &str) -> Option<QuickConnect> { Some(QuickConnect { user, host, port }) } -/// Split a `host[:port]` / `[ipv6][:port]` fragment. Returns `None` if a present -/// port segment is not a valid `1..=65535`. fn split_host_port(hostport: &str) -> Option<(String, Option<u16>)> { if hostport.is_empty() { return Some((String::new(), None)); } - // IPv6 bracket form: [host] or [host]:port. if let Some(rest) = hostport.strip_prefix('[') { let close = rest.find(']')?; let host = rest[..close].to_string(); @@ -330,14 +225,10 @@ fn split_host_port(hostport: &str) -> Option<(String, Option<u16>)> { let port = match after.strip_prefix(':') { Some(p) => Some(parse_port(p)?), None if after.is_empty() => None, - // Trailing junk after ']' that isn't a ':port' → reject. None => return None, }; return Some((host, port)); } - // Non-bracket. A single ':' means `host:port` (the suffix must be a valid - // port, else reject). Several colons is a bare, unbracketed IPv6 address — - // ambiguous, so keep it whole as the host rather than guess a port. match hostport.matches(':').count() { 0 => Some((hostport.to_string(), None)), 1 => { @@ -348,21 +239,14 @@ fn split_host_port(hostport: &str) -> Option<(String, Option<u16>)> { } } -/// Parse a required, valid port; `None` on out-of-range / non-numeric / zero. fn parse_port(s: &str) -> Option<u16> { try_parse_port(s) } -/// `Some(port)` only for a valid `1..=65535`; `None` otherwise (u16 parse already -/// rejects > 65535, and we additionally reject 0). fn try_parse_port(s: &str) -> Option<u16> { s.parse::<u16>().ok().filter(|&p| p != 0) } -/// Render a profile as a `user@host:port` connect string (PRD FR-P5). The `user@` -/// is omitted when the user is empty and `:port` is omitted when it's the default -/// 22. IPv6 hosts are re-bracketed so the result round-trips through -/// [`parse_quick_connect`]. pub fn to_connect_string(profile: &SshProfile) -> String { let host = if profile.host.contains(':') { format!("[{}]", profile.host) @@ -382,15 +266,6 @@ pub fn to_connect_string(profile: &SshProfile) -> String { out } -/// Expand `%h` (host) and `%r` (remote user) placeholders in an identity-file path -/// (PRD FR-A2), plus a leading `~/` to the home directory. A single left-to-right -/// pass, so a `%h` that expands to text containing `%r` is not re-expanded. `%%` -/// yields a literal `%`. -/// -/// The tilde matters GUI-side: identity paths are overwhelmingly `~/.ssh/...` -/// (every ssh_config import), and the keychain passphrase scheme hashes the key -/// *file contents* — an unexpanded `~` makes that read silently fail, so -/// "remember passphrase" would neither store nor resolve. pub fn expand_identity_placeholders(path: &str, host: &str, user: &str) -> String { let mut out = String::with_capacity(path.len()); let mut chars = path.chars(); @@ -403,7 +278,6 @@ pub fn expand_identity_placeholders(path: &str, host: &str, user: &str) -> Strin Some('h') => out.push_str(host), Some('r') => out.push_str(user), Some('%') => out.push('%'), - // Unknown token: keep it verbatim (e.g. "%d" stays "%d"). Some(other) => { out.push('%'); out.push(other); @@ -414,8 +288,6 @@ pub fn expand_identity_placeholders(path: &str, host: &str, user: &str) -> Strin expand_tilde(&out) } -/// Expand a leading `~/` (or a bare `~`) to the user's home directory; every -/// other path passes through unchanged, as does `~` when no home is known. pub fn expand_tilde(path: &str) -> String { let home = || { #[cfg(windows)] @@ -441,15 +313,11 @@ pub fn expand_tilde(path: &str) -> String { mod tests { use super::*; - /// Profiles written before the shell-integration switch existed must load - /// with it *on*: a plain `#[serde(default)]` would give `false` and quietly - /// opt every existing profile out of the feature it never knew about. #[test] fn profiles_saved_before_the_switch_existed_default_to_integrated() { let profile: SshProfile = serde_json::from_str(r#"{"name":"prod","host":"h","user":"u"}"#).unwrap(); assert!(profile.shell_integration); - // …and a profile that explicitly opted out stays opted out. let off: SshProfile = serde_json::from_str( r#"{"name":"prod","host":"h","user":"u","shell_integration":false}"#, ) @@ -470,13 +338,11 @@ mod tests { assert_eq!(q.host, "10.0.0.5"); assert_eq!(q.port, Some(2222)); - // Host-only. let q = parse_quick_connect("example.com").unwrap(); assert_eq!(q.user, None); assert_eq!(q.host, "example.com"); assert_eq!(q.port, None); - // Host:port with no user. let q = parse_quick_connect("example.com:8022").unwrap(); assert_eq!(q.user, None); assert_eq!(q.host, "example.com"); @@ -493,13 +359,11 @@ mod tests { #[test] fn quick_connect_at_in_username_uses_last_at() { - // The user contains an '@' (e.g. an email-style login). let q = parse_quick_connect("me@corp.com@bastion").unwrap(); assert_eq!(q.user.as_deref(), Some("me@corp.com")); assert_eq!(q.host, "bastion"); assert_eq!(q.port, None); - // Leading '@' → empty user → None, host kept. let q = parse_quick_connect("@host").unwrap(); assert_eq!(q.user, None); assert_eq!(q.host, "host"); @@ -521,7 +385,6 @@ mod tests { assert_eq!(q.host, "2001:db8::dead:beef"); assert_eq!(q.port, Some(22)); - // A bare (unbracketed) IPv6 is ambiguous but must not be split into a port. let q = parse_quick_connect("fe80::1").unwrap(); assert_eq!(q.host, "fe80::1"); assert_eq!(q.port, None); @@ -531,15 +394,10 @@ mod tests { fn quick_connect_rejects_bad_ports_and_empties() { assert!(parse_quick_connect("").is_none()); assert!(parse_quick_connect(" ").is_none()); - // Port out of u16 range. assert!(parse_quick_connect("host:70000").is_none()); - // Port zero is invalid. assert!(parse_quick_connect("host:0").is_none()); - // Non-numeric single-colon suffix is a malformed port. assert!(parse_quick_connect("host:ssh").is_none()); - // Empty host. assert!(parse_quick_connect("deploy@").is_none()); - // Max valid port. assert_eq!(parse_quick_connect("host:65535").unwrap().port, Some(65535)); } @@ -554,12 +412,10 @@ mod tests { p.port = 2222; assert_eq!(to_connect_string(&p), "deploy@10.0.0.5:2222"); - // Empty user → no leading `user@`. p.user = String::new(); p.port = 22; assert_eq!(to_connect_string(&p), "10.0.0.5"); - // IPv6 host is re-bracketed and round-trips. p.host = "::1".to_string(); p.user = "root".to_string(); p.port = 2200; @@ -573,8 +429,6 @@ mod tests { #[test] fn identity_placeholder_expansion() { - // `~/` expands to the real home dir (the GUI hashes the key file's - // contents for the keychain, so the path must be readable as-is). let home = expand_tilde("~"); assert_eq!( expand_identity_placeholders("~/.ssh/id_%h", "example.com", "deploy"), @@ -584,14 +438,11 @@ mod tests { expand_identity_placeholders("~/keys/%r@%h.pem", "host", "alice"), format!("{home}/keys/alice@host.pem") ); - // A literal %% survives as a single %, and unknown tokens stay verbatim. assert_eq!( expand_identity_placeholders("100%%-%d-%h", "h", "u"), "100%-%d-h" ); - // Single left-to-right pass: %h expanding to text with %r is not re-expanded. assert_eq!(expand_identity_placeholders("%h", "%r", "u"), "%r"); - // No placeholders and no tilde → unchanged. assert_eq!( expand_identity_placeholders("/abs/.ssh/id_ed25519", "h", "u"), "/abs/.ssh/id_ed25519" @@ -603,7 +454,6 @@ mod tests { let home = expand_tilde("~"); assert!(!home.is_empty() && home != "~"); assert_eq!(expand_tilde("~/.ssh/id"), format!("{home}/.ssh/id")); - // Not a home reference: mid-path or suffixed tildes stay verbatim. assert_eq!(expand_tilde("/a/~/b"), "/a/~/b"); assert_eq!(expand_tilde("~user/x"), "~user/x"); assert_eq!(expand_tilde("/abs/path"), "/abs/path"); @@ -630,7 +480,6 @@ mod tests { #[test] fn profile_serde_defaults_and_round_trip() { - // A minimal profile JSON fills everything else from defaults. let p: SshProfile = serde_json::from_str(r#"{"name":"min","host":"h"}"#).unwrap(); assert_eq!(p.name, "min"); assert_eq!(p.host, "h"); @@ -638,21 +487,15 @@ mod tests { assert_eq!(p.auth, AuthMode::Auto); assert!(p.credential_ref.is_none()); - // Back-compat: a config.json from a build that still wrote the removed - // `use_system_ssh` flag loads fine — serde ignores the unknown field - // (the struct has container-level `#[serde(default)]`, no - // `deny_unknown_fields`). let p: SshProfile = serde_json::from_str(r#"{"name":"old","host":"h","use_system_ssh":true}"#).unwrap(); assert_eq!(p.name, "old"); assert_eq!(p.host, "h"); - // A bad `auth` value falls back leniently instead of failing the parse. let p: SshProfile = serde_json::from_str(r#"{"name":"x","host":"h","auth":"bogus"}"#).unwrap(); assert_eq!(p.auth, AuthMode::Auto); - // Full round trip preserves the id and every field. let mut original = SshProfile::new("full"); original.host = "10.0.0.9".to_string(); original.user = "deploy".to_string(); @@ -693,20 +536,14 @@ mod tests { } } -/// Serde default for [`SshProfile::id`]: a fresh v4 UUID. fn new_id() -> Uuid { Uuid::new_v4() } -/// Serde default for [`SshProfile::port`]: the standard SSH port. fn default_port() -> u16 { 22 } -/// Serde default for [`SshProfile::shell_integration`]. Named rather than -/// `#[serde(default)]` because the default is `true`, and because profiles -/// written before the field existed must deserialize as opted *in* — the -/// integration is the behavior we want everywhere it works. fn default_true() -> bool { true } diff --git a/crates/tty7-core/src/core/threads.rs b/crates/tty7-core/src/core/threads.rs index 498e664e..f61f7e86 100644 --- a/crates/tty7-core/src/core/threads.rs +++ b/crates/tty7-core/src/core/threads.rs @@ -1,26 +1,8 @@ -//! Thread-scheduling helpers shared by the daemon and the GUI client. - -/// Ask the OS to schedule the calling thread at user-interactive QoS. -/// -/// macOS assigns unclassified threads a default QoS the scheduler is free to -/// park on efficiency cores under load. Measured on an M1 Pro mid-benchmark: -/// whole seconds where the PTY drain drops from ~96 MB/s to 50–70 MB/s — an -/// E-core's pace — then recovers. The threads on the interactive output path -/// (daemon PTY reader, connection writer/reader, client socket reader) carry -/// keystroke echo and the visible output stream, which is exactly the workload -/// `QOS_CLASS_USER_INTERACTIVE` names. Best effort; a refused hint just keeps -/// the default class. No-op elsewhere: Linux/Windows schedulers don't demote -/// by QoS class. pub fn promote_to_user_interactive() { - // Escape hatch for benchmarking the promotion itself (and for users whose - // workload fares better under default scheduling): any non-empty value - // other than "0" disables it. if std::env::var("TTY7_NO_QOS").is_ok_and(|v| !v.is_empty() && v != "0") { return; } #[cfg(target_os = "macos")] - // SAFETY: a plain scheduling hint for the current thread; no pointers, no - // preconditions. unsafe { libc::pthread_set_qos_class_self_np(libc::qos_class_t::QOS_CLASS_USER_INTERACTIVE, 0); } diff --git a/crates/tty7-core/src/core/window_state.rs b/crates/tty7-core/src/core/window_state.rs index a24775dd..1fb05a5c 100644 --- a/crates/tty7-core/src/core/window_state.rs +++ b/crates/tty7-core/src/core/window_state.rs @@ -1,26 +1,7 @@ -//! Persisted last-window geometry, stored at `window.json` in the config dir -//! (alongside `config.json` / `views.json`). The quit hook in `ui::app` -//! writes the window's final bounds here unconditionally; startup reads it -//! back only when `Config::remember_window_size` is on, so toggling the -//! setting off and on again still restores the most recent quit's geometry. -//! Same durability contract as the other config-dir files: missing/malformed -//! reads fall back to "nothing remembered", writes are atomic. -//! -//! The geometry is four plain `f32`s here rather than a `gpui::Bounds` because -//! [`WindowView`](super::session::WindowView) embeds it and `views.json` is -//! parsed in this gpui-free crate. Converting to and from `Bounds` is the GUI -//! crate's job — see its `core::window_state::WindowGeometry` extension trait. - use serde::{Deserialize, Serialize}; -/// Don't restore a window smaller than this (logical px) — a corrupt or -/// hand-edited file shouldn't reopen tty7 as a sliver. const MIN_SIZE: f32 = 200.0; -/// Last known window geometry, in gpui's global coordinate space (logical -/// pixels; origins can be negative or beyond the primary display on -/// multi-monitor setups). For a fullscreen window this records the *restore* -/// bounds, so the next normal launch isn't screen-sized. #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] pub struct WindowState { pub x: f32, @@ -34,9 +15,6 @@ impl WindowState { crate::core::config::config_path("window.json") } - /// Load the remembered geometry; `None` when nothing usable is on disk - /// (never saved, unreadable, malformed, or degenerate values), in which - /// case the caller falls back to the centered default. pub fn load() -> Option<Self> { let path = Self::path()?; let text = std::fs::read_to_string(&path).ok()?; @@ -46,8 +24,6 @@ impl WindowState { state.is_usable().then_some(state) } - /// A geometry worth restoring: all values finite and the size at least - /// [`MIN_SIZE`] each way. fn is_usable(&self) -> bool { [self.x, self.y, self.width, self.height] .iter() @@ -56,8 +32,6 @@ impl WindowState { && self.height >= MIN_SIZE } - /// Persist the geometry; IO / serialization errors are logged and swallowed - /// (worst case the next launch opens at the default size). pub fn save(&self) { let Some(path) = Self::path() else { return; diff --git a/crates/tty7-core/src/core/worktree.rs b/crates/tty7-core/src/core/worktree.rs index fce3a915..4a7e45aa 100644 --- a/crates/tty7-core/src/core/worktree.rs +++ b/crates/tty7-core/src/core/worktree.rs @@ -1,29 +1,7 @@ -//! Git-worktree support for the tab context menu's "New Worktree Tab": derive -//! the repo from a pane's cwd, propose an unused two-word name (editable in the -//! sheet, see `ui::worktree_prompt`), and run `git worktree add -b` under the -//! repository's own `.tty7/worktrees/` (kept out of `git status` by an -//! auto-written self-ignoring `.tty7/.gitignore`) — so a coding agent gets an -//! isolated checkout on its own branch, physically next to the code it forks. -//! -//! Every filesystem touch and every `git` invocation goes through the [`Host`] -//! the pane belongs to, so a worktree is created on the machine the code -//! actually lives on rather than always on this one. That also means **all of -//! it blocks** — on a remote host every call here is a round trip — so callers -//! run the whole module on the background executor (`ui::host_ops`), never -//! inline while building a menu. -//! -//! Path arithmetic is deliberately the host's too ([`Host::join`], never -//! `PathBuf::join`): a Windows client driving a Linux host would otherwise -//! build `/home/me\.tty7` and create a repository directory with a backslash in -//! its name. - use std::path::{Path, PathBuf}; use crate::host::Host; -/// Word pools for generated branch names (`quiet-otter`). Short, lowercase, -/// branch-safe; two pools of 24 give 576 combinations before the numeric -/// fallback in [`defaults`] kicks in. const ADJECTIVES: [&str; 24] = [ "quiet", "amber", "bold", "calm", "cedar", "coral", "dusky", "early", "fable", "gold", "hazel", "ivory", "jade", "keen", "lunar", "mossy", "noble", "ochre", "pale", "rapid", "sunny", "tidal", @@ -35,16 +13,12 @@ const NOUNS: [&str; 24] = [ "vole", "walrus", "yak", ]; -/// A freshly created worktree: where it lives and the branch checked out in it. #[derive(Debug)] pub struct NewWorktree { pub path: PathBuf, pub branch: String, } -/// What to create, as confirmed (or edited) in the sheet: the checkout's -/// directory name under the managed root, the new branch's name, and the -/// commit-ish it starts from. #[derive(Debug, Clone)] pub struct WorktreeRequest { pub name: String, @@ -52,10 +26,6 @@ pub struct WorktreeRequest { pub base: String, } -/// Pre-filled values for the sheet: an unused two-word candidate (offered as -/// both directory name and branch), the branch currently checked out (the -/// natural start point; `"HEAD"` when detached), and the directory the new -/// checkout would land in, for the live path preview. #[derive(Debug, Clone)] pub struct WorktreeDefaults { pub name: String, @@ -63,27 +33,10 @@ pub struct WorktreeDefaults { pub dir: PathBuf, } -/// `<root>/.tty7/worktrees` — where this repository's managed checkouts live. -/// Built with [`Host::join`] rather than [`PathBuf::join`] so the separator is -/// the *host's*, not the client's. fn managed_root(host: &dyn Host, main_root: &Path) -> PathBuf { host.join(&host.join(main_root, ".tty7"), "worktrees") } -/// Run `git -C <dir> <args>` on `host`, returning trimmed stdout on success and -/// trimmed stderr as the error otherwise. -/// -/// The shape is unchanged from when this module ran `git` itself; what changed -/// is that the invocation is now the one every git read in tty7 shares -/// (`core::git::git_output`): `GIT_OPTIONAL_LOCKS=0`, stdin nulled, ambient -/// `GIT_DIR`/`GIT_WORK_TREE` removed. `GIT_OPTIONAL_LOCKS` only suppresses -/// *optional* sub-operations (git's own words) — the locks `worktree add` and -/// `branch -d` need to do their job are not optional and are still taken. -/// -/// The three outcomes stay distinct: `Err` from the host means git never ran -/// (missing binary, vanished cwd, dead connection) and carries the same -/// `failed to run git:` prefix this module has always produced, while a git -/// that ran and failed still reports its own stderr. fn git(host: &dyn Host, dir: &Path, args: &[&str]) -> Result<String, String> { match host.git(dir, args) { Ok(out) if out.success() => Ok(out.stdout_trimmed()), @@ -92,8 +45,6 @@ fn git(host: &dyn Host, dir: &Path, args: &[&str]) -> Result<String, String> { } } -/// Whether `name` already exists as a local branch in the repo at `repo_root`. -/// A failed probe (`--verify --quiet` exits non-zero) means it's free. fn branch_exists(host: &dyn Host, repo_root: &Path, name: &str) -> bool { git( host, @@ -108,8 +59,6 @@ fn branch_exists(host: &dyn Host, repo_root: &Path, name: &str) -> bool { .is_ok() } -/// A tiny xorshift over a time+pid seed — enough randomness to spread branch -/// names without pulling in a `rand` dependency. fn seed() -> u64 { let nanos = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -125,16 +74,12 @@ fn next(state: &mut u64) -> u64 { *state } -/// One `adjective-noun` candidate from the pools. fn candidate(state: &mut u64) -> String { let a = ADJECTIVES[(next(state) % ADJECTIVES.len() as u64) as usize]; let n = NOUNS[(next(state) % NOUNS.len() as u64) as usize]; format!("{a}-{n}") } -/// A tty7-managed worktree a closing tab sat in, resolved for the -/// close-time cleanup offer: where it is, its branch, the repository it -/// belongs to, and whether it holds uncommitted changes. #[derive(Debug, Clone)] pub struct ManagedWorktree { pub path: PathBuf, @@ -143,16 +88,7 @@ pub struct ManagedWorktree { pub dirty: bool, } -/// Resolve `cwd` to the tty7-managed worktree containing it, or `None` when it -/// sits anywhere else. Only checkouts under the main repository's -/// `.tty7/worktrees/` count — a user's own linked worktrees are never offered -/// for removal. Blocking (spawns `git`). pub fn managed(host: &dyn Host, cwd: &Path) -> Option<ManagedWorktree> { - // Canonicalize before the component test: git reports resolved physical - // paths (`/private/var/…` on macOS), while `cwd` may arrive through - // symlinks — a textual comparison would then never match. The `.tty7/ - // worktrees` ancestor check is a cheap pure-textual pre-filter, so the - // common case (every ordinary tab close) never spawns git. let cwd = host.canonicalize(cwd).ok()?; let suffix = host.join(Path::new(".tty7"), "worktrees"); if !cwd.ancestors().any(|a| a.ends_with(&suffix)) { @@ -168,8 +104,6 @@ pub fn managed(host: &dyn Host, cwd: &Path) -> Option<ManagedWorktree> { .map(PathBuf::from)? .parent()? .to_path_buf(); - // The checkout must really sit in *this* repository's managed directory — - // both paths come from git, so they compare on equal (physical) footing. if !path.starts_with(managed_root(host, &main_root)) { return None; } @@ -185,13 +119,6 @@ pub fn managed(host: &dyn Host, cwd: &Path) -> Option<ManagedWorktree> { }) } -/// Whether any of `cwds` still lives inside the worktree at `path` — removing -/// the checkout then would pull the directory out from under a live shell (new -/// tabs inherit the current cwd, so two tabs sharing one worktree is common). -/// Both sides are canonicalized before the ancestor test — cwds may arrive -/// through symlinks, and on Windows canonicalize adds a `\\?\` verbatim prefix -/// that git-reported paths lack, so comparing raw would never match. A -/// vanished path never counts as occupying. pub fn occupied(host: &dyn Host, path: &Path, cwds: &[PathBuf]) -> bool { let Ok(path) = host.canonicalize(path) else { return false; @@ -200,9 +127,6 @@ pub fn occupied(host: &dyn Host, path: &Path, cwds: &[PathBuf]) -> bool { .any(|c| host.canonicalize(c).is_ok_and(|c| c.starts_with(&path))) } -/// Remove a managed worktree (`git worktree remove`, `--force` to discard -/// uncommitted changes), then best-effort delete its branch with `-d` — so a -/// branch carrying unmerged commits survives the cleanup. pub fn remove(host: &dyn Host, wt: &ManagedWorktree, force: bool) -> Result<(), String> { let path = wt.path.to_str().ok_or("worktree path is not valid UTF-8")?; let mut args = vec!["worktree", "remove"]; @@ -215,11 +139,6 @@ pub fn remove(host: &dyn Host, wt: &ManagedWorktree, force: bool) -> Result<(), Ok(()) } -/// Locate the repository containing `cwd` and the directory its managed -/// worktrees live in: `(repo_root, <main-root>/.tty7/worktrees)`. Anchored on -/// the *main* repository even when `cwd` is itself inside a linked worktree -/// (a worktree tab spawning another worktree), so checkouts never nest. The -/// common git-dir is `<main>/.git`, whose parent is the main root. fn repo_dir(host: &dyn Host, cwd: &Path) -> Result<(PathBuf, PathBuf), String> { let repo_root = git(host, cwd, &["rev-parse", "--show-toplevel"]) .map_err(|_| "not inside a git repository".to_string())?; @@ -237,18 +156,12 @@ fn repo_dir(host: &dyn Host, cwd: &Path) -> Result<(PathBuf, PathBuf), String> { Ok((repo_root, dir)) } -/// Compute the sheet's pre-filled values: a generated `adjective-noun` name -/// (retried until both the branch and the directory are unused, with a -/// numeric-suffix fallback so a saturated pool still terminates) and the -/// currently checked-out branch as the start point. pub fn defaults(host: &dyn Host, cwd: &Path) -> Result<WorktreeDefaults, String> { let (repo_root, dir) = repo_dir(host, cwd)?; let mut state = seed(); let mut name = candidate(&mut state); for attempt in 0..64 { - // Both the ref and the directory must be free — a stale directory from a - // hand-removed worktree would make `git worktree add` fail either way. if !branch_exists(host, &repo_root, &name) && !host.exists(&host.join(&dir, &name)) { break; } @@ -259,16 +172,11 @@ pub fn defaults(host: &dyn Host, cwd: &Path) -> Result<WorktreeDefaults, String> }; } - // Detached HEAD (or an unborn branch) has no abbrev-ref; start from HEAD. let base = git(host, &repo_root, &["rev-parse", "--abbrev-ref", "HEAD"]) .unwrap_or_else(|_| "HEAD".to_string()); Ok(WorktreeDefaults { name, base, dir }) } -/// Create the requested worktree for the repository containing `cwd`, at -/// `<main-root>/.tty7/worktrees/<name>`, on new branch `branch` starting from -/// `base`. Branch and base validity is git's to judge; the directory name only -/// has to stay a single path component so it can't escape the managed root. pub fn create(host: &dyn Host, cwd: &Path, req: &WorktreeRequest) -> Result<NewWorktree, String> { if req.name.is_empty() || req.name == "." || req.name == ".." || req.name.contains(['/', '\\']) { @@ -277,10 +185,6 @@ pub fn create(host: &dyn Host, cwd: &Path, req: &WorktreeRequest) -> Result<NewW let (repo_root, dir) = repo_dir(host, cwd)?; host.create_dir(&dir, true) .map_err(|e| format!("cannot create {}: {e}", dir.display()))?; - // A `*` gitignore inside `.tty7/` keeps the whole tree (checkouts included, - // the ignore file itself too) out of the repository's `git status`, without - // ever editing the repo's own .gitignore. Best-effort: a failed write only - // costs status noise, never the worktree. let ignore = host.join( dir.parent().expect(".tty7/worktrees has a parent"), ".gitignore", @@ -315,8 +219,6 @@ pub fn create(host: &dyn Host, cwd: &Path, req: &WorktreeRequest) -> Result<NewW mod tests { use super::*; - /// A fresh scratch dir under the system temp location, unique per test — - /// the same std-only pattern the config tests use (no tempfile dep). fn scratch(name: &str) -> PathBuf { let dir = std::env::temp_dir().join(format!("tty7-wt-{name}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); @@ -324,8 +226,6 @@ mod tests { dir } - /// Strip Windows' `\\?\` verbatim prefix so `std::fs::canonicalize` output - /// compares equal to the plain absolute paths git reports; a no-op on Unix. fn plain(p: &Path) -> PathBuf { let s = p.to_string_lossy(); PathBuf::from(s.strip_prefix(r"\\?\").unwrap_or(&s).to_string()) @@ -344,8 +244,6 @@ mod tests { ); } - /// A throwaway repo with one commit, so `worktree add` has a HEAD to branch - /// from. fn temp_repo(name: &str) -> PathBuf { let dir = scratch(name); sh(&dir, &["git", "init", "-q"]); @@ -357,15 +255,10 @@ mod tests { dir } - /// The host every test drives: this machine. `LocalHost` is what the GUI - /// hands these functions today, so testing against it tests the real path; - /// a remote host is covered by the conformance suite instead. fn h() -> crate::host::SharedHost { crate::host::local::LocalHost::new() } - /// The simplest sensible request: directory and branch share `name`, - /// starting from HEAD — what the sheet submits when nothing is edited. fn req(name: &str) -> WorktreeRequest { WorktreeRequest { name: name.into(), @@ -391,8 +284,6 @@ mod tests { assert!(!branch_exists(&*h, &repo, &d.name)); let head = git(&*h, &repo, &["rev-parse", "--abbrev-ref", "HEAD"]).unwrap(); assert_eq!(d.base, head); - // The target dir is the repo's own `.tty7/worktrees` (git reports the - // canonical root: /var → /private/var on macOS). let canon = plain(&std::fs::canonicalize(&repo).unwrap()); assert_eq!(plain(&d.dir), canon.join(".tty7").join("worktrees")); let _ = std::fs::remove_dir_all(&repo); @@ -405,20 +296,15 @@ mod tests { let wt = create(&*h, &repo, &req("quiet-otter")).unwrap(); assert!(wt.path.join("a.txt").exists()); assert!(branch_exists(&*h, &repo, &wt.branch)); - // The worktree lands under `<repo>/.tty7/worktrees/<name>`… let canon = plain(&std::fs::canonicalize(&repo).unwrap()); assert_eq!(plain(&wt.path), canon.join(".tty7/worktrees/quiet-otter")); - // …on the new branch… let head = git(&*h, &wt.path, &["rev-parse", "--abbrev-ref", "HEAD"]).unwrap(); assert_eq!(head, wt.branch); - // …and the auto-written `.tty7/.gitignore` keeps the main repo's - // status clean despite the checkout living inside it. assert_eq!( std::fs::read_to_string(canon.join(".tty7/.gitignore")).unwrap(), "*\n" ); assert_eq!(git(&*h, &repo, &["status", "--porcelain"]).unwrap(), ""); - // A second request colliding on the directory is refused up front. assert!( create(&*h, &repo, &req("quiet-otter")) .unwrap_err() @@ -431,7 +317,6 @@ mod tests { fn create_honors_custom_branch_and_base() { let h = h(); let repo = temp_repo("base"); - // A `stable` branch one commit behind the default branch's HEAD. sh(&repo, &["git", "branch", "stable"]); std::fs::write(repo.join("b.txt"), "b").unwrap(); sh(&repo, &["git", "add", "."]); @@ -446,11 +331,9 @@ mod tests { }, ) .unwrap(); - // Directory and branch names diverge as requested… assert_eq!(wt.path.file_name().unwrap().to_str().unwrap(), "my-dir"); let head = git(&*h, &wt.path, &["rev-parse", "--abbrev-ref", "HEAD"]).unwrap(); assert_eq!(head, "feat/my-branch"); - // …and the checkout starts from `stable` (no b.txt yet). assert!(wt.path.join("a.txt").exists()); assert!(!wt.path.join("b.txt").exists()); let _ = std::fs::remove_dir_all(&repo); @@ -478,8 +361,6 @@ mod tests { let h = h(); let repo = temp_repo("nest"); let first = create(&*h, &repo, &req("first-wt")).unwrap(); - // Spawn the second worktree from *inside* the first: it must land in - // the main repo's `.tty7/worktrees`, not nest inside the first checkout. let second = create(&*h, &first.path, &req("second-wt")).unwrap(); assert_eq!(second.path.parent().unwrap(), first.path.parent().unwrap()); let _ = std::fs::remove_dir_all(&repo); @@ -490,9 +371,7 @@ mod tests { let h = h(); let repo = temp_repo("mg"); let wt = create(&*h, &repo, &req("mg-wt")).unwrap(); - // The repo root itself is never "managed"… assert!(managed(&*h, &repo).is_none()); - // …nor is a linked worktree the user made outside `.tty7/worktrees`. let own = scratch("mg-own"); let _ = std::fs::remove_dir_all(&own); sh( @@ -507,15 +386,12 @@ mod tests { ], ); assert!(managed(&*h, &own).is_none()); - // Any path inside the managed checkout resolves to it, initially clean. let sub = wt.path.join("sub"); std::fs::create_dir_all(&sub).unwrap(); let m = managed(&*h, &sub).unwrap(); assert_eq!(m.branch, wt.branch); assert_eq!(m.path, wt.path); assert!(!m.dirty); - // Uncommitted changes flip `dirty` and block a plain remove; --force - // discards them. The branch (no unique commits) is deleted with it. std::fs::write(wt.path.join("b.txt"), "b").unwrap(); let m = managed(&*h, &wt.path).unwrap(); assert!(m.dirty); @@ -527,32 +403,14 @@ mod tests { let _ = std::fs::remove_dir_all(&own); } - /// `Host::git` runs every invocation with `GIT_OPTIONAL_LOCKS=0`, which - /// this module did *not* set when it spawned `git` itself. That variable is - /// the one thing in the unified invocation with any claim to affect a - /// *write*, so the whole create → list → remove → delete-branch path is - /// exercised end to end under it rather than argued about. - /// - /// It is safe by git's own definition — "complete any requested operation - /// without performing any optional sub-operations that require taking a - /// lock" (`git(1)`, GIT_OPTIONAL_LOCKS). `worktree add` and `branch -d` are - /// the *requested* operations, never optional sub-operations, and the locks - /// they need are taken regardless. This test is what keeps that from being - /// a reading of the manual: it fails if a git version ever decides - /// otherwise. #[test] fn writes_survive_the_optional_locks_invariant() { let h = h(); let repo = temp_repo("locks"); - // That the variable is *set* on every `Host::git` is asserted once, for - // every host, by the conformance suite's `git_optional_locks_env_is_set` - // — not re-derived here. What this test owns is the consequence. - // add — the write the contract flags as the one to prove. let wt = create(&*h, &repo, &req("lock-wt")).unwrap(); assert!(wt.path.join("a.txt").exists()); - // list — the new checkout is really registered, not merely on disk. let list = git(&*h, &repo, &["worktree", "list", "--porcelain"]).unwrap(); assert!( list.lines() @@ -560,8 +418,6 @@ mod tests { "worktree list must show the new checkout: {list}" ); - // A commit inside the checkout: an index write, the operation whose - // *optional* index refresh is what the variable suppresses. std::fs::write(wt.path.join("c.txt"), "c").unwrap(); git(&*h, &wt.path, &["add", "."]).unwrap(); git( @@ -580,15 +436,10 @@ mod tests { .unwrap(); assert_eq!(git(&*h, &wt.path, &["status", "--porcelain"]).unwrap(), ""); - // remove + `branch -d`: the branch now carries a commit the main branch - // does not, so the best-effort `-d` correctly declines and the branch - // survives — the safety property `remove` documents. let m = managed(&*h, &wt.path).unwrap(); remove(&*h, &m, false).unwrap(); assert!(!wt.path.exists()); assert!(branch_exists(&*h, &repo, &wt.branch)); - // …and a branch with nothing unique on it is deleted, so the `-d` is - // genuinely running rather than always failing. let plain_wt = create(&*h, &repo, &req("lock-wt2")).unwrap(); let m = managed(&*h, &plain_wt.path).unwrap(); remove(&*h, &m, false).unwrap(); @@ -605,9 +456,7 @@ mod tests { let inside = wt.path.join("deep"); std::fs::create_dir_all(&inside).unwrap(); assert!(occupied(&*h, &wt.path, &[repo.clone(), inside])); - // Cwds elsewhere in the repo don't count… assert!(!occupied(&*h, &wt.path, std::slice::from_ref(&repo))); - // …and neither does a cwd that no longer exists. assert!(!occupied(&*h, &wt.path, &[wt.path.join("gone")])); let _ = std::fs::remove_dir_all(&repo); } diff --git a/crates/tty7-core/src/daemon/control.rs b/crates/tty7-core/src/daemon/control.rs index d5e39c05..39c76a70 100644 --- a/crates/tty7-core/src/daemon/control.rs +++ b/crates/tty7-core/src/daemon/control.rs @@ -1,72 +1,3 @@ -//! The **control** dialect: a multiplexed request/response channel for -//! filesystem + git operations against a machine that isn't this one. -//! -//! The pane protocol in [`super::protocol`] is deliberately unmultiplexed — one -//! connection carries one PTY, requests have no ids, and a control request is a -//! short-lived connection of its own. That shape stops working the moment the -//! peer is on the other side of an ocean: a file tree expanding a directory -//! must not queue behind a `git status` that takes two seconds. So control gets -//! its own dialect on the same framing, with request ids and out-of-order -//! replies. -//! -//! ## Relationship to [`super::protocol`] -//! -//! | Shared | Separate | -//! |---|---| -//! | Outer frame `[u32 LE payload_len][u8 kind][payload]` | Kind space (control owns **60-63**) | -//! | [`MAX_FRAME`] (64 MiB) | Message enums, payload layout | -//! | `write_frame` / `read_frame` / `take_frame` | Request ids, timeouts, cancellation | -//! -//! Control kinds live in this module's own [`kind`], not `protocol`'s (which is -//! private by design). The two spaces never mix on one connection in a way that -//! could be ambiguous: a peer that speaks control announces it through -//! [`crate::daemon::protocol::PROTOCOL_VERSION`] ≥ 3 plus the -//! [`feature::CONTROL`] capability bit, and 60-63 sit clear of every range the -//! pane protocol has reserved (WS3 auth 15-19, WS4 forwards 20-24, SFTP 30-36) -//! and clear of the **retired** kind 13 (once `SPAWN_MANAGED_SSH`), which is -//! never reused — an old daemon would decode it as a pane spawn and silently do -//! the wrong thing rather than reporting an unknown kind. -//! -//! ## Payload layouts -//! -//! ```text -//! HELLO / HELLO_OK (60) -//! ┌──────────────────────────┐ -//! │ JSON (payload_len bytes) │ -//! └──────────────────────────┘ -//! -//! REQUEST / RESPONSE (61), CANCEL (63, C→S), EVENT (63, S→C) -//! ┌───────────────┬───────────────┬──────────────────┐ -//! │ u64 LE req_id │ u32 LE json_n │ JSON (json_n B) │ -//! └───────────────┴───────────────┴──────────────────┘ -//! payload_len == 12 + json_n -//! -//! REQUEST_BLOB / RESPONSE_BLOB (62) -//! ┌───────────────┬───────────────┬─────────────────┬─────────────────────┐ -//! │ u64 LE req_id │ u32 LE json_n │ JSON (json_n B) │ raw blob (the rest) │ -//! └───────────────┴───────────────┴─────────────────┴─────────────────────┘ -//! blob_len == payload_len - 12 - json_n -//! ``` -//! -//! Every non-`HELLO` frame carries a `req_id` even when it can't have one -//! (events are always 0). The eight redundant bytes buy a single header parser: -//! read `u64`, read `u32`, take the JSON, *then* branch on kind. -//! -//! Bulk payloads keep a JSON head rather than being bare bytes because the -//! bytes alone can't carry their own parameters — `WriteFile` needs the target -//! path beside the content, and `ReadFile`'s reply needs the [`Meta`] the -//! editor would otherwise have to fetch in a second round trip. -//! -//! ## Request ids -//! -//! | | | -//! |---|---| -//! | Allocated by | the client, only; the server never mints one | -//! | Range | starts at 1, `fetch_add(1)`; **0 is reserved for server pushes** | -//! | Matching | out of order — a reply is claimed by id, not by arrival order | -//! | Unknown id in a reply | dropped silently (a timed-out request's reply may still land) | -//! | Shape | strictly one reply per request; no streaming, no continuation frames | - use std::collections::HashMap; use std::io::{self, Read, Write}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; @@ -78,274 +9,67 @@ use serde::{Deserialize, Serialize}; use super::protocol::{MAX_FRAME, read_frame, write_frame}; -/// The control dialect's own version, negotiated in [`ControlHello`] and -/// independent of [`crate::daemon::protocol::PROTOCOL_VERSION`]: the pane -/// protocol and the control dialect evolve on separate clocks, and a remote -/// `tty7-server` speaks control without necessarily serving panes at all. -/// -/// Bump this when a new [`ControlRequest`] / [`ReplyOk`] variant lands. That is -/// stricter than the pane protocol's rule, and deliberately so: these enums have -/// no `#[serde(other)]` fallback either, but the consequence here is worse — -/// [`crate::daemon::install`] decides whether to *upgrade the remote binary* by -/// comparing dialect numbers, so a capability that doesn't move the number is a -/// capability the far machine never gets. A [`feature`] string is the right -/// answer only for something two current servers can genuinely disagree about -/// (the machine tree, which depends on how the server was started); "this -/// build knows the request and older ones don't" is what the number is for. -/// -/// ## History -/// -/// - **v3** — the machine-tree migration. The workspace/tab/pane tree moved -/// into the daemon: `MachineGet` / `WorkspaceTree`, the semantic tree verbs -/// (workspace/tab/pane create, close, rename, move, split, ratio, replace), -/// and the [`ControlEvent::Layout`] / [`ControlEvent::LayoutResync`] pushes -/// — seventeen new request variants in all — while the retired opaque-record -/// verbs -/// (`workspace_list` / `workspace_get` / `workspace_put` / -/// `workspace_delete` and the `workspace_changed` event) left the dialect -/// entirely. A v2 peer meeting any of the new variants fails the whole -/// decode (no `#[serde(other)]`), and this build meeting a v2 server's -/// record verbs would answer unknown-variant errors forever. The -/// [`feature::MACHINE_TREE`] bit still exists *within* v3, because two -/// current servers can genuinely differ on it (a box with no home -/// directory serves files but no tree). -/// - **v2** — [`ControlRequest::Shells`], which backs a remote window's new-tab -/// dropdown. Not a `feature` string: every server from this build on answers -/// it, so the only thing a capability bit would have bought is that a machine -/// running an older server keeps running it forever, silently serving an empty -/// menu. The bump makes `RemoteProtocol::serves` refuse to adopt that server -/// and install this build's instead, which is the actual fix. -/// - **v1** — the dialect at the time remote workspaces landed. pub const CONTROL_VERSION: u32 = 3; -/// The phrase every control-dialect refusal contains. -/// -/// Written down once so the message and the test that recognises it cannot -/// drift apart. See [`is_dialect_refusal`]. const DIALECT_MARKER: &str = "speaks control v"; -/// The message a handshake between two incompatible control dialects fails -/// with. fn dialect_refusal(peer_build: &str, peer: u32, ours: u32) -> String { format!("control peer (build {peer_build}) {DIALECT_MARKER}{peer}, this build speaks v{ours}") } -/// Whether an error message is a control-dialect refusal rather than one of the -/// dozen other reasons a connect fails (a dead host, a bad key, a refused -/// port). -/// -/// A marker in the text rather than a typed error because this message crosses -/// two process boundaries — the daemon's route ack, then the GUI's error card — -/// as a `String`, and rebuilding a type on the far side of that would be more -/// machinery than the one question needs. -/// -/// The question is worth asking because the answer is unusually specific: a -/// dialect refusal is the *only* connect failure that a reinstall on the far end -/// fixes, and the only one where offering that (destructive) action is -/// justified. Everything else it must not offer it for. pub fn is_dialect_refusal(message: &str) -> bool { message.contains(DIALECT_MARKER) } -/// This process's identity as a control server, minted once on first use. -/// -/// Answers "am I still talking to the same server?" — the question no other -/// field in [`ControlHelloOk`] can answer, because `build` and both version -/// numbers survive a restart unchanged (the remote's binary is replaced in -/// place, keeping its name). A client that reconnects and sees a different value -/// here *knows* every `pane_id` it holds names a pane in a process that no -/// longer exists. -/// -/// Per **process**, not per connection: a server serves many connections and -/// they must all report the same instance, or the client would read every new -/// connection as a restart. pub fn server_instance() -> &'static str { static INSTANCE: OnceLock<String> = OnceLock::new(); INSTANCE.get_or_init(|| uuid::Uuid::new_v4().to_string()) } -/// Paths coalesced into one [`ControlEvent::Watch`] window before the server -/// gives up on precision and sends [`ControlEvent::WatchOverflow`] instead, -/// which the client answers by invalidating the whole tree. A cap rather than -/// an unbounded batch because `cargo build` in a watched root can touch tens of -/// thousands of paths in a single 100 ms window, and re-listing beats shipping -/// them. pub const WATCH_BURST_CAP: usize = 1024; -/// Rolling window the server coalesces filesystem events into. The local -/// implementation uses the same figure on purpose — a watcher that is "helpfully" -/// more responsive locally makes every timing-sensitive behavior diverge between -/// a local and a remote workspace. pub const WATCH_COALESCE_WINDOW: Duration = Duration::from_millis(100); -// --------------------------------------------------------------------------- -// Kinds -// --------------------------------------------------------------------------- - -/// Control frame kind bytes. -/// -/// Client→server and server→client are independent spaces (a connection always -/// knows which direction it is reading), so the numeric overlap between, say, -/// [`kind::REQUEST`] and [`kind::RESPONSE`] is deliberate and mirrors how -/// `protocol`'s two spaces already work. -/// -/// 64-69 are held open for later control frames (streaming replies, -/// backpressure signals) so the dialect never has to claim a second range. pub mod kind { - // ----- client -> server ------------------------------------------------- - /// Handshake. The only control frame without a `req_id`. pub const HELLO: u8 = 60; - /// A request whose parameters fit in JSON. pub const REQUEST: u8 = 61; - /// A request with bulk bytes trailing the JSON (`WriteFile`). pub const REQUEST_BLOB: u8 = 62; - /// Abandon a request. Best-effort on the server; the client has already - /// given up by the time it sends this. pub const CANCEL: u8 = 63; - // ----- server -> client ------------------------------------------------- - - /// Handshake reply. Sent even on a version mismatch (then the server hangs - /// up), so the client can report *which* version it met. pub const HELLO_OK: u8 = 60; - /// A reply whose payload fits in JSON. pub const RESPONSE: u8 = 61; - /// A reply with bulk bytes trailing the JSON (`ReadFile`). pub const RESPONSE_BLOB: u8 = 62; - /// An unsolicited push. `req_id` is always 0. pub const EVENT: u8 = 63; } -/// Target size of a [`ControlEvent::GitChunk`] batch. Big enough that a -/// multi-megabyte diff is a few hundred frames rather than a frame per line, -/// small enough that neither side ever holds much of it. pub const GIT_STREAM_CHUNK: usize = 64 * 1024; -/// Hard ceiling on one [`ControlEvent::GitChunk`]'s payload, as opposed to -/// [`GIT_STREAM_CHUNK`]'s target. -/// -/// A batch is flushed once it *reaches* the target, so its size is the target -/// plus whatever the line that crossed it was. Without a ceiling such a batch -/// could encode to a frame larger than [`crate::daemon::protocol::MAX_FRAME`] -/// and not be sendable at all. Half the frame limit leaves room for base64's -/// four-thirds expansion and the JSON envelope around it. Splitting at this -/// size can land mid-line; that is safe precisely because the receiver -/// reassembles with [`crate::core::git::LineSplitter`], which spans chunk -/// boundaries. -/// -/// A backstop rather than a working limit: [`crate::core::git::MAX_LINE`] caps -/// the line that crosses the target, so a real batch is a megabyte at worst and -/// never reaches this. It stays because the two bounds answer to different -/// things — one to what a diff viewer can show, this one to what a frame can -/// hold — and a batch that cannot be sent is a stream that never ends. pub const GIT_STREAM_CHUNK_MAX: usize = crate::daemon::protocol::MAX_FRAME / 2; -/// How long a [`ControlRequest::GitStream`] reader waits for the *next* chunk -/// before giving up on the stream. -/// -/// Idle time, deliberately, not elapsed time: a `git diff` over a big work tree -/// on a slow link is allowed to take as long as it takes, and any cap on the -/// total would eventually truncate a legitimate read. What it catches is the -/// case nothing else can — a link that is *alive* while the server's git is -/// wedged (a network filesystem that stops answering, a lock held by something -/// that never exits). Keepalive cannot see that: the connection keeps answering -/// pings, so it is never declared dead, and the stream is answered by pushes so -/// the request deadline is long since satisfied. Without this the calling -/// thread parks for the life of the process. -/// -/// Set well past the slowest plausible gap between chunks — a cold-cache `git -/// diff` on a huge tree can take a while to print its first byte, and that gap -/// is the one being measured. pub const GIT_STREAM_IDLE_TIMEOUT: Duration = Duration::from_secs(120); -/// Ceiling on [`ControlRequest::GitStream`]s running at once on one connection. -/// -/// Each is an OS thread of its own reading a `git` child, and unlike a request -/// it is not answered from the bounded worker pool — nothing else counts them. -/// One client asking honestly needs one or two (the diff overlay and the -/// Changes panel share a probe); this is set well above that and is a backstop -/// against a peer that asks faster than it drains, not a working limit. -/// -/// A refused stream is answered with a failed [`ControlEvent::GitEnd`] rather -/// than dropped, because a stream that never speaks is the one shape a client -/// cannot recover from cheaply. pub const MAX_CONCURRENT_GIT_STREAMS: usize = 8; -/// Capability strings advertised in [`crate::daemon::protocol::DaemonVersion::features`]. -/// -/// These exist so a capability added after protocol v3 doesn't need another -/// version bump: a peer answers "what can you do" with a list rather than a -/// number, and an unknown string is simply a capability this build won't use. pub mod feature { - /// Speaks the control dialect: kinds 60-63, this module's framing. pub const CONTROL: &str = "control"; - /// Serves [`super::ControlRequest`]'s filesystem and git methods — i.e. can - /// back a remote `Host`. Distinct from [`CONTROL`] because a peer could - /// speak the dialect while exposing only the workspace tree. pub const HOST_RPC: &str = "host-rpc"; - // `"workspace-store"` is a burned name: it advertised the retired - // opaque-record scheme (verbs `workspace_list` / `workspace_get` / - // `workspace_put` / `workspace_delete`, event `workspace_changed`), all of - // which are burned with it. Never re-advertise or re-mint any of them with - // a different meaning. - /// Serves the machine-owned workspace tree: the `MachineGet` / - /// `WorkspaceTree` pulls, the semantic tree operations, and the - /// [`super::ControlEvent::Layout`] pushes. Advertised only when the server - /// actually carries a [`crate::core::machine::MachineStore`], so a client - /// learns from the handshake whether the tree verbs are worth a round - /// trip. pub const MACHINE_TREE: &str = "machine-tree"; - /// Can be launched as `--stdio` and bridge its own stdin/stdout to the - /// machine-local socket (the fallback when `AllowStreamLocalForwarding` is - /// off, the only option under WSL, and how the CI end-to-end test runs). pub const STDIO_BRIDGE: &str = "stdio-bridge"; } -// --------------------------------------------------------------------------- -// Payload types -// --------------------------------------------------------------------------- - -// The shapes that cross the wire in both directions — `Entry`, `Meta`, -// `MTime`, `Output`, `SearchHit` — are the `Host` trait's own types, re-exported -// here rather than mirrored. A parallel "wire" copy would be a standing invitation -// to let the two drift, and every drift between them is a silent -// mistranslation rather than a compile error. pub use crate::host::{Entry, MTime, Meta, Output, SearchHit}; -// Same rule for the machine's shell inventory: the dropdown's own type crosses -// the wire, not a wire-only copy of it. pub use crate::core::shells::{DetectedShell, ShellInventory}; -// And for the machine tree: the daemon's own tree types are the wire types, so -// a schema drift between the store and the dialect is a compile error rather -// than a silent mistranslation. `WorkspaceId` rides along because every tree -// verb addresses a workspace by it. pub use crate::core::machine::{Axis, LayoutDelta, Machine, PaneSeed, Side, Tab, TabId}; pub use crate::core::session::WorkspaceId; -// --------------------------------------------------------------------------- -// Requests -// --------------------------------------------------------------------------- - -/// Everything a client can ask a control peer to do. -/// -/// **Paths are `String`, never `PathBuf`.** `PathBuf`'s serde representation of -/// a non-UTF-8 path is platform-dependent, and the two ends of this connection -/// are routinely different operating systems. Remote paths are UTF-8 POSIX; a -/// non-UTF-8 name on the server is returned lossily by `ReadDir`, matching what -/// the file tree already does locally with `to_string_lossy`. -// Not `Eq`: the machine-tree verbs carry split ratios, and `f32` has no `Eq`. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ControlRequest { - // ----- liveness --------------------------------------------------------- Ping, - // ----- filesystem reads ------------------------------------------------- - /// `root` bounds the gitignore chain: rules are evaluated from `root` down - /// to `dir`, deeper wins, `!` re-includes. `None` means nothing is ignored - /// except `.git`. ReadDir { dir: String, root: Option<String>, @@ -359,23 +83,10 @@ pub enum ControlRequest { Canonicalize { path: String, }, - /// `max_bytes` is enforced **on the server**: over the limit it answers - /// [`WireErrorKind::FileTooLarge`] rather than shipping the file and having - /// the client throw it away. ReadFile { path: String, max_bytes: u64, }, - /// Breadth-first substring match over names, run entirely on the server. - /// The local implementation walks up to 2000 directories; doing that a - /// directory at a time over a transcontinental link would be 2000 round - /// trips. - /// - /// `show_hidden` is here rather than left to the client because it also - /// governs *descent*: with it false the walk never enters an ignored or - /// dot-prefixed directory, which is what stops `node_modules` from - /// consuming the whole `max_dirs` budget. Filtering the hits afterwards on - /// the client would give a different — and much slower — answer. Search { roots: Vec<String>, query: String, @@ -384,77 +95,43 @@ pub enum ControlRequest { show_hidden: bool, }, - // ----- filesystem writes ------------------------------------------------ - /// Content rides in the blob of a [`kind::REQUEST_BLOB`] frame. WriteFile { path: String, }, - /// Exclusive create; `AlreadyExists` if the path is taken (`File::create_new`). CreateFileNew { path: String, }, - /// `recursive` picks `create_dir_all` over `create_dir`. CreateDir { path: String, recursive: bool, }, - /// The server guarantees `AlreadyExists` when `to` exists — the client must - /// not probe first, which would be both an extra round trip and a TOCTOU. Rename { from: String, to: String, }, - /// `recursive` only means anything for directories. Remove { path: String, recursive: bool, }, - // ----- git -------------------------------------------------------------- - /// The work-tree root containing `path`: nearest ancestor with a `.git` - /// (directory or linked-worktree file). One server-side walk, not a ladder - /// of round trips. RepoRoot { path: String, }, - /// `git -C <cwd> <args>`. A non-zero exit is a successful reply carrying - /// that status, **not** an error — see [`WireError`]. Git { cwd: String, args: Vec<String>, }, - /// `git -C <cwd> <args>`, delivered incrementally: the server pushes - /// [`ControlEvent::GitChunk`]s under `id` until a [`ControlEvent::GitEnd`] - /// carrying the exit status. - /// - /// Same invariants as [`Git`](Self::Git) — it is the same invocation — but - /// neither side has to hold the whole output: `git diff HEAD` on a large - /// work tree is tens of megabytes, and the caller throws most of it away. - /// **The client picks `id`**, which is why there is no id in the reply. - /// Ids only have to be unique within a connection, and one connection has - /// one client — so choosing it here lets the client register its receiver - /// *before* the request goes out. A server-assigned id would arrive in the - /// reply, leaving a window in which a chunk that overtook it would reach a - /// client with no entry for that id and be dropped under the unknown-id - /// rule, silently losing the front of the diff. GitStream { id: u64, cwd: String, args: Vec<String>, }, - // ----- machine inventory ------------------------------------------------- - /// The shells installed on the server, for the new-tab dropdown of a window - /// bound to it. Answered by every server speaking [`CONTROL_VERSION`] ≥ 2; - /// an older one is replaced rather than asked (see there). Shells, - // ----- watch ------------------------------------------------------------ - /// Open a subscription; the server answers with a [`ReplyOk::WatchId`]. WatchOpen { dirs: Vec<String>, }, - /// Replace a subscription's directory set wholesale; the server diffs. WatchSet { id: u64, dirs: Vec<String>, @@ -463,55 +140,17 @@ pub enum ControlRequest { id: u64, }, - // The opaque record store's verbs — `workspace_list` / `workspace_get` / - // `workspace_put` / `workspace_delete` — lived here until the machine tree - // below replaced them. Their serde names are burned (see `feature`); do - // not re-mint them with a different meaning. - - // ----- attachment (M6's takeover) --------------------------------------- - /// Claim a workspace for this connection's session, taking it over from - /// whoever held it. The server answers - /// [`ReplyOk::Attached`] and pushes [`ControlEvent::Preempted`] to the - /// displaced session. - /// - /// A *request* rather than only the [`ControlHello::workspace`] field - /// because a client holds **one connection per machine** and may have - /// several of that machine's workspaces open at once — a hello can name one - /// workspace, and the second window would otherwise need a second link to - /// the same box. Both paths run the identical server-side claim; the hello - /// field remains the shorthand for a connection dedicated to one workspace, - /// which is what the end-to-end tests use. WorkspaceAttach { id: String, }, - /// Release a workspace this connection holds. Token-checked on the server, - /// so a session that has *already* been preempted cannot evict the client - /// that took over from it by tidying up afterwards. WorkspaceDetach { id: String, }, - // ----- machine tree (the daemon-owned structure) ------------------------ - // The semantic replacement for the retired opaque record verbs: instead of - // a whole-record `Put` (last-writer-wins the moment two clients write), - // each operation names its edit, the server validates it against the tree - // it owns, and everyone else hears an incremental - // [`ControlEvent::Layout`]. Positions cross as `u64` for the same reason - // `Search`'s limits do: a 32-bit server clamps rather than wraps. - /// The whole tree — every workspace, tab and pane record on the machine. - /// The full pull a client starts from before applying deltas. MachineGet, - /// One workspace of the tree, whole. `NotFound` when the machine has no - /// such workspace; also the re-pull a client falls back to when it cannot - /// apply a delta. WorkspaceTree { workspace: WorkspaceId, }, - /// Create an empty workspace; its first tab arrives as its own operation. - /// Answers the newborn [`ReplyOk::WorkspaceTree`]. `workspace` lets the - /// client mint the id — a window names its workspace before any round trip - /// completes — and `None` has the daemon mint one, as before. A taken id - /// is refused, never adopted. WorkspaceCreate { name: Option<String>, #[serde(default)] @@ -521,13 +160,9 @@ pub enum ControlRequest { workspace: WorkspaceId, name: Option<String>, }, - /// Forget a tree workspace and everything under it. Named `Remove` because - /// `WorkspaceDelete` was the retired record store's verb, and its serde - /// name stays burned. WorkspaceRemove { workspace: WorkspaceId, }, - /// Stamp a workspace as just-focused, for pickers ordered by recency. WorkspaceTouch { workspace: WorkspaceId, }, @@ -535,11 +170,6 @@ pub enum ControlRequest { workspace: WorkspaceId, tab: TabId, }, - /// Create a tab holding `pane` at position `at` (clamped; `None` appends). - /// `pane` is the seed for a pane the client already spawned over the pane - /// protocol — PTYs come from there, the tree only adopts them. `tab` is - /// the client-minted identity (see `WorkspaceCreate::workspace`); `None` - /// has the daemon mint one. TabCreate { workspace: WorkspaceId, at: Option<u64>, @@ -547,9 +177,6 @@ pub enum ControlRequest { #[serde(default)] tab: Option<TabId>, }, - /// Close a tab. Answers [`ReplyOk::Panes`]: the pane ids that left the - /// tree, for the caller to kill — the tree does bookkeeping, not process - /// teardown. TabClose { workspace: WorkspaceId, tab: TabId, @@ -564,14 +191,11 @@ pub enum ControlRequest { tab: TabId, to: u64, }, - /// Record the tab's sidebar repo group, as resolved by the client. TabSetGroup { workspace: WorkspaceId, tab: TabId, group: Option<String>, }, - /// Split the leaf holding `pane`; `new` seeds the freshly-spawned second - /// pane, `first` puts it on the upper/left side. PaneSplit { workspace: WorkspaceId, pane: u64, @@ -580,22 +204,16 @@ pub enum ControlRequest { new: PaneSeed, first: bool, }, - /// Close one pane, collapsing its split (or the whole tab when it was the - /// last pane). Answers [`ReplyOk::Panes`] like `TabClose`. PaneClose { workspace: WorkspaceId, pane: u64, }, - /// Move a split's divider. `path` addresses the split from the tab root, - /// and a path the tree no longer has refuses rather than guessing. PaneSetRatio { workspace: WorkspaceId, tab: TabId, path: Vec<Side>, ratio: f32, }, - /// tmux's `move-pane`: take `pane` out of where it is and re-split it next - /// to `to`, dissolving the source tab if that emptied it. PaneMove { workspace: WorkspaceId, pane: u64, @@ -603,8 +221,6 @@ pub enum ControlRequest { axis: Axis, first: bool, }, - /// The revival: rebind the leaf holding dead pane `old` to freshly-spawned - /// successor `new`, spending the old registry record. PaneReplace { workspace: WorkspaceId, old: u64, @@ -613,17 +229,6 @@ pub enum ControlRequest { } impl ControlRequest { - /// How long the client waits before giving up on this request. - /// - /// Per-method rather than one global figure because the spread is real: a - /// `stat` that hasn't answered in five seconds is not going to, while a - /// `git status` on a cold large repo legitimately takes ten. A single - /// conservative timeout would make the fast paths feel broken; a single - /// aggressive one would break the slow paths. - /// - /// A timeout **never drops the connection**: the request fails with - /// `TimedOut`, a [`kind::CANCEL`] goes out, and every other in-flight - /// request is untouched. pub fn deadline(&self) -> Duration { use ControlRequest::*; match self { @@ -640,20 +245,9 @@ impl ControlRequest { CreateFileNew { .. } | CreateDir { .. } | Rename { .. } | Remove { .. } => { Duration::from_secs(10) } - // `GitStream`'s deadline covers only its *acceptance* — the reply is - // immediate and the data arrives as pushes, which have their own - // idle timeout (see `GIT_STREAM_IDLE_TIMEOUT`). Git { .. } | GitStream { .. } | Search { .. } => Duration::from_secs(20), - // Filesystem probes on Unix, but on Windows the WSL enumeration - // spawns `wsl.exe -l -q`, which is slow enough to deserve the same - // budget as git. Shells => Duration::from_secs(20), - // An attach is bookkeeping plus at most one push to a peer that may - // be wedged — the push is `try`-shaped on the server, so this only - // has to cover a slow link, not a slow client. WorkspaceAttach { .. } | WorkspaceDetach { .. } => Duration::from_secs(10), - // Tree operations are a locked mutation plus one small file write, - // so the budget covers a slow disk, not slow work. MachineGet | WorkspaceTree { .. } | WorkspaceCreate { .. } @@ -674,23 +268,15 @@ impl ControlRequest { } } - /// Whether this request carries bulk bytes, i.e. rides - /// [`kind::REQUEST_BLOB`] instead of [`kind::REQUEST`]. pub fn takes_blob(&self) -> bool { matches!(self, ControlRequest::WriteFile { .. }) } - /// Whether this request's *reply* carries bulk bytes. pub fn returns_blob(&self) -> bool { matches!(self, ControlRequest::ReadFile { .. }) } } -// --------------------------------------------------------------------------- -// Replies -// --------------------------------------------------------------------------- - -/// A reply to one request: the operation's value, or why it couldn't run. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub enum ControlReply { #[serde(rename = "ok")] @@ -700,7 +286,6 @@ pub enum ControlReply { } impl ControlReply { - /// Fold into the `io::Result` shape every `Host` method returns. pub fn into_result(self) -> io::Result<ReplyOk> { match self { ControlReply::Ok(v) => Ok(v), @@ -709,9 +294,6 @@ impl ControlReply { } } -/// The successful half of a reply. One variant per result *shape*, not per -/// request — several requests answer `Unit`, and `Stat` and `WriteFile` both -/// answer `Meta`. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ReplyOk { @@ -722,52 +304,24 @@ pub enum ReplyOk { Bool(bool), Path(String), OptPath(Option<String>), - /// `ReadFile`: the content is the frame's blob, this is only its metadata. - FileMeta { - meta: Meta, - }, + FileMeta { meta: Meta }, Hits(Vec<SearchHit>), Output(Output), WatchId(u64), - /// [`ControlRequest::Shells`]: what that machine can launch. Shells(ShellInventory), - /// [`ControlRequest::WorkspaceAttach`] succeeded. `took_over_from` names the - /// machine whose session was displaced, so the client that *did* the taking - /// can say so — only the notice going the other way is specified, - /// but a takeover the new client cannot see is one the user cannot explain. - Attached { - took_over_from: Option<String>, - }, - /// [`ControlRequest::MachineGet`]: the machine's whole tree. Boxed for the - /// same reason the tree replies below are: `ReplyOk` values live on the - /// dispatch stack, and the common replies must not pay for the big ones. + Attached { took_over_from: Option<String> }, MachineTree(Box<Machine>), - /// One workspace of the tree ([`ControlRequest::WorkspaceTree`] / - /// [`ControlRequest::WorkspaceCreate`]). WorkspaceTree(Box<crate::core::machine::Workspace>), - /// The tab an operation created ([`ControlRequest::TabCreate`]). TabTree(Box<Tab>), - /// Pane ids an operation removed from the tree, for the caller to kill. Panes(Vec<u64>), } -/// An operation that could not be performed. -/// -/// **A non-zero `git` exit is not this.** It is `Ok(Output { status: Some(1) })`. -/// That distinction is what lets `git_status`'s `Option<String>` semantics -/// survive the move behind the `Host` trait unchanged. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct WireError { pub kind: WireErrorKind, - /// Human-readable, shown to the user verbatim. Carries the path when a path - /// is the point, and nothing else about the server's internals. pub msg: String, } -/// The closed set of error classes that cross the wire. -/// -/// `io::ErrorKind` can't be used directly: it isn't `Serialize`, and it is -/// `#[non_exhaustive]`, so its variant set is not a stable wire contract. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum WireErrorKind { @@ -778,14 +332,10 @@ pub enum WireErrorKind { NotADirectory, IsADirectory, DirectoryNotEmpty, - /// Past the caller's `max_bytes`. FileTooLarge, - /// No git on the server, or it wouldn't start. GitUnavailable, TimedOut, - /// The control connection died. Synthesized by the client; never sent. ConnectionReset, - /// Anything else; `msg` carries the original description. Other, } @@ -797,8 +347,6 @@ impl WireError { } } - /// Classify an `io::Error` for the wire. The inverse of [`WireError::into_io`] - /// on every kind this table names. pub fn from_io(e: &io::Error) -> Self { use io::ErrorKind as K; let kind = match e.kind() { @@ -820,18 +368,12 @@ impl WireError { } } - /// Rebuild a local `io::Error`. `msg` becomes the error's payload, so a - /// notification shows what the server said rather than a generic class name. pub fn into_io(self) -> io::Error { io::Error::new(self.kind.to_io_kind(), self.msg) } } impl WireErrorKind { - /// The `io::ErrorKind` this class maps back to. - /// - /// `GitUnavailable` lands on `NotFound` — it means the binary isn't there — - /// and `Other` on `Other`, which is why the class is kept beside a `msg`. pub fn to_io_kind(self) -> io::ErrorKind { use io::ErrorKind as K; match self { @@ -850,59 +392,28 @@ impl WireErrorKind { } } -// --------------------------------------------------------------------------- -// Events -// --------------------------------------------------------------------------- - -/// An unsolicited server push, carried on [`kind::EVENT`] with `req_id == 0`. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ControlEvent { - /// Filesystem changes, coalesced and deduplicated by the server over a - /// [`WATCH_COALESCE_WINDOW`]. Watch { id: u64, paths: Vec<String>, }, - /// More than [`WATCH_BURST_CAP`] distinct paths changed in one window; - /// the client invalidates the subtree instead of applying a list. WatchOverflow { id: u64, }, - /// A batch of a [`ControlRequest::GitStream`]'s stdout, in order. - /// - /// Line data, newline-terminated, batched into frames of roughly - /// [`GIT_STREAM_CHUNK`] bytes — not a verbatim slice of git's stdout. The - /// server reads through [`crate::host::Host::git_lines`], so what crosses - /// the wire is what that yields: line *content* is preserved exactly (bar - /// the lossy UTF-8 replacement it already applies), while `\r\n` endings - /// and whether the last line carried a terminator are normalised away. The - /// only consumer is line-oriented, so that is the contract rather than an - /// accident — and framing per batch instead of per line is what keeps a - /// 90 000-line diff from becoming 90 000 frames. - /// - /// Base64 for the same reason [`crate::host::Output::stdout`] is: events - /// are encoded with `serde_json`, which has no byte type and renders one as - /// an array of decimal numbers, so a plain `Vec<u8>` would put roughly four - /// bytes on the wire per byte of diff — making the streaming path *more* - /// expensive than the buffered read it exists to replace, on exactly the - /// multi-megabyte diff it was added for. GitChunk { id: u64, #[serde(with = "crate::host::b64")] bytes: Vec<u8>, }, - /// The stream is over. `code` is git's exit status, `None` when it was - /// killed by a signal; `failed` reports a read error on the server side, - /// which is distinct from git exiting non-zero. GitEnd { id: u64, code: Option<i32>, failed: bool, }, - // ----- reserved for M5/M6; defined so the dialect doesn't need a bump --- PaneExited { pane_id: u64, code: Option<i32>, @@ -911,77 +422,27 @@ pub enum ControlEvent { pane_id: u64, json: serde_json::Value, }, - /// The takeover: someone else attached to `workspace`, so this - /// session no longer holds it. - /// - /// **`workspace` is not redundant.** One control connection carries a whole - /// machine and may hold several of its workspaces, so a push that named only - /// the new owner would leave the client unable to tell which of its windows - /// just went read-only. Preempted { workspace: String, by: String, }, - // `workspace_changed` was the retired record store's change notice; its - // serde name is burned along with the record verbs. - /// One incremental change to one tree workspace on this machine — the - /// push half of the machine-tree verbs. The writer never receives its own - /// operation back (origin exclusion, so an optimistically-applied edit is - /// not applied twice); every other client applies the delta to its live - /// window or, when it cannot, re-pulls the workspace with - /// [`ControlRequest::WorkspaceTree`]. - /// - /// `workspace` is the [`WorkspaceId`] rendered as a string, matching how - /// `Preempted` names its. Layout { workspace: String, delta: LayoutDelta, }, - /// The server dropped at least one [`Layout`](Self::Layout) push for this - /// connection (its per-connection delta queue overflowed — see - /// [`crate::host::server::LAYOUT_EVENT_QUEUE`]): the peer's mirrors are - /// now wrong in a way no later delta repairs. So the client re-pulls the - /// machine whole and resyncs its windows — the identical recovery a delta - /// that will not apply already triggers, just server-announced instead of - /// stumbled into. Connection-wide, because drops happen at the queue, not - /// per workspace; the watch dialect's `WatchOverflow` is the precedent. - /// - /// It arrives *instead of* the deltas the queue was still holding, not - /// ahead of them: those are older than the gap and already inside the tree - /// the client is about to pull, so delivering them after the pull would - /// walk the client backwards through history it has already left behind. LayoutResync, } -/// Where control events that are nobody's *local* business end up. -/// -/// [`RemoteHost`](crate::host::remote::RemoteHost) routes `Watch` and -/// `WatchOverflow` into the subscription that asked for them, because those -/// belong to a caller that is still holding a `WatchSub`. The rest — -/// `Preempted`, `PaneExited`, `AgentStatus`, `Layout` — are about a -/// *window*, and the host layer has no window. -/// -/// A process-wide observer rather than a parameter on `connect_with` because -/// the interested party (the GUI's connection state machine) is one thing, -/// while connections are made in three places that would each have to be taught -/// to thread it through. Last registration wins; `None` drops events, which is -/// what a headless `tty7-server` wants. pub type EventObserver = Arc<dyn Fn(crate::host::HostId, ControlEvent) + Send + Sync>; static EVENT_OBSERVER: Mutex<Option<EventObserver>> = Mutex::new(None); -/// Install the observer. Idempotent, last-call-wins; the GUI calls it at -/// startup. pub fn set_event_observer(f: EventObserver) { if let Ok(mut slot) = EVENT_OBSERVER.lock() { *slot = Some(f); } } -/// Hand an unrouted event to the observer, if there is one. -/// -/// **Runs on a reader thread**, so the observer must not block — the intended -/// shape is a mailbox push, exactly like the watch forwarders. pub fn observe_event(host: crate::host::HostId, event: ControlEvent) { let observer = match EVENT_OBSERVER.lock() { Ok(slot) => slot.clone(), @@ -993,26 +454,15 @@ pub fn observe_event(host: crate::host::HostId, event: ControlEvent) { } } -// --------------------------------------------------------------------------- -// Handshake -// --------------------------------------------------------------------------- - -/// The client's opening frame. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct ControlHello { - /// The control dialect the client speaks. See [`CONTROL_VERSION`]. pub control_version: u32, - /// The workspace this connection is bound to. `None` uses the connection - /// for host RPC only, which is how the stdio end-to-end test drives it. pub workspace: Option<String>, - /// Session token and hostname, used later to decide takeover between two - /// clients claiming the same workspace. pub client_token: String, pub client_hostname: String, } impl ControlHello { - /// A hello for a connection that only does host RPC. pub fn host_rpc(client_token: impl Into<String>, client_hostname: impl Into<String>) -> Self { ControlHello { control_version: CONTROL_VERSION, @@ -1023,58 +473,25 @@ impl ControlHello { } } -/// The server's answer. Sent **even when the versions don't match** — the -/// server then closes the connection, and this frame is the only way the client -/// learns which version it actually met (a `HELLO` has no `req_id`, so there is -/// no error reply to attach the mismatch to). #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct ControlHelloOk { pub control_version: u32, - /// The server's [`crate::daemon::protocol::PROTOCOL_VERSION`]. Redundant - /// with the control version; kept for diagnostics. pub protocol_version: u32, - /// The server binary's version string. Display only. pub build: String, - /// The server's path separator — this is what backs `Host::separator`, and - /// why a Windows client can hold POSIX paths correctly. pub separator: char, - /// The server's `$HOME`, so "new workspace" can default to `~` on the - /// remote rather than on the client. pub home: String, - /// Capability bits; see [`feature`]. #[serde(default)] pub features: Vec<String>, - /// Which *process* answered — see [`server_instance`]. - /// - /// The one field here that changes without anything else changing. `build` - /// is the same across a restart, and so are both version numbers, so before - /// this a client that came back to a machine had no way to tell "the link - /// blinked" from "the server is a different process now and every pane it - /// held is gone". That question decides whether a reconnect re-attaches or - /// rebuilds, and guessing it wrong either throws away live shells or leaves - /// dead ones on screen. - /// - /// `#[serde(default)]` for the same reason every other added field has it, - /// though nothing can currently send an empty one: control v2 is the floor - /// and this landed with it. An empty value therefore means *unknown*, never - /// "a server that restarted" — a client that cannot tell must not act as if - /// it could. #[serde(default)] pub instance: String, } impl ControlHelloOk { - /// Whether the server advertises `name`. pub fn has_feature(&self, name: &str) -> bool { self.features.iter().any(|f| f == name) } } -// --------------------------------------------------------------------------- -// Framing -// --------------------------------------------------------------------------- - -/// `u64 req_id` + `u32 json_len`. const CONTROL_HEADER: usize = 12; fn to_json<T: Serialize>(value: &T) -> io::Result<Vec<u8>> { @@ -1089,7 +506,6 @@ fn invalid(msg: impl Into<String>) -> io::Error { io::Error::new(io::ErrorKind::InvalidData, msg.into()) } -/// Build a `[req_id][json_len][JSON][blob]` payload. fn encode_body(req_id: u64, json: &[u8], blob: &[u8]) -> io::Result<Vec<u8>> { let json_n = u32::try_from(json.len()) .map_err(|_| invalid("control JSON exceeds the u32 length prefix"))?; @@ -1101,11 +517,6 @@ fn encode_body(req_id: u64, json: &[u8], blob: &[u8]) -> io::Result<Vec<u8>> { Ok(out) } -/// Split a payload into `(req_id, json, blob)`. -/// -/// Every bound is checked before a slice is taken: a hostile `json_len` must -/// produce an `InvalidData` error, never a panic and never a read past the -/// payload. fn decode_body(payload: &[u8]) -> io::Result<(u64, &[u8], &[u8])> { if payload.len() < CONTROL_HEADER { return Err(invalid(format!( @@ -1131,7 +542,6 @@ fn decode_body(payload: &[u8]) -> io::Result<(u64, &[u8], &[u8])> { )) } -/// Decode a payload that must not have a trailing blob (kinds 61 and 63). fn decode_body_exact<'a>(payload: &'a [u8], what: &str) -> io::Result<(u64, &'a [u8])> { let (req_id, json, blob) = decode_body(payload)?; if !blob.is_empty() { @@ -1143,8 +553,6 @@ fn decode_body_exact<'a>(payload: &'a [u8], what: &str) -> io::Result<(u64, &'a Ok((req_id, json)) } -/// A request id of 0 is reserved for server pushes and is never valid on a -/// request, cancel or reply. fn require_nonzero(req_id: u64, what: &str) -> io::Result<()> { if req_id == 0 { return Err(invalid(format!( @@ -1154,11 +562,6 @@ fn require_nonzero(req_id: u64, what: &str) -> io::Result<()> { Ok(()) } -// --------------------------------------------------------------------------- -// Messages -// --------------------------------------------------------------------------- - -/// A control frame travelling client → server. #[derive(Clone, Debug, PartialEq)] pub enum ControlClientMsg { Hello(ControlHello), @@ -1166,7 +569,6 @@ pub enum ControlClientMsg { req_id: u64, req: ControlRequest, }, - /// A request with bulk bytes; the JSON head still carries the parameters. RequestBlob { req_id: u64, req: ControlRequest, @@ -1178,19 +580,11 @@ pub enum ControlClientMsg { } impl ControlClientMsg { - /// Encode and write this message as one frame. pub fn encode<W: Write>(&self, w: &mut W) -> io::Result<()> { let (k, payload) = self.to_frame()?; write_frame(w, k, &payload) } - /// The frame this message *would* write, without writing it. - /// - /// Split out so a caller can tell "this message cannot be encoded" from - /// "the link failed". Everything fallible here happens with the wire - /// untouched, and the difference matters: marking the connection dead over - /// a local serialization failure puts the workspace into `Reconnecting` - /// over a request the server never saw. pub fn to_frame(&self) -> io::Result<(u8, Vec<u8>)> { let (k, payload) = match self { ControlClientMsg::Hello(hello) => (kind::HELLO, to_json(hello)?), @@ -1210,9 +604,6 @@ impl ControlClientMsg { (kind::CANCEL, encode_body(*req_id, &[], &[])?) } }; - // See `ControlServerMsg::to_frame`: reaching the verdict here rather - // than inside `write_frame` is what keeps "too big" a failure with the - // wire untouched. if payload.len() > MAX_FRAME { return Err(io::Error::new( io::ErrorKind::InvalidData, @@ -1225,7 +616,6 @@ impl ControlClientMsg { Ok((k, payload)) } - /// Decode one already-read frame. pub fn from_frame(k: u8, payload: Vec<u8>) -> io::Result<Self> { match k { kind::HELLO => Ok(ControlClientMsg::Hello(from_json(&payload)?)), @@ -1258,14 +648,12 @@ impl ControlClientMsg { } } - /// Read and decode the next client message from `r`. pub fn read<R: Read>(r: &mut R) -> io::Result<Self> { let (k, payload) = read_frame(r)?; Self::from_frame(k, payload) } } -/// A control frame travelling server → client. #[derive(Clone, Debug, PartialEq)] pub enum ControlServerMsg { HelloOk(ControlHelloOk), @@ -1273,31 +661,20 @@ pub enum ControlServerMsg { req_id: u64, reply: ControlReply, }, - /// A reply with bulk bytes; the JSON head carries the metadata. ResponseBlob { req_id: u64, reply: ControlReply, blob: Vec<u8>, }, - /// A push. Its `req_id` is always 0 on the wire. Event(ControlEvent), } impl ControlServerMsg { - /// Encode and write this message as one frame. pub fn encode<W: Write>(&self, w: &mut W) -> io::Result<()> { let (k, payload) = self.to_frame()?; write_frame(w, k, &payload) } - /// The frame this message *would* write, without writing it. - /// - /// The server's half of the same split as - /// [`ControlClientMsg::to_frame`], and it earns its keep in the same way: - /// a reply that cannot be encoded — a `SearchHit` path that is not UTF-8, a - /// payload past [`MAX_FRAME`] — leaves the wire untouched, so the server can - /// still answer the request with the error instead of dropping the reply and - /// leaving the client to wait out its deadline. pub fn to_frame(&self) -> io::Result<(u8, Vec<u8>)> { let (k, payload) = match self { ControlServerMsg::HelloOk(ok) => (kind::HELLO_OK, to_json(ok)?), @@ -1318,9 +695,6 @@ impl ControlServerMsg { } ControlServerMsg::Event(event) => (kind::EVENT, encode_body(0, &to_json(event)?, &[])?), }; - // Checked here rather than left to `write_frame`, so that "too big to - // send" is a verdict reached with nothing written — which is what lets - // the caller answer with an error instead of going silent. if payload.len() > MAX_FRAME { return Err(io::Error::new( io::ErrorKind::InvalidData, @@ -1333,7 +707,6 @@ impl ControlServerMsg { Ok((k, payload)) } - /// Decode one already-read frame. pub fn from_frame(k: u8, payload: Vec<u8>) -> io::Result<Self> { match k { kind::HELLO_OK => Ok(ControlServerMsg::HelloOk(from_json(&payload)?)), @@ -1367,69 +740,27 @@ impl ControlServerMsg { } } - /// Read and decode the next server message from `r`. pub fn read<R: Read>(r: &mut R) -> io::Result<Self> { let (k, payload) = read_frame(r)?; Self::from_frame(k, payload) } } -// --------------------------------------------------------------------------- -// Client -// --------------------------------------------------------------------------- - -/// How often the client pings, and only when the link has gone quiet — a busy -/// connection proves itself. pub const KEEPALIVE_PING_INTERVAL: Duration = Duration::from_secs(15); -/// Silence that has to elapse before a ping is worth sending. pub const KEEPALIVE_IDLE_BEFORE_PING: Duration = Duration::from_secs(30); -/// Silence after which the link counts as dead and the workspace goes to -/// `Reconnecting`. Three ping intervals: two may be lost without a false -/// positive. pub const KEEPALIVE_DEAD_AFTER: Duration = Duration::from_secs(45); -/// How long [`ControlClient::close`] waits for its reader thread before -/// detaching it. -/// -/// Generous enough that a reader woken by a real shutdown is always reaped -/// (that takes microseconds), short enough that a client built without a -/// [`LinkShutdown`] still closes promptly instead of hanging its caller. pub const CLOSE_GRACE: Duration = Duration::from_millis(500); -/// A reply as the caller receives it: the value, plus the blob if the frame -/// carried one. #[derive(Clone, Debug, PartialEq)] pub struct ControlResponse { pub reply: ReplyOk, pub blob: Vec<u8>, } -/// Where server pushes go. Called on the reader thread, so it must not block — -/// the intended shape is a channel send. pub type EventSink = Box<dyn Fn(ControlEvent) + Send + Sync + 'static>; -/// Whatever can force a parked reader out of a blocking `read`. -/// -/// **This is not optional politeness; without it a client cannot be closed.** -/// The reader thread spends its whole life inside `read_frame`, which blocks -/// until the peer sends something. Setting a "we're closed now" flag does not -/// wake it, because nothing is looking at the flag — the thread is inside a -/// syscall. And the peer has no reason to send anything: it is waiting for the -/// next request. Both ends wait for the other forever. -/// -/// So closing has to act on the file descriptor itself. Every transport has -/// *some* way to do that, but they share no trait in std — a socket has -/// `shutdown`, a child process has `kill`, an SSH channel has `close` — hence -/// this one-method abstraction rather than a bound on the stream type. -/// -/// **Note for the server side** (the `Duplex`): the same problem -/// exists there in mirror image, so `Duplex` will want the same capability. -/// Aligning is a matter of `Duplex` either requiring `LinkShutdown` as a -/// supertrait or exposing an equivalent method; this trait is deliberately -/// minimal so it can be adopted rather than duplicated. pub trait LinkShutdown: Send + Sync + 'static { - /// Force the read half to return. Called at most once, and may be called - /// while the reader is blocked inside `read`. fn shutdown_link(&self) -> io::Result<()>; } @@ -1446,19 +777,6 @@ impl LinkShutdown for std::os::unix::net::UnixStream { } } -/// The client half of a control connection: one writer, one reader thread, and -/// a table of outstanding requests keyed by id. -/// -/// **Out-of-order matching is the whole point.** Every call blocks its own -/// caller and nobody else's: a twenty-second `git` and a five-millisecond -/// `read_dir` issued in that order return in the opposite order, because the -/// reader claims each reply from the table by id rather than assuming replies -/// arrive as requests were sent. Without that, expanding a directory in the -/// file tree would queue behind whatever slow thing the status bar last asked -/// for. -/// -/// Cloneable and `Sync`: `Arc<ControlClient>` is the intended way to share it, -/// and concurrent `call`s from many threads are the normal case. pub struct ControlClient { inner: Arc<ClientInner>, reader: Mutex<Option<std::thread::JoinHandle<()>>>, @@ -1468,32 +786,17 @@ struct ClientInner { writer: Mutex<Box<dyn Write + Send>>, next_req_id: AtomicU64, pending: Mutex<HashMap<u64, SyncSender<ControlReply>>>, - /// Blobs arrive on the same frame as their reply, so they ride a side table - /// keyed by the same id rather than widening the channel's item type. blobs: Mutex<HashMap<u64, Vec<u8>>>, connected: AtomicBool, last_inbound: Mutex<Instant>, hello: ControlHelloOk, - /// How to force the reader out of its blocking read. `None` when the caller - /// supplied raw halves with no way to close them — then [`ControlClient::close`] - /// detaches the reader instead of waiting for it. shutdown: Option<Arc<dyn LinkShutdown>>, - /// Set by the reader as it exits, so `close` can wait for it *bounded* - /// rather than joining a thread that may never return. reader_done: Mutex<bool>, reader_exit: Condvar, - /// Run when the link is declared dead — see [`ControlClient::on_link_down`]. link_down: Mutex<Vec<Box<dyn Fn() + Send + Sync>>>, } impl ControlClient { - /// Perform the handshake over an already-connected stream, then start the - /// reader thread. - /// - /// `r` and `w` are the two halves of one duplex link — a `try_clone`d - /// socket, or a child process's stdout and stdin. Taking them separately - /// rather than behind a trait keeps this usable for both without this - /// module having an opinion about how a stream splits. pub fn connect<R, W>( r: R, w: W, @@ -1507,12 +810,6 @@ impl ControlClient { Self::connect_with(r, w, None, hello, events) } - /// [`ControlClient::connect`] over a TCP socket, with shutdown wired up. - /// - /// Prefer this to `connect` whenever the transport *has* a shutdown: a - /// client built without one cannot wake its own reader, so closing it costs - /// a [`CLOSE_GRACE`] wait and leaks the reader thread until the peer - /// happens to hang up. pub fn over_tcp( sock: std::net::TcpStream, hello: &ControlHello, @@ -1523,8 +820,6 @@ impl ControlClient { Self::connect_with(r, sock, Some(closer), hello, events) } - /// [`ControlClient::connect`] over a Unix-domain socket, with shutdown - /// wired up. #[cfg(unix)] pub fn over_unix( sock: std::os::unix::net::UnixStream, @@ -1536,9 +831,6 @@ impl ControlClient { Self::connect_with(r, sock, Some(closer), hello, events) } - /// The full form: `shutdown` is what [`ControlClient::close`] uses to force - /// the reader out of its blocking read. See [`LinkShutdown`] for why a flag - /// alone cannot do it. pub fn connect_with<R, W>( mut r: R, mut w: W, @@ -1562,9 +854,6 @@ impl ControlClient { } }; if ok.control_version != hello.control_version { - // The peer has already closed by now; the frame existed purely so - // this message can name both versions instead of saying "the - // connection dropped". return Err(io::Error::new( io::ErrorKind::Unsupported, dialect_refusal(&ok.build, ok.control_version, hello.control_version), @@ -1596,52 +885,23 @@ impl ControlClient { }) } - /// What the peer said about itself. pub fn hello(&self) -> &ControlHelloOk { &self.inner.hello } - /// Whether the link is still up. Once false it never returns to true — a - /// reconnect builds a new `ControlClient`. pub fn is_connected(&self) -> bool { self.inner.connected.load(Ordering::Acquire) } - /// Register work to run when the link is declared dead. - /// - /// `call` needs nothing of the sort — it waits on a slot in `pending`, and - /// `fail_all` empties that. But a caller parked on a *push* is invisible - /// there: [`ControlRequest::GitStream`]'s reply arrives long before its - /// data does, so the thread draining the chunks is waiting on a channel - /// this module has never heard of. Without a hook that channel is never - /// closed and the thread parks until its idle timeout expires, holding - /// whatever the caller believed was merely in flight. - /// - /// [`GIT_STREAM_IDLE_TIMEOUT`] does not make this redundant, and neither - /// makes the other so: this fires the instant the link is known to be gone, - /// where the timeout would sit out its full wait for an answer that is - /// already impossible. The timeout covers the case a hook cannot see at all - /// — a link that stays up while the far side stops producing. - /// - /// A hook may run more than once (`close` and the reader's own teardown - /// both declare the link dead) and must therefore be idempotent, and it - /// must not call back into this client — it runs while the hook table is - /// held, on whichever thread noticed the link go. pub fn on_link_down<F: Fn() + Send + Sync + 'static>(&self, hook: F) { if let Ok(mut hooks) = self.inner.link_down.lock() { hooks.push(Box::new(hook)); } - // The link can die between the handshake and this call — a connection - // refused mid-registration would otherwise leave the hook armed for an - // event that has already passed. if !self.is_connected() { self.inner.run_link_down(); } } - /// How long the link has been silent. The caller's keepalive policy reads - /// this against [`KEEPALIVE_IDLE_BEFORE_PING`] and - /// [`KEEPALIVE_DEAD_AFTER`]. pub fn idle_for(&self) -> Duration { self.inner .last_inbound @@ -1650,29 +910,19 @@ impl ControlClient { .unwrap_or_default() } - /// Issue a request and block until its reply, `deadline` elapses, or the - /// link drops. - /// - /// Blocking is deliberate: `Host` is a blocking, object-safe - /// trait, and every caller is already on a background thread. Blocking here - /// blocks exactly one of them. pub fn call(&self, req: ControlRequest) -> io::Result<ReplyOk> { self.call_full(req, &[]).map(|r| r.reply) } - /// [`ControlClient::call`] with bulk bytes attached to the request. pub fn call_with_blob(&self, req: ControlRequest, blob: &[u8]) -> io::Result<ReplyOk> { self.call_full(req, blob).map(|r| r.reply) } - /// [`ControlClient::call`], keeping the reply's blob. pub fn call_full(&self, req: ControlRequest, blob: &[u8]) -> io::Result<ControlResponse> { let deadline = req.deadline(); self.call_with_deadline(req, blob, deadline) } - /// The full form, for callers that want a deadline other than the method's - /// default (the conformance suite's tighter bounds, mainly). pub fn call_with_deadline( &self, req: ControlRequest, @@ -1687,14 +937,7 @@ impl ControlClient { } let req_id = self.inner.next_req_id.fetch_add(1, Ordering::Relaxed); - // Every request the client makes, named. A remote workspace's link is - // invisible from the outside — the traffic is inside an SSH channel — - // so without this the only evidence of *who* is talking is packet - // lengths in russh's own trace, which is not evidence. Cheap: `Off` by - // default, and the format cost only runs when it isn't. log::debug!(target: "tty7::control", "#{req_id} {req:?}"); - // Capacity 1: the reader must never block handing off a reply, and - // there is only ever one reply per id. let (tx, rx) = sync_channel(1); self.inner.pending()?.insert(req_id, tx); @@ -1721,8 +964,6 @@ impl ControlClient { .map(|reply| ControlResponse { reply, blob }) } Err(RecvTimeoutError::Timeout) => { - // Drop the slot first: a late reply then finds no entry and is - // discarded, per the unknown-id rule. self.inner.forget(req_id); let _ = self.inner.send(&ControlClientMsg::Cancel { req_id }); Err(io::Error::new( @@ -1730,7 +971,6 @@ impl ControlClient { format!("control request {req_id} timed out after {deadline:?}"), )) } - // The sender is gone: the reader shut down and cleared the table. Err(RecvTimeoutError::Disconnected) => { self.inner.forget(req_id); Err(io::Error::new( @@ -1741,35 +981,16 @@ impl ControlClient { } } - /// Send a keepalive `Ping`, ignoring the answer's content. pub fn ping(&self) -> io::Result<()> { self.call(ControlRequest::Ping).map(|_| ()) } - /// Tear the link down: fail every waiter, force the reader out of its - /// blocking read, and reap it. - /// - /// The order matters and so does the bound. Failing the waiters first means - /// nobody is left holding a deadline against a socket that is about to go - /// away. Shutting the link down is what actually wakes the reader — - /// `fail_all` only touches this side's bookkeeping, and a reader parked in - /// `read_frame` is inside a syscall where no flag can reach it. - /// - /// The join is bounded by [`CLOSE_GRACE`] because it must be. When no - /// [`LinkShutdown`] was supplied there is nothing that *can* wake the - /// reader, and an unbounded join would hang whichever thread dropped the - /// client — in practice the UI thread closing a remote workspace. A - /// detached reader thread is a far smaller problem than a frozen window: it - /// exits on its own as soon as the peer hangs up, and it holds nothing but - /// an `Arc` whose waiters have all been failed already. pub fn close(&self) { self.inner .fail_all("control connection closed by this client"); if let Some(closer) = &self.inner.shutdown && let Err(e) = closer.shutdown_link() { - // Already closed, or never opened. Either way the reader is on its - // way out and there is nothing better to do. log::trace!("control link shutdown reported {e}"); } @@ -1788,7 +1009,6 @@ impl ControlClient { Err(_) => false, }; if reaped { - // The reader has already returned, so this join is immediate. let _ = handle.join(); } else { log::debug!( @@ -1806,10 +1026,6 @@ impl Drop for ControlClient { } } -/// Hand-written because the writer is a trait object and the pending table -/// holds channel senders — neither of which can derive it, and neither of which -/// is what a reader of a log line wants anyway. What matters is who the peer is -/// and whether the link is alive. impl std::fmt::Debug for ControlClient { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let in_flight = self.inner.pending.lock().map(|p| p.len()).unwrap_or(0); @@ -1832,12 +1048,6 @@ impl ClientInner { } fn send(&self, msg: &ControlClientMsg) -> io::Result<()> { - // Encoded before the writer is even locked, and before anything is - // concluded about the link. A message that cannot be serialized, or a - // payload past `MAX_FRAME`, fails with the wire untouched — the server - // never saw the request, so the connection is exactly as healthy as it - // was and must not be marked dead. Only a failure *after* bytes could - // have gone out leaves the stream in a state worth giving up on. let (k, payload) = msg.to_frame()?; let mut w = self @@ -1868,9 +1078,6 @@ impl ClientInner { .unwrap_or_default() } - /// Deliver a reply to whoever is waiting on `req_id`. A reply for an id - /// nobody is waiting on is dropped without complaint: it is the expected - /// tail of a request that already timed out. fn deliver(&self, req_id: u64, reply: ControlReply, blob: Vec<u8>) { let Ok(mut pending) = self.pending.lock() else { return; @@ -1879,29 +1086,15 @@ impl ClientInner { log::trace!("control reply for unknown req_id {req_id}; dropping"); return; }; - // Under the `pending` lock, which is the one `forget` takes first. - // Inserting after releasing it loses the race with a caller that times - // out in the gap: its `forget` clears a `blobs` entry that does not - // exist yet, this insert lands with nobody left to take it, and a whole - // file's contents stay in the map for the life of the connection. if !blob.is_empty() && let Ok(mut blobs) = self.blobs.lock() { blobs.insert(req_id, blob); } drop(pending); - // Capacity 1 and one reply per id, so this cannot block; it can only - // fail if the caller already gave up between the table lookup and here. let _ = tx.try_send(reply); } - /// Fail every outstanding request. Called when the link dies, so callers - /// get `ConnectionReset` immediately instead of each waiting out its own - /// deadline. - /// - /// Waiters on a *push* rather than a reply are not in `pending` and cannot - /// be woken from here, so the hooks run too — see - /// [`ControlClient::on_link_down`]. fn fail_all(&self, why: &str) { self.connected.store(false, Ordering::Release); let drained: Vec<_> = match self.pending.lock() { @@ -1917,8 +1110,6 @@ impl ClientInner { self.run_link_down(); } - /// Run every registered link-down hook. Idempotent by contract, since both - /// `close` and the reader's own teardown reach `fail_all`. fn run_link_down(&self) { if let Ok(hooks) = self.link_down.lock() { for hook in hooks.iter() { @@ -1930,7 +1121,6 @@ impl ClientInner { fn reader_loop<R: Read>(inner: Arc<ClientInner>, r: R, events: EventSink) { read_until_closed(&inner, r, events); - // Whatever ended the loop, `close` is entitled to stop waiting. if let Ok(mut done) = inner.reader_done.lock() { *done = true; } @@ -1963,14 +1153,11 @@ fn read_until_closed<R: Read>(inner: &Arc<ClientInner>, mut r: R, events: EventS } Ok(ControlServerMsg::Event(event)) => events(event), Ok(ControlServerMsg::HelloOk(_)) => { - // A second handshake mid-stream is a desync, not a greeting. log::warn!("control peer sent a second HELLO_OK; dropping the connection"); inner.fail_all("control peer re-sent its handshake"); return; } Err(e) => { - // A frame we cannot parse means the stream position is no - // longer trustworthy — same verdict the pane protocol reaches. log::warn!("control decode error: {e}"); inner.fail_all("control stream desynchronized"); return; @@ -1999,8 +1186,6 @@ mod tests { } } - /// A bare `ClientInner` with no link behind it — enough for the side-table - /// bookkeeping, which is all `deliver`/`forget` touch. fn detached_inner() -> Arc<ClientInner> { Arc::new(ClientInner { writer: Mutex::new(Box::new(Cursor::new(Vec::new()))), @@ -2025,9 +1210,6 @@ mod tests { }) } - /// A path its own platform accepts but `str` cannot hold — Latin-1 bytes on - /// Unix, an unpaired surrogate on Windows. Both are real filenames, and - /// neither survives a JSON wire. fn unrepresentable_path() -> PathBuf { #[cfg(unix)] { @@ -2037,23 +1219,14 @@ mod tests { #[cfg(windows)] { use std::os::windows::ffi::OsStringExt as _; - // `C:\` then a lone high surrogate: legal in an NTFS name, not - // convertible to a `str`. PathBuf::from(std::ffi::OsString::from_wide(&[ 0x0043, 0x003a, 0x005c, 0xd800, 0x002e, 0x0072, 0x0073, ])) } } - /// `to_frame` reaches every "cannot be sent" verdict with the wire - /// untouched. That is what lets the server answer such a reply with an - /// error instead of dropping it, and the client keep its link on a local - /// encode failure — both of which are silent hangs otherwise. #[test] fn to_frame_refuses_what_cannot_be_sent_without_writing_it() { - // A path the platform accepts and `str` cannot hold. `serde` errors on - // such a `Path` rather than converting it lossily, which is the shape a - // `SearchHit` takes when the server has one under the search roots. let unrepresentable = ControlServerMsg::Response { req_id: 1, reply: ControlReply::Ok(ReplyOk::Hits(vec![SearchHit { @@ -2068,8 +1241,6 @@ mod tests { "a path that is not valid Unicode must not encode" ); - // And the size verdict, which `write_frame` would otherwise reach only - // after the writer was locked. let huge = ControlServerMsg::ResponseBlob { req_id: 1, reply: ControlReply::Ok(ReplyOk::Meta(meta())), @@ -2081,11 +1252,6 @@ mod tests { assert_eq!(err.kind(), io::ErrorKind::InvalidData); } - /// A message that cannot be put on the wire is not a dead link. `send` - /// fails before the writer is even locked, so the server never saw the - /// request and the connection is exactly as healthy as it was — marking it - /// dead would drop the workspace into `Reconnecting` and, since - /// `is_connected` never returns to true, keep it there. #[test] fn a_frame_too_large_to_send_does_not_condemn_the_link() { let inner = detached_inner(); @@ -2106,19 +1272,9 @@ mod tests { "a local encode failure marked the connection dead" ); - // And the link still works for a message that does fit. inner.send(&ControlClientMsg::Cancel { req_id: 1 }).unwrap(); } - /// A caller giving up at the same instant its reply lands must not leave - /// the blob behind. `deliver` and `forget` both touch two tables, and if - /// the blob goes in after the `pending` lock is released, `forget` clears - /// an entry that does not exist yet and the bytes — a whole file, up to the - /// editor's limit — are retained for the life of the connection. - /// - /// Hammered rather than interleaved by hand: there is no seam to inject at, - /// so this leans on repetition to hit the window. It fails intermittently - /// against the insert-after-unlock ordering and never against the fix. #[test] fn a_blob_racing_its_callers_timeout_is_not_retained() { for req_id in 1..2_000u64 { @@ -2307,10 +1463,6 @@ mod tests { } } - /// One id for the whole process. A per-connection id would make every - /// reconnect look like a restart, which is the failure this whole mechanism - /// exists to avoid — in the *expensive* direction, since the client answers - /// a restart by rebuilding the window. #[test] fn the_server_instance_is_one_value_per_process() { let first = server_instance(); @@ -2318,8 +1470,6 @@ mod tests { assert_eq!(first, server_instance()); } - /// A client on an older v2 build decodes a hello that has no `instance` as - /// "unknown" rather than failing the whole handshake. #[test] fn a_hello_without_an_instance_still_decodes() { let json = r#"{"control_version":2,"protocol_version":3,"build":"26.7.6", @@ -2341,8 +1491,6 @@ mod tests { } } - /// Every client-direction message survives encode → read, including one - /// `Request` per `ControlRequest` variant. #[test] fn client_messages_round_trip() { let mut msgs = vec![ @@ -2380,9 +1528,6 @@ mod tests { } } - /// Every server-direction message survives encode → read, including one - /// `Response` per `ControlReply` variant and one `Event` per - /// `ControlEvent`. #[test] fn server_messages_round_trip() { let mut msgs = vec![ @@ -2416,10 +1561,6 @@ mod tests { } } - /// The byte layout is the contract's, checked by hand rather than by - /// round-tripping through our own decoder — a symmetric bug in both halves - /// would pass every round-trip test and still be unreadable to the other - /// implementation. #[test] fn frame_layout_matches_the_contract() { let mut buf = Vec::new(); @@ -2431,7 +1572,6 @@ mod tests { .encode(&mut buf) .unwrap(); - // Outer frame: [u32 LE payload_len][u8 kind][payload] let payload_len = u32::from_le_bytes(buf[..4].try_into().unwrap()) as usize; assert_eq!(buf[4], 62, "REQUEST_BLOB is kind 62"); assert_eq!( @@ -2460,8 +1600,6 @@ mod tests { ); } - /// A non-blob frame's `payload_len` is exactly `12 + json_n`, with nothing - /// trailing. #[test] fn non_blob_frames_have_no_tail() { let mut buf = Vec::new(); @@ -2476,8 +1614,6 @@ mod tests { assert_eq!(payload_len, 12 + json_n); } - /// `CONTROL_CANCEL` is a bare `[req_id][json_len = 0]` — twelve bytes, no - /// JSON at all. #[test] fn cancel_is_twelve_bytes_with_an_empty_json_head() { let mut buf = Vec::new(); @@ -2491,15 +1627,6 @@ mod tests { assert_eq!(buf.len(), 17); } - /// A git chunk's bytes cross the wire as base64, not as a JSON array of - /// decimal numbers. - /// - /// The difference is roughly four wire bytes per byte of diff, on the one - /// path whose entire reason for existing is a multi-megabyte read — and it - /// is invisible in a plain round-trip assertion, because the array form - /// decodes back perfectly well. So the size is asserted too: 8 KiB of - /// printable text cannot fit in under twice its length as decimals and - /// commas, and does fit comfortably as base64. #[test] fn a_git_chunk_crosses_the_wire_as_base64() { let bytes: Vec<u8> = (0..8192u32).map(|i| b'a' + (i % 26) as u8).collect(); @@ -2526,11 +1653,6 @@ mod tests { } } - /// The chunk ceiling leaves room for what encoding costs. A chunk capped at - /// [`GIT_STREAM_CHUNK_MAX`] grows by base64's four-thirds before the JSON - /// envelope goes round it, so a ceiling set anywhere near [`MAX_FRAME`] - /// would produce chunks that cannot be sent at all — the failure this cap - /// exists to make impossible. #[test] fn the_git_chunk_ceiling_leaves_room_for_base64_and_the_envelope() { let encoded = GIT_STREAM_CHUNK_MAX / 3 * 4 + 4; @@ -2546,8 +1668,6 @@ mod tests { }; } - /// An event's `req_id` is on the wire and is zero — the eight redundant - /// bytes that let one header parser serve every non-`HELLO` kind. #[test] fn events_carry_a_zero_req_id() { let mut buf = Vec::new(); @@ -2558,10 +1678,6 @@ mod tests { assert_eq!(&buf[5..13], &0u64.to_le_bytes()); } - // ---- hostile / malformed input ----------------------------------------- - - /// A frame shorter than the 12-byte control header errors instead of - /// slicing out of bounds. #[test] fn short_payload_is_invalid_data() { for len in 0..CONTROL_HEADER { @@ -2577,16 +1693,13 @@ mod tests { } } - /// A `json_len` that runs past the payload — including `u32::MAX` — errors - /// rather than panicking on the slice. This is the one field a hostile peer - /// controls that indexes memory. #[test] fn oversized_json_len_is_invalid_data_not_a_panic() { for json_n in [13u32, 1_000_000, u32::MAX] { let mut payload = Vec::new(); payload.extend_from_slice(&1u64.to_le_bytes()); payload.extend_from_slice(&json_n.to_le_bytes()); - payload.extend_from_slice(b"{}"); // only 2 bytes actually present + payload.extend_from_slice(b"{}"); let e = ControlClientMsg::from_frame(kind::REQUEST, payload.clone()).unwrap_err(); assert_eq!(e.kind(), io::ErrorKind::InvalidData, "json_n {json_n}"); let e = ControlServerMsg::from_frame(kind::RESPONSE_BLOB, payload).unwrap_err(); @@ -2594,8 +1707,6 @@ mod tests { } } - /// A non-blob kind carrying trailing bytes is a desync, not something to - /// tolerate: `12 + json_n == payload_len` is required for 61 and 63. #[test] fn trailing_bytes_on_a_non_blob_kind_are_invalid_data() { let json = serde_json::to_vec(&ControlRequest::Ping).unwrap(); @@ -2613,9 +1724,6 @@ mod tests { assert_eq!(e.kind(), io::ErrorKind::InvalidData); } - /// An event with a non-zero `req_id` is a protocol error — a push has no - /// request to belong to, and letting it through would let a server steal a - /// pending reply's slot. #[test] fn event_with_a_nonzero_req_id_is_invalid_data() { let json = serde_json::to_vec(&ControlEvent::WatchOverflow { id: 1 }).unwrap(); @@ -2624,8 +1732,6 @@ mod tests { assert_eq!(e.kind(), io::ErrorKind::InvalidData); } - /// Conversely, req_id 0 on anything that is *not* an event is a protocol - /// error: 0 belongs to pushes. #[test] fn zero_req_id_is_rejected_on_requests_and_responses() { let json = serde_json::to_vec(&ControlRequest::Ping).unwrap(); @@ -2644,7 +1750,6 @@ mod tests { assert_eq!(e.kind(), io::ErrorKind::InvalidData, "kind {k}"); } - // And the encoder refuses to mint one in the first place. let mut buf = Vec::new(); assert!( ControlClientMsg::Request { @@ -2656,9 +1761,6 @@ mod tests { ); } - /// Unknown kinds are errors, not skips — the same verdict the pane protocol - /// reaches, and the reason control chose 60-63 rather than reusing the - /// retired 13. #[test] fn unknown_kinds_are_invalid_data() { for k in [0u8, 1, 13, 40, 50, 59, 64, 69, 200, 255] { @@ -2669,8 +1771,6 @@ mod tests { } } - /// Control's kinds do not collide with anything the pane protocol uses or - /// has reserved, and never touch the retired 13. #[test] fn control_kinds_sit_in_their_own_range() { let ours = [ @@ -2687,7 +1787,6 @@ mod tests { assert!((60..=63).contains(&k), "control kind {k} left its range"); assert_ne!(k, 13, "13 is retired and must never be reused"); } - // Every kind the pane protocol uses or reserves, per protocol.rs. let taken: Vec<u8> = (1..=24).chain(30..=36).chain([40, 50]).collect(); for k in ours { assert!( @@ -2697,7 +1796,6 @@ mod tests { } } - /// Malformed JSON inside a well-formed frame is `InvalidData`, not a panic. #[test] fn malformed_json_is_invalid_data() { let payload = encode_body(1, b"{not json", &[]).unwrap(); @@ -2708,8 +1806,6 @@ mod tests { assert_eq!(e.kind(), io::ErrorKind::InvalidData); } - /// A frame past `MAX_FRAME` is refused at encode time rather than shipped - /// for the peer to refuse — the writer is where the allocation already is. #[test] fn oversize_blob_is_refused_at_encode() { let blob = vec![0u8; MAX_FRAME]; @@ -2726,8 +1822,6 @@ mod tests { assert_eq!(e.kind(), io::ErrorKind::InvalidData); } - /// And a *claimed* length past `MAX_FRAME` is refused at decode without - /// allocating it. #[test] fn oversize_declared_length_is_refused_at_decode() { let mut buf = Vec::new(); @@ -2737,8 +1831,6 @@ mod tests { assert_eq!(e.kind(), io::ErrorKind::InvalidData); } - /// A frame exactly at `MAX_FRAME` is legal, so the boundary is inclusive on - /// both sides and a 64 MiB read isn't silently one byte short. #[test] fn a_frame_exactly_at_max_frame_survives() { let json = serde_json::to_vec(&ControlRequest::WriteFile { @@ -2762,12 +1854,6 @@ mod tests { assert_eq!(ControlClientMsg::read(&mut Cursor::new(&buf)).unwrap(), msg); } - // ---- error mapping ------------------------------------------------------ - - /// The `io::ErrorKind` ↔ `WireErrorKind` tables are inverses on every kind - /// they name. If they are not, a `NotFound` from the server becomes an - /// `Other` on the client and every "file is missing" path in the GUI stops - /// recognizing itself. #[test] fn error_kinds_round_trip_through_io() { use io::ErrorKind as K; @@ -2790,7 +1876,6 @@ mod tests { } } - /// The kinds that collapse on the way out stay collapsed, deliberately. #[test] fn error_kinds_that_collapse_do_so_predictably() { use io::ErrorKind as K; @@ -2808,13 +1893,9 @@ mod tests { WireError::from_io(&io::Error::new(K::WouldBlock, "x")).kind, WireErrorKind::Other ); - // GitUnavailable has no io kind of its own; it reads as "not found", - // which is what a missing binary is. assert_eq!(WireErrorKind::GitUnavailable.to_io_kind(), K::NotFound); } - /// The server's message survives into the local `io::Error`, because that - /// string is what the user is shown. #[test] fn wire_error_message_survives_into_io() { let e = WireError::new(WireErrorKind::NotFound, "/home/me/gone: no such file").into_io(); @@ -2822,8 +1903,6 @@ mod tests { assert!(e.to_string().contains("/home/me/gone")); } - /// A non-zero git exit is a *successful* reply. This is the invariant that - /// keeps `git_status`'s `Option<String>` semantics intact behind the trait. #[test] fn a_nonzero_git_exit_is_ok_not_err() { let reply = ControlReply::Ok(ReplyOk::Output(Output { @@ -2853,9 +1932,6 @@ mod tests { } } - /// `Output`'s byte fields ride as base64 strings, not JSON number arrays. - /// The difference is ~1.33× versus ~4×; on a megabyte of `git diff` that is - /// the difference between a snappy panel and a stalled one. #[test] fn output_bytes_ride_as_base64_not_a_number_array() { let out = Output { @@ -2870,15 +1946,10 @@ mod tests { &json[..60.min(json.len())] ); assert!(!json.contains("[0,0,0"), "must not be a number array"); - // 3000 bytes -> 4000 base64 chars, versus ~6000 for a number array. assert!(json.len() < 4200, "base64 payload is {} bytes", json.len()); assert_eq!(serde_json::from_str::<Output>(&json).unwrap(), out); } - // ---- timeouts ----------------------------------------------------------- - - /// Every request has a deadline, and the classes keep the contract's shape: - /// metadata is quick, content is patient, git and search sit between. #[test] fn deadlines_match_the_contract_table() { use ControlRequest as R; @@ -2970,9 +2041,6 @@ mod tests { } } - /// Only `WriteFile` takes a request blob; only `ReadFile` returns one. The - /// client picks the frame kind from these, so a wrong answer here silently - /// changes the wire. #[test] fn blob_shape_is_known_per_request() { for req in every_request() { @@ -2983,8 +2051,6 @@ mod tests { } } - /// The handshake carries the server's separator and home, which is how a - /// Windows client ends up doing POSIX path arithmetic for a Linux host. #[test] fn hello_ok_round_trips_with_features() { let mut buf = Vec::new(); @@ -2997,17 +2063,12 @@ mod tests { assert_eq!(ok.home, "/home/me"); assert!(ok.has_feature(feature::CONTROL)); assert!(ok.has_feature(feature::HOST_RPC)); - // The retired record store's bit is a burned name and must - // never come back. assert!(!ok.has_feature("workspace-store")); } other => panic!("expected HelloOk, got {other:?}"), } } - /// `features` is `#[serde(default)]`, so a peer that predates the field - /// still decodes — the whole reason capabilities are a list rather than - /// another version number. #[test] fn hello_ok_without_features_still_decodes() { let json = br#"{"control_version":1,"protocol_version":3,"build":"old", @@ -3017,23 +2078,10 @@ mod tests { assert!(!ok.has_feature(feature::CONTROL)); } - // ---- ControlClient over a real duplex stream ---------------------------- - // - // These drive a genuine loopback TCP connection with a scripted peer on the - // far thread, rather than a `Cursor`. A `Cursor` can prove the codec is - // symmetric; it cannot prove that a reply is claimed by the right waiter, - // that a slow request doesn't hold up a fast one, or that a dead socket - // wakes everyone. Those are properties of the *client*, and they only exist - // once there are two threads and a real stream between them. - use std::net::{TcpListener, TcpStream}; use std::sync::mpsc; use std::thread; - /// Stand up a scripted peer on loopback and connect a client to it. - /// - /// `serve` receives the peer's own socket after the handshake has been - /// answered, and drives whatever exchange the test needs. fn client_with_peer<F>(events: EventSink, serve: F) -> ControlClient where F: FnOnce(TcpStream) + Send + 'static, @@ -3070,8 +2118,6 @@ mod tests { w.flush().unwrap(); } - /// The handshake completes, a request goes out, and its reply comes back — - /// over loopback TCP, with the client's reader thread doing the decoding. #[test] fn a_request_and_its_reply_cross_a_real_duplex_stream() { let client = client_with_peer(no_events(), |mut sock| { @@ -3114,16 +2160,6 @@ mod tests { ); } - /// **The reason this layer exists.** A slow request must not hold up a fast - /// one issued behind it. - /// - /// The peer here deliberately answers out of order: it parks a `Git` that - /// arrived first, answers a `ReadDir` that arrived second, and only then - /// goes back to the `Git`. If replies were matched by arrival order — or if - /// one shared lock serialized callers — the `ReadDir` would sit behind the - /// `Git` and the file tree would freeze every time the status bar asked a - /// slow question. The completion order recorded below is the proof it does - /// not. #[test] fn a_slow_request_does_not_block_a_fast_one_behind_it() { let client = Arc::new(client_with_peer(no_events(), |mut sock| { @@ -3134,8 +2170,6 @@ mod tests { ControlRequest::Git { .. } => parked_git = Some(req_id), ControlRequest::ReadDir { .. } => { reply_to(&mut sock, req_id, ReplyOk::Entries(vec![])); - // Only now, well after the fast reply, does the - // slow one land. thread::sleep(Duration::from_millis(250)); reply_to( &mut sock, @@ -3170,8 +2204,6 @@ mod tests { out }); - // Let the slow request reach the peer first, so it is unambiguously - // *ahead* of the fast one in both issue order and req_id. thread::sleep(Duration::from_millis(100)); let entries = client .call(ControlRequest::ReadDir { @@ -3194,9 +2226,6 @@ mod tests { )); } - /// Many concurrent callers, each answered with a value only they asked for, - /// with the peer replying in reverse order. Proves the pending table keys - /// on `req_id` rather than on anything positional. #[test] fn concurrent_callers_each_get_their_own_reply() { const N: u64 = 16; @@ -3208,8 +2237,6 @@ mod tests { other => panic!("unexpected message {other:?}"), } } - // Reverse order, and each reply echoes the *request's* own path so - // a mismatched delivery is visible rather than merely plausible. for (req_id, req) in seen.into_iter().rev() { let path = match req { ControlRequest::Canonicalize { path } => path, @@ -3240,25 +2267,18 @@ mod tests { } } - /// A request that outruns its deadline fails with `TimedOut`, sends a - /// `CONTROL_CANCEL`, and — critically — **does not drop the connection**: - /// the next request on the same client still works. #[test] fn a_timeout_cancels_that_request_and_leaves_the_connection_usable() { let (saw_cancel_tx, saw_cancel_rx) = mpsc::channel(); let client = client_with_peer(no_events(), move |mut sock| { - // Swallow the first request without answering it. match ControlClientMsg::read(&mut sock).unwrap() { ControlClientMsg::Request { .. } => {} other => panic!("unexpected message {other:?}"), } - // The client should now give up and cancel. match ControlClientMsg::read(&mut sock).unwrap() { ControlClientMsg::Cancel { req_id } => saw_cancel_tx.send(req_id).unwrap(), other => panic!("expected Cancel, got {other:?}"), } - // A late reply to the abandoned request: it must be discarded, not - // handed to whoever asks next. reply_to(&mut sock, 1, ReplyOk::Path("/late".into())); match ControlClientMsg::read(&mut sock).unwrap() { ControlClientMsg::Request { req_id, .. } => { @@ -3289,13 +2309,9 @@ mod tests { ); } - /// When the link dies, every waiting caller is woken with - /// `ConnectionReset` immediately — not left to serve out its own deadline, - /// which for a `ReadFile` would be thirty seconds of a frozen editor. #[test] fn losing_the_link_fails_in_flight_requests_at_once() { let client = Arc::new(client_with_peer(no_events(), |mut sock| { - // Take one request, then hang up without answering. let _ = ControlClientMsg::read(&mut sock); drop(sock); })); @@ -3314,15 +2330,10 @@ mod tests { ); assert!(!client.is_connected()); - // And a request issued afterwards fails immediately rather than - // queueing against a dead socket. let e = client.call(ControlRequest::Ping).unwrap_err(); assert_eq!(e.kind(), io::ErrorKind::ConnectionReset); } - /// Bulk bytes ride both directions: content up with `WriteFile`, content - /// down with `ReadFile`, each beside a JSON head carrying the parameters - /// that a bare-blob frame could not have held. #[test] fn blobs_ride_in_both_directions() { let content: Vec<u8> = (0..=255u8).cycle().take(300_000).collect(); @@ -3379,9 +2390,6 @@ mod tests { assert_eq!(read.blob, content, "the blob is the file's content"); } - /// An error reply becomes an `io::Error` of the matching kind, carrying the - /// server's message — this is the path every "file is gone" notification in - /// the GUI takes. #[test] fn an_error_reply_becomes_a_matching_io_error() { let client = client_with_peer(no_events(), |mut sock| { @@ -3412,8 +2420,6 @@ mod tests { assert!(e.to_string().contains("900 MB")); } - /// Pushes reach the sink without any request to hang them on, and interleave - /// freely with replies rather than being serialized behind them. #[test] fn events_reach_the_sink_interleaved_with_replies() { let (tx, rx) = mpsc::channel(); @@ -3464,8 +2470,6 @@ mod tests { ); } - /// Request ids start at 1 and never reissue 0, which is what keeps a push - /// from ever being mistaken for a reply. #[test] fn request_ids_start_at_one_and_increase() { let (tx, rx) = mpsc::channel(); @@ -3487,11 +2491,6 @@ mod tests { assert_eq!(ids, vec![1, 2, 3, 4]); } - /// A peer speaking a different control dialect is reported as exactly that, - /// naming both versions. The peer answers the handshake and *then* hangs up - /// — a `HELLO` has no `req_id`, so there is no error reply to carry the - /// mismatch, and without this frame the client would only see a closed - /// socket. #[test] fn a_control_version_mismatch_names_both_versions() { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); @@ -3526,13 +2525,6 @@ mod tests { ); } - /// **Only a dialect refusal is recognised as one.** - /// - /// [`is_dialect_refusal`] gates a destructive offer — reinstalling the - /// server on someone's machine and dropping every pane on it. A connect that - /// failed for any other reason must never reach that button, because - /// replacing the binary would not fix it and would cost the user their - /// sessions to find out. #[test] fn other_connect_failures_are_not_dialect_refusals() { for other in [ @@ -3547,8 +2539,6 @@ mod tests { assert!(is_dialect_refusal(&dialect_refusal("26.7.6", 2, 3))); } - /// A peer that answers the handshake with something other than `HELLO_OK` - /// is a desync, not a greeting to be tolerated. #[test] fn a_handshake_answered_with_the_wrong_frame_is_invalid_data() { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); @@ -3566,26 +2556,13 @@ mod tests { assert_eq!(e.kind(), io::ErrorKind::InvalidData); } - /// **Regression: closing an idle client must not hang.** - /// - /// The reader spends its life blocked in `read_frame`, and an idle peer has - /// no reason to send anything. Failing the pending table does not wake a - /// thread that is inside a syscall, so a `close` that merely set a flag and - /// then joined would wait for a frame neither side will ever send — and - /// because `Drop` calls `close`, *every* dropped client would freeze - /// whichever thread dropped it. For a user shutting a remote workspace that - /// is the UI thread. #[test] fn closing_an_idle_client_returns_promptly() { let client = client_with_peer(no_events(), |mut sock| { - // A peer that answers nothing and never hangs up: exactly the - // situation a healthy, quiet connection is in. let _ = ControlClientMsg::read(&mut sock); thread::sleep(Duration::from_secs(30)); }); assert_eq!(client.call(ControlRequest::Ping).unwrap_err().kind(), { - // The peer never replies, so this times out — leaving the reader - // parked exactly where the bug needs it. io::ErrorKind::TimedOut }); @@ -3598,8 +2575,6 @@ mod tests { ); } - /// And the same guarantee through `Drop`, which is how it is actually - /// reached in production — nobody calls `close` by hand. #[test] fn dropping_an_idle_client_returns_promptly() { let client = client_with_peer(no_events(), |mut sock| { @@ -3615,9 +2590,6 @@ mod tests { ); } - /// With a shutdown wired up the reader is genuinely reaped, not merely - /// abandoned — the fast path, and the one that keeps threads from piling up - /// across a session's worth of reconnects. #[test] fn close_reaps_the_reader_when_the_link_can_be_shut_down() { let client = client_with_peer(no_events(), |mut sock| { @@ -3626,8 +2598,6 @@ mod tests { }); let started = Instant::now(); client.close(); - // A shut-down socket wakes the reader in microseconds; anything near - // the grace period would mean it was detached rather than reaped. assert!( started.elapsed() < CLOSE_GRACE, "reader should have been reaped, not waited out: {:?}", @@ -3636,14 +2606,10 @@ mod tests { assert!(!client.is_connected()); } - /// A frame the client cannot parse drops the connection rather than being - /// skipped: once the stream's position is in doubt, nothing after it can be - /// trusted. Waiting callers are woken, not left hanging. #[test] fn a_malformed_frame_tears_the_connection_down() { let client = client_with_peer(no_events(), |mut sock| { let _ = ControlClientMsg::read(&mut sock); - // A well-framed frame of an unknown control kind. write_frame(&mut sock, 64, &[0u8; 16]).unwrap(); sock.flush().unwrap(); thread::sleep(Duration::from_secs(2)); diff --git a/crates/tty7-core/src/daemon/duplex.rs b/crates/tty7-core/src/daemon/duplex.rs index bae2fd13..fe82780e 100644 --- a/crates/tty7-core/src/daemon/duplex.rs +++ b/crates/tty7-core/src/daemon/duplex.rs @@ -1,75 +1,20 @@ -//! [`Duplex`] — one bidirectional link, as the *server* side sees it. -//! -//! The client half of a control connection takes its two halves separately -//! ([`ControlClient::connect`](crate::daemon::control::ControlClient::connect)): -//! it is always the side that opened the link, so it already holds whatever it -//! opened. The server is handed things — an accepted socket, or a process it was -//! `exec`'d into — and has to get two halves *out* of them. That is all this -//! trait does. -//! -//! # Why not `try_clone` -//! -//! Every existing server path in the tree splits with `try_clone` -//! (`daemon::server::handle_conn`), which works because a socket is one object -//! with two directions. Under `tty7-server --stdio` it is not: the read half is -//! file descriptor 0 and the write half is file descriptor 1, two unrelated -//! pipes to two different places. There is nothing to clone. So the split -//! happens once, at construction, and the trait says so. -//! -//! # Shutdown is [`LinkShutdown`], not a second abstraction -//! -//! A server thread parked in `read` cannot be woken by a flag — it is inside a -//! syscall, and the peer has no reason to send anything, because it is waiting -//! for a reply. This is precisely the deadlock -//! [`LinkShutdown`](crate::daemon::control::LinkShutdown) exists to break on the -//! client side, and it is the same deadlock here. -//! -//! So [`Halves::shutdown`] **is** a `LinkShutdown`, reusing that trait rather -//! than mirroring it. Two shutdown abstractions that mean the same thing would -//! be two places to forget a transport, and the socket impls would have to be -//! written twice; adopting the existing one costs nothing and keeps a single -//! answer to "how do I force this link to end". -//! -//! It is also non-optional. Every transport a server can be handed *has* an -//! answer — for a socket it is `shutdown(2)`, for stdio it is closing the write -//! half — and making the field an `Option` would only mean the question could be -//! skipped, which is how the client-side deadlock happened in the first place. - use std::io; use std::sync::{Arc, Mutex}; use crate::daemon::control::LinkShutdown; -/// The two halves of a link, plus the handle that can force the read half to -/// return. pub struct Halves<R, W> { - /// Inbound bytes. Read on the connection's own thread. pub read: R, - /// Outbound bytes. Shared by every worker replying on this connection, so - /// the server keeps it behind a mutex and writes one whole frame per lock. pub write: W, - /// How to end the link from another thread. See the module docs. pub shutdown: Arc<dyn LinkShutdown>, } -/// A bidirectional link a server can serve one connection over. -/// -/// Implemented for the accepted-socket types on both platforms and for -/// [`StdioDuplex`]; anything else a future transport brings (an SSH channel, -/// say) implements it the same way. pub trait Duplex: Send + 'static { - /// The inbound half. type Read: io::Read + Send + 'static; - /// The outbound half. type Write: io::Write + Send + 'static; - /// Consume the link and yield its halves. Consuming rather than borrowing is - /// what lets stdio participate: its halves were never one object to begin - /// with. fn split(self) -> io::Result<Halves<Self::Read, Self::Write>>; - /// A short label for logs and diagnostics, so "the link dropped" can say - /// *which kind* of link. fn kind_label(&self) -> &'static str; } @@ -112,22 +57,6 @@ impl Duplex for std::net::TcpStream { } } -// --------------------------------------------------------------------------- -// stdio -// --------------------------------------------------------------------------- - -/// The process's own stdin and stdout, as one link. -/// -/// This is how `tty7-server --stdio --serve` is reached: whatever spawned it -/// (`ssh host tty7-server --stdio`, `wsl.exe -- tty7-server --stdio`, or a test -/// harness) talks to it down the pipes it was born with. There is no socket, no -/// port and no filesystem rendezvous — which is the entire point, because that -/// is what makes the path work under `AllowStreamLocalForwarding no`, under WSL, -/// and in CI on a box with no sshd. -/// -/// Unix only. A Windows `tty7-server` is reached over its own loopback -/// transport; nothing in the tree spawns one down a pipe, and the handle -/// surgery below has no portable equivalent worth carrying unused. #[cfg(unix)] pub struct StdioDuplex { read: std::fs::File, @@ -136,31 +65,14 @@ pub struct StdioDuplex { #[cfg(unix)] impl StdioDuplex { - /// Take exclusive ownership of the process's stdin and stdout. - /// - /// **This is a hijack, deliberately.** Both descriptors are duplicated, and - /// the originals are then pointed at `/dev/null`. After this call `println!`, - /// a library's stray progress bar, and anything else that reaches for fd 1 - /// write into the void instead of into the middle of a control frame. A - /// protocol carried on stdout cannot share stdout, and "nothing in this - /// process ever prints" is not an invariant that survives a dependency - /// bump — so it is enforced here rather than assumed. - /// - /// Diagnostics still work: stderr is untouched, and it is where the stdio - /// server logs. pub fn take() -> io::Result<StdioDuplex> { use std::os::fd::FromRawFd as _; - // Duplicate first, redirect second: if the redirect failed after a - // successful dup we would still hold a working link, whereas the other - // order could leave the process with no stdout at all. let stdin_fd = dup_fd(libc::STDIN_FILENO)?; let stdout_fd = dup_fd(libc::STDOUT_FILENO)?; redirect_to_null(libc::STDIN_FILENO)?; redirect_to_null(libc::STDOUT_FILENO)?; - // SAFETY: both fds came from `dup(2)` above, are owned by nobody else, - // and are handed to `File` exactly once. let read = unsafe { std::fs::File::from_raw_fd(stdin_fd) }; let write = unsafe { std::fs::File::from_raw_fd(stdout_fd) }; Ok(StdioDuplex { @@ -191,20 +103,6 @@ impl Duplex for StdioDuplex { } } -/// The write half of a [`StdioDuplex`]: a closable stdout. -/// -/// Closing it is the whole reason this type exists rather than a bare `File`. -/// The peer of a stdio server has exactly one way to learn the server is -/// finished — reading EOF on the pipe — and the only way to produce that EOF is -/// to drop the last descriptor on our end. So the file lives behind a shared -/// slot that [`LinkShutdown::shutdown_link`] can empty from any thread. -/// -/// The read half is deliberately *not* closable the same way. Closing a pipe -/// descriptor another thread is already blocked reading on does not wake it (the -/// open file description outlives the descriptor), so pretending otherwise would -/// be a shutdown that silently does nothing. What actually ends the read is the -/// peer hanging up — which closing our write half is exactly what provokes. It -/// is the same half-close a TCP `shutdown(Write)` performs, for the same reason. #[cfg(unix)] #[derive(Clone)] pub struct StdioWriter { @@ -228,9 +126,6 @@ impl io::Write for StdioWriter { let mut slot = self.inner.lock().unwrap_or_else(|e| e.into_inner()); match slot.as_mut() { Some(f) => f.flush(), - // Nothing buffered and nothing to flush it to: the shutdown already - // happened, and reporting an error here would only turn an orderly - // close into a logged failure. None => Ok(()), } } @@ -246,9 +141,6 @@ impl LinkShutdown for StdioWriter { .take() .is_some(); if !taken { - // Already closed. Idempotent on purpose: `close` runs from both the - // teardown path and a `Drop`, and neither should have to know - // whether the other went first. return Ok(()); } Ok(()) @@ -257,8 +149,6 @@ impl LinkShutdown for StdioWriter { #[cfg(unix)] fn dup_fd(fd: libc::c_int) -> io::Result<libc::c_int> { - // SAFETY: `fd` is one of the standard descriptors; `dup` either returns a - // fresh owned descriptor or -1 with errno set. let new = unsafe { libc::dup(fd) }; if new < 0 { return Err(io::Error::last_os_error()); @@ -273,8 +163,6 @@ fn redirect_to_null(fd: libc::c_int) -> io::Result<()> { .write(true) .open("/dev/null")?; use std::os::fd::AsRawFd as _; - // SAFETY: both descriptors are valid and owned here; `dup2` closes `fd` - // and re-points it at `/dev/null` atomically. let rc = unsafe { libc::dup2(null.as_raw_fd(), fd) }; if rc < 0 { return Err(io::Error::last_os_error()); @@ -287,9 +175,6 @@ mod tests { use super::*; use std::io::{Read as _, Write as _}; - /// The socket impls hand back halves that really are the same link, and a - /// shutdown from a third handle ends a parked read — the property the whole - /// trait exists for. #[cfg(unix)] #[test] fn a_unix_stream_splits_into_working_halves() { @@ -313,7 +198,6 @@ mod tests { read.read_exact(&mut got).unwrap(); assert_eq!(&got, b"pong"); - // The parked reader has to come back, not hang. let reader = std::thread::spawn(move || { let mut sink = Vec::new(); read.read_to_end(&mut sink).map(|_| ()) @@ -322,8 +206,6 @@ mod tests { let _ = reader.join().unwrap(); } - /// Shutting a stdio writer down closes it for good: a later write fails - /// rather than quietly succeeding into a descriptor the peer no longer has. #[cfg(unix)] #[test] fn a_shut_stdio_writer_refuses_further_writes() { @@ -338,7 +220,6 @@ mod tests { w.write(b"after").unwrap_err().kind(), io::ErrorKind::BrokenPipe ); - // Idempotent: teardown and `Drop` both call it. w.shutdown_link().unwrap(); assert_eq!(std::fs::read(tmp.path()).unwrap(), b"before"); } diff --git a/crates/tty7-core/src/daemon/install/asset.rs b/crates/tty7-core/src/daemon/install/asset.rs index ce65ea3a..28ac01fd 100644 --- a/crates/tty7-core/src/daemon/install/asset.rs +++ b/crates/tty7-core/src/daemon/install/asset.rs @@ -1,68 +1,21 @@ -//! The pure half of the installer: `uname -sm` → release asset, client version → -//! release tag → download URL, and the remote paths a server binary lives at. -//! -//! Everything here is a total function of its arguments — no network, no SFTP, no -//! clock — which is the point: the asset naming here is a *literal* contract -//! with the release workflow (`.github/workflows/release.yml`), and a contract -//! is only worth having if both sides can be tested without standing up the -//! other one. - use std::fmt; -/// The release asset for a 64-bit x86 Linux box. -/// -/// **`<os>-<arch>-musl`, not the Rust target triple.** These names used to be -/// `${{ matrix.target }}` pasted into a filename, which put `unknown` — the -/// triple's *vendor* field, meaning "no particular vendor" — in front of anyone -/// reading the releases page. Of the triple's four fields only two say anything -/// to whoever downloads this: the architecture, which is what `asset_for_uname` -/// picks by, and `musl`, which is why one file runs on any distribution. The -/// order matches the GUI assets the same release publishes -/// (`tty7-<version>-linux-x86_64.tar.gz`), so one release is one naming scheme. -/// -/// The build target keeps the triple wherever it really is one — `cargo -/// zigbuild --target`, the `target/<triple>/release` path, the cache key. This -/// is a *download* name, and the two are no longer spelled the same on purpose. pub const ASSET_X86_64: &str = "tty7-server-linux-x86_64-musl"; -/// The release asset for a 64-bit ARM Linux box. See [`ASSET_X86_64`] for the -/// naming. pub const ASSET_AARCH64: &str = "tty7-server-linux-aarch64-musl"; -/// The sha256 manifest published beside every asset in a release. pub const CHECKSUMS_ASSET: &str = "checksums.txt"; -/// Where release assets are downloaded from. The tag and asset name are appended -/// (`{RELEASE_BASE}/{tag}/{asset}`); HTTPS to github.com is the trust anchor for -/// the checksum file itself. pub const RELEASE_BASE: &str = "https://github.com/l0ng-ai/tty7/releases/download"; -/// The `XDG_DATA_HOME`-shaped directory tty7 owns on a remote machine, relative -/// to `$HOME`. Split into components because the installer has to `mkdir` each -/// level (SFTP has no `mkdir -p`) and because joining is `/`-only regardless of -/// the *client's* OS — a Windows client must not produce `.local\share`. pub const INSTALL_DIR_COMPONENTS: [&str; 4] = [".local", "share", "tty7", "bin"]; -/// Why a machine cannot be served a `tty7-server`. -/// -/// Both variants carry the raw `uname -sm` output: the whole value of refusing -/// instead of guessing is that the user can read the string we refused and either -/// recognise their box or paste it into an issue. #[derive(Debug, Clone, PartialEq, Eq)] pub enum UnsupportedTarget { - /// `uname -s` is not `Linux`. A remote tty7-server is a Linux binary; there - /// is no macOS/BSD/Solaris asset to fall back to. NotLinux { raw: String }, - /// `uname -s` is `Linux` but `uname -m` is not one we publish for — 32-bit - /// arm, i686, riscv64, or something we have never seen. UnknownMachine { raw: String }, - /// `uname -sm` did not produce the two whitespace-separated words it is - /// specified to. Almost always means the command did not run at all (a login - /// shell that printed a banner, a restricted shell) rather than a real - /// answer, so it gets its own variant with the raw text. Unparseable { raw: String }, } impl UnsupportedTarget { - /// The `uname -sm` text this refusal is about, as the remote printed it. pub fn raw(&self) -> &str { match self { Self::NotLinux { raw } | Self::UnknownMachine { raw } | Self::Unparseable { raw } => { @@ -94,17 +47,6 @@ impl fmt::Display for UnsupportedTarget { impl std::error::Error for UnsupportedTarget {} -/// Map raw `uname -sm` output to the release asset that runs on that machine. -/// -/// **Exact string match, then fail.** No prefix matching, no "starts with `arm` -/// so it is probably aarch64" heuristic. Guessing wrong here installs a binary -/// that dies with `Exec format error` at first exec — an error with no visible -/// connection to the architecture detection that caused it, on a machine the user -/// may not be able to inspect. An unknown machine string is a clean, explainable -/// refusal that names itself. -/// -/// `amd64` / `arm64` are accepted alongside the values Linux actually reports -/// because some container images and BSD-flavoured userlands normalise to them. pub fn asset_for_uname(uname_sm: &str) -> Result<&'static str, UnsupportedTarget> { let raw = uname_sm.trim().to_string(); let mut words = raw.split_whitespace(); @@ -121,18 +63,6 @@ pub fn asset_for_uname(uname_sm: &str) -> Result<&'static str, UnsupportedTarget } } -/// Resolve an asset name that arrived over a wire back to a `&'static str`. -/// -/// [`super::InstallRequest::asset`] is `&'static str` because on the producing -/// side it is always one of the two consts above. A decoder cannot promise that, -/// and the relay in `daemon::router` has to rebuild the request a *different -/// process* raised — so the two known names map to themselves, and anything else -/// (a client older or newer than the daemon that named it) is leaked. -/// -/// Leaking is bounded in the way that matters: the value comes from tty7's own -/// daemon naming one of its own release assets, and a session sees at most a -/// handful of distinct machines. It is preferred to guessing one of the two -/// consts, which would show the user a prompt naming the wrong architecture. pub fn interned(name: &str) -> &'static str { if name == ASSET_X86_64 { ASSET_X86_64 @@ -143,12 +73,6 @@ pub fn interned(name: &str) -> &'static str { } } -/// The release tag whose assets a client of `version` must download. -/// -/// The nightly channel republishes a single rolling `nightly` tag every night, so -/// a nightly client must not ask for `v26.7.6-nightly.20260727` — that tag does -/// not exist and never will. Rule: the version contains `-nightly.` → `nightly`; -/// otherwise `v` + version. pub fn release_tag(version: &str) -> String { if version.contains("-nightly.") { "nightly".to_string() @@ -157,40 +81,18 @@ pub fn release_tag(version: &str) -> String { } } -/// The download URL for one asset of one release. pub fn download_url(tag: &str, asset: &str) -> String { format!("{RELEASE_BASE}/{tag}/{asset}") } -/// Absolute remote paths for one *dialect*'s server binary. -/// -/// Built with explicit `/` joins from an absolute `$HOME` the remote resolved for -/// us (SFTP does not expand `~`, and `PathBuf::join` would emit `\` on a Windows -/// client). #[derive(Debug, Clone, PartialEq, Eq)] pub struct RemotePaths { - /// `$HOME/.local/share/tty7/bin`. pub bin_dir: String, - /// `$HOME/.local/share/tty7/bin/tty7-server-c<control>p<protocol>` — the - /// atomically published binary. See [`binary_name`] for why the dialects, and - /// not the version, are what the name carries. pub binary: String, - /// `$HOME/.local/share/tty7/bin/.tty7-server-c<control>p<protocol>.tmp` — - /// where the bytes land before `chmod`, the `--protocol` check, and `rename`. - /// - /// A dotfile, so a half-written upload is not mistaken for an installed - /// server by anything reading the directory. The installer adds a per-process - /// suffix (`super::unique_temp`) before writing: one file per dialect means - /// two clients installing the same dialect at once would otherwise interleave - /// their bytes into one name. pub temp: String, - /// Every directory that must exist before the upload, outermost first. SFTP - /// has no recursive mkdir, so the installer walks this. pub dir_chain: Vec<String>, } -/// Build the remote paths for a server speaking `control`/`protocol` under an -/// absolute remote `home`. pub fn remote_paths(home: &str, control: u32, protocol: u32) -> RemotePaths { let home = home.trim_end_matches('/'); let mut dir_chain = Vec::with_capacity(INSTALL_DIR_COMPONENTS.len()); @@ -209,41 +111,10 @@ pub fn remote_paths(home: &str, control: u32, protocol: u32) -> RemotePaths { } } -/// The filename a server speaking `control`/`protocol` is installed under. -/// -/// **The dialects are the name, and the version is nowhere in it.** Everything -/// the installer decides — is there something usable here, can the daemon that -/// is running talk to us — is a question about dialects, and a name built from -/// them answers it with a `stat` the client can address without asking the -/// remote anything. A name built from the version answers a *different* -/// question, and answers this one wrong in both directions: two builds that -/// share a version string but not a dialect (any two dev builds between -/// releases) look interchangeable, and two builds that share a dialect but not a -/// version look incompatible and cost an 8 MB upload that changes nothing. -/// -/// One file per dialect, so a machine accumulates at most one binary per wire -/// break rather than one per release. Which *build* is sitting behind a given -/// dialect is a separate question, answered by [`PROTOCOL_FLAG`][flag] and by -/// the control handshake — not by the filename. -/// -/// [flag]: super::PROTOCOL_FLAG pub fn binary_name(control: u32, protocol: u32) -> String { format!("tty7-server-c{control}p{protocol}") } -/// [`RemotePaths`] pointing at a binary that is **already on the machine**, -/// found rather than named — the server a connect adopted because it speaks our -/// dialects (`Installer::adoptable_running_server`). -/// -/// `binary` is the path as the remote reported it, verbatim: it is what the -/// transport must connect to, and rebuilding it from a version parsed out of the -/// filename would turn a binary installed somewhere unexpected into a path that -/// does not exist. -/// -/// `temp` and `dir_chain` still describe *our* install location, because that is -/// where a later install would write. Nothing writes anything on the adoption -/// path, so they are unused there; keeping them well-formed means a caller that -/// falls back to installing does not need a second `RemotePaths`. pub fn remote_paths_for_binary( home: &str, binary: &str, @@ -255,18 +126,6 @@ pub fn remote_paths_for_binary( paths } -/// The dialects encoded in an installed binary's *path*, if it is one of ours. -/// -/// This is how the running daemon is identified without asking it: the install -/// path carries the dialects by construction, so `readlink /proc/<pid>/exe` on -/// the remote answers "can the thing serving this machine talk to us" in the -/// round trip that found it. -/// -/// `None` for anything else, and that deliberately includes every binary -/// installed by a client that named files after versions: an old name carries no -/// dialect, so it gets no opinion, and the probe (`--protocol`) is what settles -/// it. Guessing a dialect from a version string is the exact inference this -/// naming exists to make impossible. pub fn dialect_from_path(path: &str) -> Option<(u32, u32)> { let name = path.rsplit('/').next()?; let (control, protocol) = name.strip_prefix("tty7-server-c")?.split_once('p')?; @@ -277,9 +136,6 @@ pub fn dialect_from_path(path: &str) -> Option<(u32, u32)> { mod tests { use super::*; - /// The contract's mapping table, row for row. This test *is* the client half - /// of the asset naming contract: if the release workflow ever renames an - /// asset, this is where the two sides stop agreeing. #[test] fn uname_maps_to_the_published_assets() { for raw in ["Linux x86_64", "Linux amd64"] { @@ -295,8 +151,6 @@ mod tests { } } - /// Real `uname` output ends in a newline, and a shell may pad it. Trimming is - /// the only normalisation allowed — the *words* are matched exactly. #[test] fn uname_output_is_trimmed_before_matching() { assert_eq!(asset_for_uname("Linux x86_64\n").unwrap(), ASSET_X86_64); @@ -306,10 +160,6 @@ mod tests { ); } - /// The refusal path, which is the whole reason this function exists. Every - /// one of these would be a plausible prefix/fuzzy match — `x86_64-v2` starts - /// with `x86_64`, `armv7l` starts with `arm`, `Linux` appears inside - /// `GNU/Linux` — and each would install a binary that cannot exec. #[test] fn unknown_machines_are_refused_not_guessed() { for raw in [ @@ -334,9 +184,6 @@ mod tests { } } - /// A non-Linux host is refused with its own variant so the message can say - /// "needs Linux" rather than "unknown architecture" — the user's next step is - /// completely different. #[test] fn non_linux_systems_are_refused() { for raw in [ @@ -355,11 +202,6 @@ mod tests { } } - /// Anything that is not exactly two words never reaches the mapping. In - /// practice this catches the common failure where the command did not run and - /// we got a shell banner, an error message, or nothing at all — and it is the - /// guard that keeps a three-word string from silently matching on its first - /// two words. #[test] fn output_that_is_not_two_words_is_unparseable() { for raw in [ @@ -380,15 +222,11 @@ mod tests { } } - /// Stable releases resolve to their own tag; nightlies resolve to the single - /// rolling `nightly` tag, because per-night tags are never created. #[test] fn release_tag_sends_nightlies_to_the_rolling_tag() { assert_eq!(release_tag("26.7.5"), "v26.7.5"); assert_eq!(release_tag("0.1.0"), "v0.1.0"); assert_eq!(release_tag("26.7.6-nightly.20260727"), "nightly"); - // A pre-release that is *not* a nightly keeps its own tag: only the - // nightly channel republishes under a rolling name. assert_eq!(release_tag("26.8.0-rc.1"), "v26.8.0-rc.1"); } @@ -404,16 +242,6 @@ mod tests { ); } - /// **The asset names, pinned as literals.** - /// - /// They are one half of a contract whose other half is a `cp` in two - /// workflow files, and checking them against the consts they come from - /// would assert nothing. A literal here is what makes changing one side - /// without the other a failing test rather than a 404 on a user's machine. - /// - /// Including the absence of `unknown`: that word only ever reached these - /// names by way of `${{ matrix.target }}`, and a build triple pasted into a - /// download name is worth failing on rather than explaining again. #[test] fn asset_names_are_the_ones_the_release_workflow_publishes() { assert_eq!(ASSET_X86_64, "tty7-server-linux-x86_64-musl"); @@ -424,17 +252,10 @@ mod tests { "{asset} carries the triple's vendor field" ); } - // `checksums::expected_digest` matches the filename field whole, and - // says outright that it relies on no asset name being a substring of - // another. Two names is the whole set, so check it here. assert!(!ASSET_X86_64.contains(ASSET_AARCH64)); assert!(!ASSET_AARCH64.contains(ASSET_X86_64)); } - /// Path construction, including the `mkdir` chain. Asserted literally: these - /// strings are what an SFTP server sees, and a `\` in any of them (which is - /// what `PathBuf::join` would produce on a Windows client) would create a file - /// named `.local\share\tty7\bin` in the remote home directory. #[test] fn remote_paths_are_posix_and_named_by_dialect() { let p = remote_paths("/home/me", 3, 4); @@ -459,9 +280,6 @@ mod tests { ); } - /// The temp name is a sibling dotfile of the target, so the finishing rename - /// is same-directory (same filesystem → atomic) and a partial upload is not - /// mistaken for an installed server. #[test] fn temp_path_is_a_hidden_sibling_of_the_binary() { let p = remote_paths("/home/me", 3, 4); @@ -471,19 +289,15 @@ mod tests { assert!(!p.binary.rsplit('/').next().unwrap().starts_with('.')); } - /// A trailing slash on the resolved home (some SFTP servers return `/root/`) - /// must not produce a doubled separator. #[test] fn trailing_slash_on_home_is_absorbed() { assert_eq!( remote_paths("/root/", 1, 1).binary, "/root/.local/share/tty7/bin/tty7-server-c1p1" ); - // Root as home is degenerate but must still be well-formed. assert_eq!(remote_paths("/", 1, 1).bin_dir, "/.local/share/tty7/bin"); } - /// The inverse used to identify a *running* daemon from its executable path. #[test] fn dialects_are_recoverable_from_an_install_path() { assert_eq!( @@ -491,19 +305,12 @@ mod tests { Some((3, 4)) ); assert_eq!(dialect_from_path("tty7-server-c12p30"), Some((12, 30))); - // Not ours, or not dialect-named: no opinion rather than a wrong one. assert_eq!(dialect_from_path("/usr/bin/tty7-server"), None); assert_eq!(dialect_from_path("/bin/bash"), None); assert_eq!(dialect_from_path("tty7-server-c3"), None); assert_eq!(dialect_from_path("tty7-server-cxpy"), None); } - /// Every name a version-naming client ever installed reads as "no opinion". - /// - /// The whole point of the rename is that a version string can no longer be - /// mistaken for a dialect; a parser that squeezed `3` out of `26.7.3` would - /// reintroduce exactly that, and on the paths of binaries already sitting on - /// users' machines. #[test] fn legacy_version_named_binaries_carry_no_dialect() { for legacy in [ @@ -516,7 +323,6 @@ mod tests { } } - /// Round-trip: the name we install under is the name we recognise later. #[test] fn install_path_and_dialect_extraction_round_trip() { for (c, p) in [(1u32, 1u32), (3, 4), (26, 7)] { diff --git a/crates/tty7-core/src/daemon/install/checksums.rs b/crates/tty7-core/src/daemon/install/checksums.rs index 0c403943..914febad 100644 --- a/crates/tty7-core/src/daemon/install/checksums.rs +++ b/crates/tty7-core/src/daemon/install/checksums.rs @@ -1,35 +1,18 @@ -//! `checksums.txt` parsing and asset verification. -//! -//! The release publishes one GNU coreutils `sha256sum`-format manifest covering -//! every asset. HTTPS to github.com is the trust anchor — the manifest is not -//! separately signed — so this module's whole job is to make sure the bytes we -//! are about to write onto someone else's machine are the bytes that release -//! actually published. -//! -//! Pure: no network, no filesystem. The bytes come in as a slice. - use std::fmt; use sha2::{Digest as _, Sha256}; -/// A parsed sha256 digest: 32 raw bytes, compared by value rather than by -/// string so casing and whitespace can never make a comparison accidentally -/// succeed. pub type Digest = [u8; 32]; -/// Why an asset failed verification. Every variant aborts the install; none of -/// them is retried, and there is no unverified fallback. #[derive(Debug, Clone, PartialEq, Eq)] pub enum ChecksumError { - /// The manifest has no line for this asset. Either the release is - /// incomplete or we derived an asset name the release does not carry — both - /// are "stop", never "install it anyway". - Missing { asset: String }, - /// The asset's line exists but is not `<64 hex> <name>`. - Malformed { asset: String, line: String }, - /// The manifest and the downloaded bytes disagree. The one variant that can - /// mean something is actively wrong (a corrupted download, a proxy that - /// rewrote the body, a compromised mirror), so it reports both digests. + Missing { + asset: String, + }, + Malformed { + asset: String, + line: String, + }, Mismatch { asset: String, expected: String, @@ -64,14 +47,12 @@ impl fmt::Display for ChecksumError { impl std::error::Error for ChecksumError {} -/// The sha256 of some bytes. pub fn sha256(bytes: &[u8]) -> Digest { let mut hasher = Sha256::new(); hasher.update(bytes); hasher.finalize().into() } -/// Render a digest as lowercase hex, for messages. pub fn hex(digest: &Digest) -> String { use fmt::Write as _; digest.iter().fold(String::with_capacity(64), |mut s, b| { @@ -80,8 +61,6 @@ pub fn hex(digest: &Digest) -> String { }) } -/// Parse 64 hex characters into a digest. Case-insensitive; any -/// other length or a non-hex character is a parse failure. fn parse_hex(s: &str) -> Option<Digest> { if s.len() != 64 { return None; @@ -93,25 +72,12 @@ fn parse_hex(s: &str) -> Option<Digest> { Some(out) } -/// The digest `manifest` records for `asset`. -/// -/// **The filename field is matched whole, never by substring.** -/// `tty7-server-linux-x86_64-musl` happens not to be a substring of any other -/// asset today, but that is an accident of the current release contents, not a -/// property anyone maintains — and a substring match that drifted would -/// silently verify one binary's bytes against another's digest. -/// -/// The coreutils format is `<digest><two spaces><name>`; the second space is `*` -/// in binary mode (`<digest> *<name>`), which some tools emit, so a leading `*` -/// on the name is stripped. Blank lines and `#` comments are skipped. pub fn expected_digest(manifest: &str, asset: &str) -> Result<Digest, ChecksumError> { for line in manifest.lines() { let line = line.trim_end_matches(['\r', '\n']); if line.trim().is_empty() || line.trim_start().starts_with('#') { continue; } - // Split once on whitespace: everything before is the digest field, - // everything after (minus the binary-mode marker) is the filename field. let Some((digest_field, name_field)) = line.split_once(char::is_whitespace) else { continue; }; @@ -129,8 +95,6 @@ pub fn expected_digest(manifest: &str, asset: &str) -> Result<Digest, ChecksumEr }) } -/// Verify downloaded `bytes` against the manifest. `Ok(())` is the only outcome -/// that permits an install. pub fn verify(manifest: &str, asset: &str, bytes: &[u8]) -> Result<(), ChecksumError> { let expected = expected_digest(manifest, asset)?; let actual = sha256(bytes); @@ -149,8 +113,6 @@ mod tests { use super::*; use crate::daemon::install::asset::{ASSET_AARCH64, ASSET_X86_64}; - /// A manifest shaped exactly like the release workflow's, with digests that - /// really are the sha256 of the payloads below. fn manifest_for(payloads: &[(&str, &[u8])]) -> String { payloads .iter() @@ -165,17 +127,13 @@ mod tests { verify(&manifest, ASSET_X86_64, bytes).expect("the published bytes must verify"); } - /// Uppercase hex in the manifest is still the same digest. #[test] fn digest_comparison_is_case_insensitive() { let bytes = b"payload".as_slice(); - // Only the digest is uppercased — the filename field stays exact-match. let manifest = format!("{} {ASSET_X86_64}\n", hex(&sha256(bytes)).to_uppercase()); verify(&manifest, ASSET_X86_64, bytes).expect("case must not matter"); } - /// **The failure path.** Bytes that do not match must abort with - /// both digests reported — not retry, not install anyway. #[test] fn mismatched_bytes_abort_with_both_digests() { let published = b"the real server binary".as_slice(); @@ -196,14 +154,11 @@ mod tests { } other => panic!("a mismatch must report both digests, got {other:?}"), } - // The message has to be actionable on its own — it is what the user sees. let msg = err.to_string(); assert!(msg.contains("sha256"), "{msg}"); assert!(msg.contains("aborted"), "{msg}"); } - /// A one-bit difference is caught. Cheap to assert, and the property the - /// whole verification exists for. #[test] fn a_single_flipped_bit_fails() { let mut payload = vec![0u8; 4096]; @@ -217,9 +172,6 @@ mod tests { )); } - /// No line for our asset → abort. This is the "release is missing the - /// architecture we need" case, and installing the other architecture (or - /// nothing-checked) would both be worse than stopping. #[test] fn a_missing_entry_aborts() { let manifest = manifest_for(&[(ASSET_AARCH64, b"arm bytes")]); @@ -233,9 +185,6 @@ mod tests { )); } - /// A line whose digest field is not 64 hex characters is malformed, not - /// "close enough". Truncated digests are exactly what a partially-uploaded - /// manifest looks like. #[test] fn a_malformed_entry_aborts() { for bad in [ @@ -254,9 +203,6 @@ mod tests { } } - /// **Whole-field match, not substring.** A manifest carrying a longer name - /// that *contains* ours must not satisfy the lookup — this is the guard - /// whole-field matching exists for. #[test] fn filename_matching_is_exact_not_substring() { let payload = b"decoy".as_slice(); @@ -274,8 +220,6 @@ mod tests { ); } - /// Binary-mode (`*name`) lines, CRLF line endings, comments and blank lines - /// are all shapes a checksum file can legitimately arrive in. #[test] fn tolerates_binary_mode_crlf_and_comments() { let payload = b"payload".as_slice(); @@ -285,8 +229,6 @@ mod tests { verify(&manifest, ASSET_X86_64, payload).expect("binary-mode CRLF lines must parse"); } - /// Empty input hashes to the well-known empty sha256; a fixed vector keeps - /// the hashing itself honest rather than only self-consistent. #[test] fn sha256_matches_known_vectors() { assert_eq!( diff --git a/crates/tty7-core/src/daemon/install/download.rs b/crates/tty7-core/src/daemon/install/download.rs index 1a83c1ab..7939cbee 100644 --- a/crates/tty7-core/src/daemon/install/download.rs +++ b/crates/tty7-core/src/daemon/install/download.rs @@ -1,45 +1,14 @@ -//! The HTTPS half of D5: the *client* downloads release assets and pushes them -//! over SSH, because the machines this feature exists for — behind a jump host, -//! on an internal network, in a locked-down VPC — frequently cannot reach GitHub -//! themselves. -//! -//! ## Why `ureq`, and why behind a feature -//! -//! The GUI's update check uses `reqwest_client`, which wraps Zed's reqwest fork -//! behind `gpui::http_client`. `tty7-core` must not depend on gpui, so that stack -//! is unavailable here. `ureq` is blocking (which matches this call path — the -//! installer runs on a daemon std thread, not in an async context), rustls-based -//! (no OpenSSL, so nothing to find at build time), and shares the `rustls` and -//! `http` versions already in the tree. -//! -//! It is optional, behind `remote-install`, which the GUI crate turns on and -//! `tty7-server` does not. `tty7-server` builds as a *static musl* binary that is -//! itself the thing being downloaded; giving it an HTTP client would add size and -//! a TLS backend to every remote install for a code path it can never take. Same -//! mechanism as the existing `gssapi` feature, for the same reason. - use std::io::Read as _; use std::time::Duration; use super::AssetFetcher; -/// Overall budget for one asset download. A 6 MB binary on a bad connection is -/// slow but finite; a stalled TLS session is not, and this is what makes the -/// difference visible. const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(180); -/// Refuse a body larger than this. A release asset is ~6 MB; anything at this -/// scale means we are downloading something other than what we asked for, and -/// buffering it in memory before finding out is not a good trade. const MAX_ASSET_BYTES: u64 = 128 * 1024 * 1024; -/// How much body to take per read, and therefore how often progress is -/// reported: ~130 updates over a 8 MB asset. Large enough that the syscall -/// overhead stays irrelevant, small enough that a bar moves smoothly rather -/// than in visible jumps. const READ_CHUNK: usize = 64 * 1024; -/// Downloads release assets over HTTPS. pub struct HttpsFetcher { agent: ureq::Agent, } @@ -72,11 +41,6 @@ impl AssetFetcher for HttpsFetcher { .call() .map_err(|e| describe(url, &e.to_string()))?; - // GitHub serves release assets from a redirect to object storage; ureq - // follows those itself. A 404 here is the interesting one: it means the - // release tag we derived from our own version was never published (a - // local dev build, a tag that failed to publish), and saying so beats - // "download failed". let status = response.status().as_u16(); if status == 404 { return Err(format!( @@ -87,8 +51,6 @@ impl AssetFetcher for HttpsFetcher { return Err(format!("{url} returned HTTP {status}")); } - // Only a hint: it is what the *server* claims, so it sizes the - // allocation and the progress bar but never the ceiling check below. let declared = response .headers() .get("content-length") @@ -97,9 +59,6 @@ impl AssetFetcher for HttpsFetcher { .filter(|n| *n <= MAX_ASSET_BYTES); let mut body = response.into_body(); - // `take` still caps the read, so a lying (or absent) Content-Length - // cannot make this buffer more than the ceiling — one byte over is - // enough to detect it, which is why the limit is `+ 1`. let mut reader = body.as_reader().take(MAX_ASSET_BYTES + 1); let mut bytes = Vec::with_capacity(declared.unwrap_or(0) as usize); let mut buf = vec![0u8; READ_CHUNK]; @@ -122,9 +81,6 @@ impl AssetFetcher for HttpsFetcher { } } -/// Turn a transport error into something a user can act on. The distinction -/// worth drawing is "the network is not reachable from here" (retry later, or -/// check the proxy) versus everything else. fn describe(url: &str, reason: &str) -> String { let lower = reason.to_ascii_lowercase(); if lower.contains("dns") || lower.contains("resolve") { @@ -142,32 +98,11 @@ fn describe(url: &str, reason: &str) -> String { mod tests { use super::*; - /// Constructing the agent must not panic (a rustls provider that fails to - /// install would, and would do it at the worst possible moment — mid-connect - /// on someone's first remote workspace). #[test] fn the_agent_builds() { let _ = HttpsFetcher::default(); } - /// Talks to the real github.com. `#[ignore]`d because it needs the network, - /// which no other test here does — run it by hand (`cargo test -p tty7-core - /// --features remote-install -- --ignored talks_to_github --nocapture`) - /// after touching the HTTP client. - /// - /// Two things only a live server can prove: - /// - /// - **Redirects are followed.** Every GitHub download path — `/raw/` and - /// `releases/download/…` alike — answers with a 302 to another host. A - /// client that does not follow it returns an empty body under a status - /// that still reads as success, so the "asset" would sha256 to the digest - /// of nothing. Asserting real content is what catches that. - /// - **The TLS trust anchor works.** ureq's webpki roots must accept - /// github.com's chain; that HTTPS connection *is* the security model here - /// (`checksums.txt` is not separately signed). - /// - /// Deliberately a small file rather than a release asset: assets are ~20 MB - /// and this is a correctness check, not a bandwidth test. #[test] #[ignore = "needs the network"] fn talks_to_github() { @@ -181,8 +116,6 @@ mod tests { bytes.len() ); - // And a tag that was never published is reported as such, not as a - // generic transport failure. let missing = super::super::asset::download_url("v0.0.0-never", "tty7-server-nope"); let err = fetcher .get(&missing) @@ -190,8 +123,6 @@ mod tests { assert!(err.contains("404"), "{err}"); } - /// The proxy/TLS case gets its own wording because the fix is completely - /// different from "try again later". #[test] fn tls_failures_name_the_likely_cause() { let msg = describe("https://example/x", "invalid peer certificate"); diff --git a/crates/tty7-core/src/daemon/install/mod.rs b/crates/tty7-core/src/daemon/install/mod.rs index f2aef501..574f9e3e 100644 --- a/crates/tty7-core/src/daemon/install/mod.rs +++ b/crates/tty7-core/src/daemon/install/mod.rs @@ -1,49 +1,3 @@ -//! Installing, launching and dialect-matching `tty7-server` on a remote machine. -//! -//! The six steps, in order: -//! -//! | | Step | Where | -//! |---|---|---| -//! | 1 | `uname -sm` → the release asset that runs there | [`asset::asset_for_uname`] | -//! | 2 | SFTP-stat `~/.local/share/tty7/bin/tty7-server-c<control>p<protocol>` | [`Installer::run`] | -//! | 3 | absent → download the asset **on the client** + sha256-verify it | [`download`], [`checksums`] | -//! | 4 | SFTP-put into `bin/.tty7-server-c<c>p<p>.<pid>.tmp` | [`RemoteOps::put`] | -//! | 5 | `chmod 0755`, `--protocol` to earn the name, then `rename` — atomic publish | [`RemoteOps::rename`] | -//! | 6 | probe the remote control socket; nothing there → launch a detached daemon | [`Installer::ensure_daemon`] | -//! -//! ## Why the client downloads -//! -//! Design D5: the client fetches the binary over HTTPS and pushes it over the -//! existing SSH connection, rather than having the remote `curl` it. Machines -//! behind a jump host or on an air-gapped internal network cannot reach GitHub — -//! and those are a large share of the machines this feature exists for. The -//! client always can, because it just downloaded its own copy of tty7 the same -//! way. -//! -//! ## …except for WSL, which downloads nothing -//! -//! A WSL distro is served the Linux binary the -//! *Windows client already shipped with*, not one fetched from a release. Both -//! paths meet at [`ServerBinarySource`] — [`ReleaseDownload`] for a real remote, -//! [`wsl::BundledServerBinary`] for a distro on this machine — so steps 2 and -//! 4-6 (stat, upload, atomic publish, launch) are literally the same code, and -//! only "where do the bytes come from" differs. See [`wsl`]. -//! -//! ## What is injected, and why -//! -//! Three seams — [`RemoteOps`] (SSH/SFTP), [`AssetFetcher`] (HTTPS), and -//! [`InstallConfirm`] (the user) — so the whole flow can be driven by fakes. The -//! failure paths that matter most here (a sha256 mismatch, a full disk, a refused -//! consent) are exactly the ones you cannot conjure on a real machine on demand, -//! so they have to be reachable without one. -//! -//! ## Scope -//! -//! Nothing here uses `sudo` or writes outside `$HOME`. Nothing here opens -//! the workspace link either: this module's contract with the transport -//! (`remote_link` / the SSH router) is exactly [`ensure_remote_server`] — call it, -//! and on `Ok` the far end has the right binary installed and a daemon serving. - use std::io; use std::sync::{Arc, Mutex, OnceLock}; use std::time::{Duration, Instant}; @@ -60,42 +14,19 @@ pub use checksums::ChecksumError; use crate::daemon::ssh::SshConnection; -/// The client version, which is also the version of the server it installs — -/// client and server ship from the same workspace version. -/// -/// Names the release to download and labels this client in a prompt, and that is -/// all it may be used for. **"Which server matches me" is a question about -/// dialects**, not about this string; two builds between releases share it and -/// need not speak to each other. See [`asset::binary_name`]. pub fn client_version() -> &'static str { env!("CARGO_PKG_VERSION") } -/// Mode bits for the installed binary: owner-executable, world-readable. Not -/// 0700 — the *directory* is 0700, which is what actually scopes access, and a -/// 0755 binary matches what every other user-local install looks like. const BINARY_MODE: u32 = 0o755; -/// Mode bits for every directory we create (directories 0700). const DIR_MODE: u32 = 0o700; -/// How long a freshly launched remote daemon gets to start answering on its -/// control socket. Longer than the local [`crate::daemon::spawn`] budget: every -/// probe is an SSH round trip, and the far end may be a loaded or distant box. const REMOTE_STARTUP_TIMEOUT: Duration = Duration::from_secs(15); -/// Gap between probes while waiting for a launched daemon. const REMOTE_POLL_INTERVAL: Duration = Duration::from_millis(400); -/// How long a remote daemon asked to stop gets before we conclude it will not. const REMOTE_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(10); -// --------------------------------------------------------------------------- -// Injection seams. -// --------------------------------------------------------------------------- - -/// The result of running one command on the remote machine. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ExecOutput { - /// The command's exit status, or `None` if the channel closed without one - /// (killed by a signal, or a server that does not report status). pub status: Option<u32>, pub stdout: String, pub stderr: String, @@ -106,8 +37,6 @@ impl ExecOutput { self.status == Some(0) } - /// The most informative one-line reason a command failed, for error - /// messages: stderr if it said anything, else the exit status. pub(crate) fn failure_reason(&self) -> String { let stderr = self.stderr.trim(); if !stderr.is_empty() { @@ -120,7 +49,6 @@ impl ExecOutput { } } -/// What a remote path is, if it is anything. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct RemoteStat { pub size: u64, @@ -128,49 +56,17 @@ pub struct RemoteStat { pub is_dir: bool, } -/// Everything the installer needs to do *on the remote machine*: run a command -/// and manipulate files. Implemented for real over SSH + SFTP in [`ssh_ops`], -/// and by an in-memory fake in this module's tests. -/// -/// Errors are strings because that is what the SFTP layer produces and because -/// every one of them is destined for a user-visible message that quotes the -/// server's own wording — "Failure" from a full disk, "Permission denied" from a -/// read-only home. Classification back into structure happens in -/// [`InstallError`], where the path is known too. pub trait RemoteOps: Send + Sync { - /// The remote user's home directory, absolute. SFTP has no `~` expansion, so - /// every path the installer builds starts here. fn home_dir(&self) -> Result<String, String>; - /// Run `cmd` through the remote's shell and collect its output. fn run(&self, cmd: &str) -> Result<ExecOutput, String>; - /// Start `cmd` and return as soon as it has been accepted, without waiting - /// for it to exit. Used only for the daemon launch, which by design never - /// exits. fn spawn_detached(&self, cmd: &str) -> Result<(), String>; - /// A wait to run in the *same* invocation as the daemon launch, right after - /// the launch line, for a transport that cannot let the invocation return - /// while the daemon is still fragile. `binary` is the server just launched. - /// - /// Defaulted to nothing, because SSH needs nothing: closing an exec channel - /// is unhurried and a backgrounded daemon survives it. WSL is the one - /// transport that does — see [`wsl::launch_settle`](super::install::wsl). fn launch_settle(&self, _binary: &str) -> Option<String> { None } - /// `stat` following symlinks. `Ok(None)` means "not there", which is a normal - /// answer, not an error. fn stat(&self, path: &str) -> Result<Option<RemoteStat>, String>; - /// Create one directory. Succeeding when it already exists is the - /// implementation's job (SFTP servers disagree about which status they - /// return for that). fn mkdir(&self, path: &str) -> Result<(), String>; fn chmod(&self, path: &str, mode: u32) -> Result<(), String>; - /// Write `bytes` to `path`, truncating anything already there. fn put(&self, path: &str, bytes: &[u8]) -> Result<(), String>; - /// [`put`](Self::put), calling `on_progress(written)` as the write - /// advances. Defaulted to plain `put` for the same reason as - /// [`AssetFetcher::get_with_progress`]: an in-memory fake writes all of it - /// at once and has no intermediate state to report. fn put_with_progress( &self, path: &str, @@ -183,27 +79,14 @@ pub trait RemoteOps: Send + Sync { } result } - /// Rename `from` over `to`. Same directory, so same filesystem, so atomic. fn rename(&self, from: &str, to: &str) -> Result<(), String>; fn remove_file(&self, path: &str) -> Result<(), String>; - /// Entry names (not paths) in a directory. `Ok(None)` if it does not exist. fn list_dir(&self, path: &str) -> Result<Option<Vec<String>>, String>; } -/// Fetches a release asset over HTTPS. The seam exists so the whole install -/// flow is testable without a network, and so the HTTP client stays behind one -/// small interface that `tty7-server` never links (see the `remote-install` -/// feature). pub trait AssetFetcher: Send + Sync { fn get(&self, url: &str) -> Result<Vec<u8>, String>; - /// [`get`](Self::get), calling `on_progress(done, total)` as the body - /// arrives. `total` is the `Content-Length` when the server sent one. - /// - /// Defaulted to plain `get` so a fetcher that has nothing useful to say - /// mid-transfer — every fake in the tests, and the checksums fetch, which is - /// under a kilobyte — implements one method, not two. The real - /// [`HttpsFetcher`](download::HttpsFetcher) overrides it. fn get_with_progress( &self, url: &str, @@ -214,18 +97,11 @@ pub trait AssetFetcher: Send + Sync { } } -/// A verified server binary, and where it came from. pub struct LoadedBinary { pub bytes: Vec<u8>, - /// Human-readable provenance, quoted verbatim in the consent prompt: a - /// release URL for a downloaded asset, an absolute local path for a bundled - /// one. The user is being asked to approve a write, and "from where" is half - /// of what makes that question answerable. pub origin: String, } -/// Length and provenance, never the bytes: a derived `Debug` here would put six -/// megabytes of ELF into a log line or a test failure. impl std::fmt::Debug for LoadedBinary { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("LoadedBinary") @@ -235,28 +111,9 @@ impl std::fmt::Debug for LoadedBinary { } } -/// Where the bytes of a server binary come from. -/// -/// Three implementations: -/// -/// | Source | Used for | Integrity | -/// |---|---|---| -/// | [`ReleaseDownload`] | Any real remote | sha256 against the release's `checksums.txt` | -/// | [`wsl::BundledServerBinary`] | A WSL distro on this machine | The bytes shipped inside this install; whatever verified *this* client covers them | -/// | [`BundledOrRelease`] | A real remote when a local binary is on hand | The local copy if there is one, else the release's sha256 | -/// -/// A WSL distro downloading its own copy would be absurd — the client is on the -/// same disk, often on a machine that reaches GitHub only through the proxy the -/// user is trying to escape — and a checksum manifest for a file we shipped -/// ourselves would verify nothing that the client's own signature did not. pub trait ServerBinarySource: Send + Sync { fn load(&self, version: &str, asset: &'static str) -> Result<LoadedBinary, InstallError>; - /// [`load`](Self::load), reporting bytes as they arrive. - /// - /// Defaulted to plain `load` because only one of the three sources has a - /// transfer worth watching: [`wsl::BundledServerBinary`] reads a local file - /// and is done before a bar could paint. fn load_with_progress( &self, version: &str, @@ -268,27 +125,8 @@ pub trait ServerBinarySource: Send + Sync { } } -/// A local binary if [`wsl::BUNDLED_DIR_ENV`] names a directory holding one, -/// otherwise the release download. -/// -/// Design D5 chose "client downloads, client uploads" because the *remote* is -/// often walled off from GitHub. But the client can be walled off too — an -/// air-gapped laptop, a TLS-intercepting corporate proxy (`ureq` trusts webpki -/// roots, not the system store), or simply a build with no published release, -/// which is every developer build and every `cargo install` from source. In all -/// of those the bytes are already on the disk and the download is the only thing -/// standing in the way. -/// -/// **Opt-in, and never silent.** With the variable unset this is byte-for-byte -/// `ReleaseDownload`. Pointing it somewhere means "I vouch for these bytes": -/// there is no `checksums.txt` to verify a file the user placed by hand -/// against, exactly as with the WSL bundle. The install prompt still names the -/// origin, so the choice is visible at the moment it matters. pub struct BundledOrRelease<'a> { pub fetch: &'a dyn AssetFetcher, - /// Resolved once, at construction, rather than read from the environment - /// per call — so the choice is a value the tests can hand in, and a - /// mid-install change to the variable cannot make two steps disagree. pub bundled: Option<wsl::BundledServerBinary>, } @@ -313,10 +151,6 @@ impl ServerBinarySource for BundledOrRelease<'_> { on_progress: &dyn Fn(u64, Option<u64>), ) -> Result<LoadedBinary, InstallError> { match &self.bundled { - // A named directory that does *not* hold this asset is an error, not - // a reason to fall back: someone who set the variable meant to - // install from it, and quietly downloading instead would defeat - // whichever of the reasons above they set it for. Some(bundled) => bundled.load(version, asset), None => ReleaseDownload { fetch: self.fetch }.load_with_progress( version, @@ -327,9 +161,6 @@ impl ServerBinarySource for BundledOrRelease<'_> { } } -/// The default source: fetch the release asset and its `checksums.txt` over -/// HTTPS, and verify one against the other before anything is written or the -/// user is asked. pub struct ReleaseDownload<'a> { pub fetch: &'a dyn AssetFetcher, } @@ -347,9 +178,6 @@ impl ServerBinarySource for ReleaseDownload<'_> { ) -> Result<LoadedBinary, InstallError> { let tag = asset::release_tag(version); let manifest_url = asset::download_url(&tag, asset::CHECKSUMS_ASSET); - // Not reported: `checksums.txt` is under a kilobyte, and a bar that - // jumped to 100% for it before restarting for the real asset would read - // as a stall rather than as two files. let manifest = self .fetch .get(&manifest_url) @@ -379,62 +207,27 @@ impl ServerBinarySource for ReleaseDownload<'_> { } } -// --------------------------------------------------------------------------- -// Consent — the decision point M5's UI plugs into. -// --------------------------------------------------------------------------- - -/// Everything the user needs to answer "may tty7 write a binary onto this -/// machine?": what, where, how big, and where it came from. -/// -/// `size_bytes` is the size of the bytes *already downloaded and verified*, not -/// a `Content-Length` guess — by the time this is raised the download has -/// happened and its sha256 matched, so the number quoted is exactly what will be -/// written. #[derive(Debug, Clone, PartialEq, Eq)] pub struct InstallRequest { - /// A human label for the machine (`me@build-box:22`), for the prompt title. pub host: String, - /// The version about to be installed (= the client's own version). pub version: String, - /// The release asset name, e.g. `tty7-server-linux-x86_64-musl`. pub asset: &'static str, - /// The URL it was downloaded from. pub source_url: String, - /// Absolute remote path it will be published at. pub remote_path: String, - /// Exact byte count that will be written. pub size_bytes: u64, - /// Lowercase hex sha256 of those bytes, as published in `checksums.txt` and - /// as verified locally. Shown so a cautious user can check it by hand. pub sha256: String, } -/// The user's answer. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum InstallDecision { Approve, Decline, } -/// Asks the user whether to write a server binary onto a machine for the first -/// time ("往别人机器上写二进制值得问一次"). -/// -/// **Only the first install on a given machine asks.** "First" is decided from -/// evidence on the remote itself — an empty (or absent) -/// `~/.local/share/tty7/bin` — rather than from client-side bookkeeping, so a -/// reinstalled client, a second laptop, or a wiped config dir does not re-ask -/// about a machine tty7 has demonstrably already written to. Later version -/// upgrades on that machine are silent, which is the design's explicit intent. -/// -/// The default implementation ([`DenyInstall`]) **declines**. A headless daemon -/// with no UI attached must not decide on the user's behalf that writing to -/// their servers is fine; it fails with a message that says consent was never -/// obtained. M5's GUI registers a real one with [`set_install_confirm`]. pub trait InstallConfirm: Send + Sync { fn confirm(&self, request: &InstallRequest) -> InstallDecision; } -/// The default: no UI, no consent, no install. pub struct DenyInstall; impl InstallConfirm for DenyInstall { @@ -449,8 +242,6 @@ fn confirm_slot() -> &'static Mutex<Arc<dyn InstallConfirm>> { CONFIRM.get_or_init(|| Mutex::new(Arc::new(DenyInstall))) } -/// Register the confirmation handler. Called once by the GUI at startup; last -/// call wins so a test can install its own. pub fn set_install_confirm(confirm: Arc<dyn InstallConfirm>) { if let Ok(mut slot) = confirm_slot().lock() { *slot = confirm; @@ -458,30 +249,10 @@ pub fn set_install_confirm(confirm: Arc<dyn InstallConfirm>) { } thread_local! { - /// A confirmation handler that outranks [`CONFIRM`] for the duration of one - /// call, on one thread. See [`with_install_confirm`]. - static SCOPED_CONFIRM: std::cell::RefCell<Option<Arc<dyn InstallConfirm>>> = + static SCOPED_CONFIRM: std::cell::RefCell<Option<Arc<dyn InstallConfirm>>> = const { std::cell::RefCell::new(None) }; } -/// Run `f` with `confirm` answering any install prompt it raises, then put the -/// previous handler back. -/// -/// **Why a thread-local and not just [`set_install_confirm`].** The process-wide -/// slot is the right shape for the GUI, which has exactly one user and one -/// answer for all of them. It is the wrong shape for the *daemon*, where the -/// only handler that can reach a user is one bound to a particular routed -/// connection — the client on the other end of it. Two workspaces connecting to -/// two machines at once each need their own, and a global would give the second -/// one's prompt to the first one's socket. -/// -/// A thread-local works because [`Installer`] is blocking start to finish: the -/// consent question is asked on the same thread that will write the bytes. The -/// router's relay therefore wraps its `spawn_blocking` body in this, and -/// [`install_confirm`] finds it before it looks at the global. -/// -/// Nesting restores rather than clears, so a GUI-process call inside a scoped -/// one (there are none today) would not silently lose its handler. pub fn with_install_confirm<T>(confirm: Arc<dyn InstallConfirm>, f: impl FnOnce() -> T) -> T { let previous = SCOPED_CONFIRM.with(|slot| slot.borrow_mut().replace(confirm)); let out = f(); @@ -489,9 +260,6 @@ pub fn with_install_confirm<T>(confirm: Arc<dyn InstallConfirm>, f: impl FnOnce( out } -/// The confirmation handler in force: this thread's scoped one -/// ([`with_install_confirm`]) if there is one, else the process-wide one -/// ([`set_install_confirm`]), else [`DenyInstall`]. pub fn install_confirm() -> Arc<dyn InstallConfirm> { if let Some(scoped) = SCOPED_CONFIRM.with(|slot| slot.borrow().clone()) { return scoped; @@ -502,58 +270,19 @@ pub fn install_confirm() -> Arc<dyn InstallConfirm> { .unwrap_or_else(|_| Arc::new(DenyInstall)) } -// --------------------------------------------------------------------------- -// Install progress -// --------------------------------------------------------------------------- - -/// How far a first install has got, in bytes. -/// -/// Only the two steps that take real time appear. `uname`, `stat`, `mkdir`, -/// `chmod` and the rename are single round trips: a phase for each would flicker -/// past faster than it could be read, and a progress display that spends most of -/// its life on two steps is better off saying which of the two it is on. -/// -/// The byte counts are of the *asset*, so `Uploading` restarts at zero rather -/// than continuing where `Downloading` left off. Two bars' worth of work shown -/// as one 0-200% sweep would be worse; two named phases each running 0-100% is -/// what the user is actually waiting through. -/// -/// Serialisable because the install runs in the **daemon** and the user is in -/// the GUI: this crosses the routed connection as a -/// [`RoutePrompt::InstallProgress`](crate::daemon::router::RoutePrompt) frame. -/// Unlike [`InstallRequest`] it needs no wire twin — every field is already a -/// plain number. #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "snake_case")] pub enum InstallPhase { - /// Fetching the asset onto *this* machine over HTTPS. - /// - /// `total` is `None` when the server sent no `Content-Length` — rare for a - /// release asset, but a chunked response is legal and a progress sink that - /// cannot represent "unknown total" would have to invent one. Downloading { done: u64, total: Option<u64> }, - /// Writing the verified bytes to the remote over SFTP. `total` is exact: - /// the bytes are in memory by now. Uploading { done: u64, total: u64 }, - /// Stopping the server that was running and starting the one we want, with - /// no bytes involved either way. - /// - /// Carries no counts because there is nothing to count: it is a SIGTERM, a - /// poll until the socket goes quiet, a launch, and a poll until it answers — - /// up to `REMOTE_SHUTDOWN_TIMEOUT + REMOTE_STARTUP_TIMEOUT` of a GUI that - /// would otherwise sit there looking like nothing had been clicked. Reported - /// precisely because the case it exists for ("Replace Server" onto a binary - /// already present) transfers nothing and so would report nothing at all. Restarting, } impl InstallPhase { - /// Fraction complete in `0.0..=1.0`, or `None` when the total is unknown. pub fn fraction(&self) -> Option<f32> { let (done, total) = match *self { InstallPhase::Downloading { done, total } => (done, total?), InstallPhase::Uploading { done, total } => (done, total), - // Indeterminate by nature: the wait is two timeouts, not a transfer. InstallPhase::Restarting => return None, }; if total == 0 { @@ -563,30 +292,10 @@ impl InstallPhase { } } -/// Watches an install go by (the first install writes ~8 MB across -/// two network hops, and a client that says only "connecting…" for the length of -/// both is indistinguishable from one that has hung). -/// -/// **Reports are frequent and must be cheap.** One arrives per transfer chunk — -/// hundreds over a single install — so an implementation stores the latest and -/// returns. It must not block, lock anything a UI thread holds, or do IO: the -/// thread calling this is the one moving the bytes. -/// -/// **Nothing here affects the install.** It is a side channel, which is why -/// [`Installer`] reaches for it through the global rather than carrying it as a -/// field the way it carries [`InstallConfirm`] — a sink cannot change what gets -/// written, so it does not belong in the constructor every caller and fake has -/// to satisfy. -/// -/// The default ([`SilentProgress`]) drops everything, which is the right -/// behaviour for a headless daemon with nobody watching. pub trait InstallProgress: Send + Sync { - /// `host` is the same label [`InstallRequest::host`] carries, so a sink - /// serving several machines can tell them apart. fn report(&self, host: &str, phase: InstallPhase); } -/// The default: nobody is watching, so nothing is recorded. pub struct SilentProgress; impl InstallProgress for SilentProgress { @@ -599,8 +308,6 @@ fn progress_slot() -> &'static Mutex<Arc<dyn InstallProgress>> { PROGRESS.get_or_init(|| Mutex::new(Arc::new(SilentProgress))) } -/// Register the process-wide progress sink. Called once by the GUI at startup; -/// last call wins. pub fn set_install_progress(progress: Arc<dyn InstallProgress>) { if let Ok(mut slot) = progress_slot().lock() { *slot = progress; @@ -608,19 +315,10 @@ pub fn set_install_progress(progress: Arc<dyn InstallProgress>) { } thread_local! { - /// A sink that outranks [`PROGRESS`] for the duration of one call, on one - /// thread. See [`with_install_progress`]. - static SCOPED_PROGRESS: std::cell::RefCell<Option<Arc<dyn InstallProgress>>> = + static SCOPED_PROGRESS: std::cell::RefCell<Option<Arc<dyn InstallProgress>>> = const { std::cell::RefCell::new(None) }; } -/// Run `f` with `progress` receiving any install it drives, then put the -/// previous sink back. -/// -/// The same shape, and the same reason, as [`with_install_confirm`]: in the -/// daemon the only sink that can reach a user is one bound to a particular -/// routed connection, and two machines installing at once through a global would -/// report both machines' bytes to whichever client asked last. pub fn with_install_progress<T>(progress: Arc<dyn InstallProgress>, f: impl FnOnce() -> T) -> T { let previous = SCOPED_PROGRESS.with(|slot| slot.borrow_mut().replace(progress)); let out = f(); @@ -628,8 +326,6 @@ pub fn with_install_progress<T>(progress: Arc<dyn InstallProgress>, f: impl FnOn out } -/// The progress sink in force: this thread's scoped one, else the process-wide -/// one, else [`SilentProgress`]. pub fn install_progress() -> Arc<dyn InstallProgress> { if let Some(scoped) = SCOPED_PROGRESS.with(|slot| slot.borrow().clone()) { return scoped; @@ -640,46 +336,16 @@ pub fn install_progress() -> Arc<dyn InstallProgress> { .unwrap_or_else(|_| Arc::new(SilentProgress)) } -// --------------------------------------------------------------------------- -// Asking a server binary what it speaks -// --------------------------------------------------------------------------- - -/// The flag that makes a `tty7-server` print [`RemoteProtocol`] and exit. -/// -/// A *file*, not a running daemon: the numbers are compile-time constants, so -/// this answers "what would this binary speak" without a socket, a handshake, or -/// anything already being up. That is what lets the installer decide whether to -/// write 8 MB **before** writing it. -/// -/// Servers older than this flag print usage to stderr and exit non-zero, which -/// [`Installer::probe_protocol`] reads as "no opinion" — the same conservative -/// answer an unreadable `/proc` gets. pub const PROTOCOL_FLAG: &str = "--protocol"; -/// What a `tty7-server` binary speaks, as it reports itself. -/// -/// The remote counterpart of [`DaemonVersion`](crate::daemon::protocol::DaemonVersion), -/// and deliberately the same shape: two dialect numbers that decide -/// compatibility, plus a build string that decides nothing. #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct RemoteProtocol { - /// [`crate::daemon::control::CONTROL_VERSION`] — the control dialect, which - /// is what a remote *workspace* runs on. pub control: u32, - /// [`crate::daemon::protocol::PROTOCOL_VERSION`] — the pane dialect, which - /// is what a routed *pane* on that machine runs on. pub protocol: u32, - /// `CARGO_PKG_VERSION`. **Display only** — same rule as - /// [`DaemonVersion::build`](crate::daemon::protocol::DaemonVersion::build) - /// and [`ControlHelloOk::build`](crate::daemon::control::ControlHelloOk::build). - /// Two builds that speak the same numbers are interchangeable no matter what - /// their version strings say, and treating a version string as a dialect is - /// exactly the bug this type exists to end. pub build: String, } impl RemoteProtocol { - /// What this client speaks. pub fn of_this_build() -> RemoteProtocol { RemoteProtocol { control: crate::daemon::control::CONTROL_VERSION, @@ -688,97 +354,39 @@ impl RemoteProtocol { } } - /// The two numbers that decide everything, without the build string that - /// decides nothing. This is what names the installed file — see - /// [`asset::binary_name`]. pub fn dialect(&self) -> (u32, u32) { (self.control, self.protocol) } - /// Whether a server speaking `self` can serve a client speaking `other`. - /// - /// **Both numbers, both exactly equal** — the same judgement - /// `spawn::ensure_running` makes locally (`v.protocol == PROTOCOL_VERSION`), - /// applied to both dialects because a remote workspace uses both: control - /// for the workspace itself, pane for every terminal in it. - /// - /// Equality rather than `>=` deliberately. A newer server is not - /// automatically able to speak an older client's dialect, and guessing that - /// it can turns a clean prompt into a wire error halfway through a session. pub fn serves(&self, other: &RemoteProtocol) -> bool { self.control == other.control && self.protocol == other.protocol } - /// The single line a server prints for [`PROTOCOL_FLAG`]. - /// - /// Paired with [`parse`](Self::parse) here rather than left to each side's - /// own `serde_json` call: the writer is `tty7-server` and the reader is the - /// client, they ship separately and meet over SSH, and one shared function - /// is what stops the format drifting between them. pub fn to_line(&self) -> String { - // Infallible in practice — three plain fields — and a server that could - // not describe itself should still exit cleanly rather than make the - // caller handle an error that cannot happen. serde_json::to_string(self).unwrap_or_default() } - /// Parse one from a probe's stdout. - /// - /// Takes the **last** non-blank line: a login shell that prints a banner - /// from `.bashrc` would otherwise poison an otherwise fine answer, and the - /// server writes its line last because it writes it at exit. pub fn parse(stdout: &str) -> Option<RemoteProtocol> { let line = stdout.lines().rev().find(|l| !l.trim().is_empty())?; serde_json::from_str(line.trim()).ok() } } -// --------------------------------------------------------------------------- -// Version negotiation (mirroring `spawn::ensure_running`). -// --------------------------------------------------------------------------- - -/// A remote daemon that is serving a machine at a *different* build than the -/// client we are running. -/// -/// The local analogue is [`crate::daemon::spawn::MismatchedDaemon`], and the -/// reasoning is identical: that daemon owns every live pane on that machine, so -/// killing it at connect time would silently destroy running work. We keep using -/// it and record the mismatch here; the GUI raises the keep-or-restart prompt and -/// calls [`restart_remote_daemon`] if the user picks restart. -/// -/// Binaries coexist (the install path carries the version), so "restart" means -/// only that the *running process* is replaced — the old binary stays on disk. #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct MismatchedRemoteDaemon { - /// Machine label, matching [`InstallRequest::host`]. pub host: String, - /// The version the running daemon's executable path encodes, or `None` when - /// it could not be read (a locked-down `/proc`, a hand-placed binary). pub running_version: Option<String>, - /// The executable path the running daemon was launched from, when known. pub running_exe: Option<String>, - /// The version we installed and would rather it were. pub wanted_version: String, } static MISMATCHED: Mutex<Vec<MismatchedRemoteDaemon>> = Mutex::new(Vec::new()); thread_local! { - /// Where mismatches found on this thread go instead of [`MISMATCHED`]. - /// See [`with_mismatch_sink`]. - static SCOPED_MISMATCH: std::cell::RefCell<Option<Arc<Mutex<Vec<MismatchedRemoteDaemon>>>>> = + static SCOPED_MISMATCH: std::cell::RefCell<Option<Arc<Mutex<Vec<MismatchedRemoteDaemon>>>>> = const { std::cell::RefCell::new(None) }; } -/// Run `f` with any mismatch it discovers collected into `sink` rather than into -/// the process-wide registry. -/// -/// The counterpart of [`with_install_confirm`], and for the same reason: -/// [`take_mismatched_remote_daemons`] reads a static in *this* process, so a -/// mismatch the daemon finds while opening a routed connection is invisible to -/// the GUI that asked for it. Scoped, it can be handed to the client that is -/// waiting on the other end of that very connection, which is the only party -/// that can answer "keep the old sessions or restart the service?". pub fn with_mismatch_sink<T>( sink: Arc<Mutex<Vec<MismatchedRemoteDaemon>>>, f: impl FnOnce() -> T, @@ -801,29 +409,18 @@ fn record_mismatch(entry: MismatchedRemoteDaemon) { let Ok(mut slot) = MISMATCHED.lock() else { return; }; - // One entry per host: reconnecting to the same machine repeatedly must not - // queue up a prompt per attempt. if slot.iter().any(|e| e.host == entry.host) { return; } slot.push(entry); } -/// File mismatches discovered in *another* process into this one's registry. -/// -/// The relay's landing point (`daemon::router`): the daemon finds the mismatch, -/// the GUI is the process with the keep-or-restart prompt, and -/// [`take_mismatched_remote_daemons`] only ever reads a local static. Without -/// this the consent prompt could not fire at all. pub fn record_remote_mismatches(entries: Vec<MismatchedRemoteDaemon>) { for entry in entries { record_mismatch(entry); } } -/// Remote daemons found running at a different build than this client. Take -/// semantics, so the keep-or-restart prompt fires once per discovery rather than -/// once per window. pub fn take_mismatched_remote_daemons() -> Vec<MismatchedRemoteDaemon> { MISMATCHED .lock() @@ -831,60 +428,32 @@ pub fn take_mismatched_remote_daemons() -> Vec<MismatchedRemoteDaemon> { .unwrap_or_default() } -// --------------------------------------------------------------------------- -// Errors (specific, path-bearing, never retried into a different path). -// --------------------------------------------------------------------------- - #[derive(Debug)] pub enum InstallError { - /// `uname -sm` could not be run at all. Probe(String), - /// It ran, and the answer is a machine we do not publish for. Unsupported(UnsupportedTarget), - /// The remote home directory could not be resolved, so no path can be built. NoHome(String), - /// The download failed (network, 404 for a tag that was never published, a - /// proxy). Carries the URL, because "which release did it even look for" is - /// the first question. - Download { url: String, reason: String }, - /// sha256 verification failed. Terminal: no retry, no unverified fallback. + Download { + url: String, + reason: String, + }, Checksum(ChecksumError), - /// A WSL install found no bundled Linux server binary in this client's own - /// installation. Terminal, and deliberately **not** downgraded to a - /// download: a WSL distro is served the binary the client - /// shipped with, and silently reaching for GitHub instead would turn a - /// packaging bug into an intermittent network failure on someone else's - /// machine. Names every directory that was looked in, because the fix is - /// always "the installer did not ship the file". MissingBundled { asset: &'static str, searched: Vec<String>, }, - /// The user was asked and said no. - Declined { host: String, path: String }, - /// A write to the remote failed — full disk, read-only home, no permission. - /// Reports the exact path and the server's own reason, and is **not** - /// retried anywhere else ("不重试,不降级到别的路径"). - Write { path: String, reason: String }, - /// The daemon would not start, or would not answer after starting. - Launch { reason: String }, - /// The bytes were uploaded, made executable, asked what they speak — and - /// answered with something other than the dialect the filename they were - /// about to be published under promises. - /// - /// Terminal, and the temp file is removed rather than published. This is the - /// check that makes "the filename is the dialect" a fact instead of a - /// convention: without it a source that hands over the wrong build (a - /// [`wsl::BUNDLED_DIR_ENV`] pointing at a stale cross-compile, a release tag - /// that predates a wire break) writes a file that lies, and the *next* - /// connect trusts the name and fails in the handshake with nothing to - /// blame. - /// - /// `spoke` is `None` when the binary could not answer at all — it did not - /// exec, or it is older than [`PROTOCOL_FLAG`]. Both mean the same thing - /// here: nothing may be published under a name that has not been earned. + Declined { + host: String, + path: String, + }, + Write { + path: String, + reason: String, + }, + Launch { + reason: String, + }, DialectMismatch { - /// Where the bytes came from, so the message can name the thing to fix. origin: String, wanted: RemoteProtocol, spoke: Option<RemoteProtocol>, @@ -961,60 +530,25 @@ impl From<InstallError> for io::Error { } } -// --------------------------------------------------------------------------- -// Outcome. -// --------------------------------------------------------------------------- - -/// What [`Installer::run`] actually did, for logs and tests. #[derive(Debug, Clone, PartialEq, Eq)] pub struct InstallReport { pub asset: &'static str, pub paths: RemotePaths, - /// Whether bytes were transferred (false when the right version was already - /// installed). pub installed: bool, - /// Whether the user was asked (only ever true on a machine with no prior - /// tty7 install). pub confirmed: bool, - /// Whether a daemon had to be launched (false when one was already serving). pub launched: bool, - /// Set when a daemon that **cannot serve this client** is on the machine. - /// - /// A different build is not a mismatch — a different *dialect* is. See - /// [`Installer::check_running_build`]. pub mismatch: Option<MismatchedRemoteDaemon>, - /// The already-running server this connect adopted instead of installing, - /// when its dialects matched ours despite a different build. - /// - /// `Some` is the case always intended and the implementation - /// missed: a 26.7.6 client meeting a 26.7.7 server they both speak. Recorded - /// because "we deliberately did not install" is otherwise indistinguishable - /// in a log from "we forgot to". pub reused: Option<RemoteProtocol>, } -// --------------------------------------------------------------------------- -// The flow. -// --------------------------------------------------------------------------- - -/// Runs the six steps against injected remote/network/user seams. pub struct Installer<'a> { ops: &'a dyn RemoteOps, - /// Set by [`Installer::new`]; wrapped in a [`ReleaseDownload`] at use. fetch: Option<&'a dyn AssetFetcher>, - /// Set by [`Installer::with_source`], and takes precedence. Exactly one of - /// the two is ever `Some`. source: Option<&'a dyn ServerBinarySource>, confirm: &'a dyn InstallConfirm, host: String, - /// Which release to download, and what to call this client in a prompt. - /// **Never a decision input** — see [`asset::binary_name`]. version: String, - /// What this client speaks, and therefore which file on the remote is the - /// one that can serve it. dialect: RemoteProtocol, - /// Overridable so tests do not spend the real budget waiting for a daemon - /// that a fake will never start. startup_timeout: Duration, poll_interval: Duration, } @@ -1039,10 +573,6 @@ impl<'a> Installer<'a> { } } - /// The same six steps, with `source` deciding where the bytes come from: - /// [`wsl::BundledServerBinary`] for a distro on this machine, - /// [`BundledOrRelease`] for a real remote. A build with no HTTP client at - /// all can still install through the former. pub fn with_source( ops: &'a dyn RemoteOps, source: &'a dyn ServerBinarySource, @@ -1062,56 +592,22 @@ impl<'a> Installer<'a> { } } - /// Download a specific version's release instead of this build's. Tests - /// only. Does **not** move the install path — that follows the dialect. pub fn with_version(mut self, version: impl Into<String>) -> Self { self.version = version.into(); self.dialect.build = self.version.clone(); self } - /// Pretend this client speaks `control`/`protocol`. Tests only — a real - /// client can only speak its own dialect, and every decision in this module - /// keys off it, so the fakes need a way to stand somewhere else. pub fn with_dialect(mut self, control: u32, protocol: u32) -> Self { self.dialect.control = control; self.dialect.protocol = protocol; self } - /// The paths this client's dialect installs to under `home`. fn paths_for(&self, home: &str) -> RemotePaths { asset::remote_paths(home, self.dialect.control, self.dialect.protocol) } - /// Make this machine's *running* server one that speaks to us, installing a - /// binary first only if the one at our dialect's path cannot — "Replace - /// server on this host". - /// - /// The action a failed handshake offers, and it covers both ways a connect - /// can reach a server it cannot talk to: - /// - /// | What is wrong | What this does | - /// |---|---| - /// | An older daemon is serving; our binary is there and fine | Restart onto it. **No download.** | - /// | The binary at our dialect's path is missing, or answers with something else | Install ours, then restart | - /// - /// The first row is the common one and the reason this asks before it - /// downloads: [`run`](Self::run) leaves a machine in exactly that state - /// every time it declines to kill a daemon that owns live panes, so the - /// button under the handshake error must not need a network — or a released - /// asset that speaks our dialect, which for a dev build does not exist — to - /// fix the case it was written for. - /// - /// The second row is the only thing anywhere that overwrites a published - /// binary. Every other path trusts `tty7-server-c<c>p<p>` to speak c/p, - /// because [`install`](Self::install) proves that before publishing it; only - /// something outside tty7 can put a file there that lies, and this is the - /// way out when it does. - /// - /// **Every pane the running server hosts dies**, in both rows, for the same - /// reason as [`restart_daemon`](Self::restart_daemon). Only ever call it - /// with a user's explicit answer behind it. pub fn replace(&self) -> Result<(), InstallError> { let home = self.ops.home_dir().map_err(InstallError::NoHome)?; let paths = self.paths_for(&home); @@ -1135,14 +631,6 @@ impl<'a> Installer<'a> { self.restart_daemon() } - /// Whether the binary at our dialect's path is there, runnable, and really - /// speaks what its name claims. - /// - /// The one place that spends a probe on a `stat` hit. [`run`](Self::run) - /// deliberately does not — it would pay a round trip on every connect to - /// re-check something the install already proved. Here the caller is about - /// to either download 8 MB or drop every pane on the machine, so one - /// question first is cheap by comparison. fn published_binary_serves_us(&self, paths: &RemotePaths) -> Result<bool, InstallError> { let stat = self .ops @@ -1159,19 +647,13 @@ impl<'a> Installer<'a> { .is_some_and(|spoken| spoken.serves(&self.dialect))) } - /// Shorten the daemon-startup budget. Tests only. pub fn with_timeouts(mut self, startup: Duration, poll: Duration) -> Self { self.startup_timeout = startup; self.poll_interval = poll; self } - /// The whole flow. On `Ok`, a `tty7-server` this client can speak to is - /// answering on the machine's control socket — either the one published at - /// `tty7-server-c<control>p<protocol>`, or one that was already running and - /// said it speaks our dialects. pub fn run(&self) -> Result<InstallReport, InstallError> { - // --- 1. uname -sm -------------------------------------------------- let uname = self .ops .run("uname -sm") @@ -1185,12 +667,6 @@ impl<'a> Installer<'a> { })?; let asset = asset::asset_for_uname(&uname).map_err(InstallError::Unsupported)?; - // --- 2. is a server that can serve us already there? ------------------ - // - // One `stat` of a path built entirely from this client's own two - // dialect numbers. Nothing is asked of the remote to decide *which* - // path to look at, which is what keeps this cheap enough to run before - // every link and correct on a machine that cannot reach GitHub. let home = self.ops.home_dir().map_err(InstallError::NoHome)?; let paths = self.paths_for(&home); @@ -1212,18 +688,8 @@ impl<'a> Installer<'a> { reused: None, }; - // A file that exists but is not executable is a half-finished install - // from a crashed run (rename landed, chmod did not) — redo it rather - // than launching something the kernel will refuse. let usable = already.is_some_and(|stat| !stat.is_dir && stat.mode & 0o100 != 0); if !usable { - // --- 3. before writing 8 MB, ask what is already serving ---------- - // - // Version skew is settled by comparing dialects, not - // build strings. Only the *running* server is asked: the socket is - // singular, so a compatible binary that is merely present on disk - // would still have to be started — and starting our own is simpler - // and more predictable than adopting a stranger's file. match self.adoptable_running_server()? { Some((exe, spoken)) => { log::info!( @@ -1251,21 +717,12 @@ impl<'a> Installer<'a> { } } - // --- 6. make sure a daemon is serving -------------------------------- let (launched, mismatch) = self.ensure_daemon(&report.paths)?; report.launched = launched; report.mismatch = mismatch; Ok(report) } - /// The running `tty7-server` on this machine, when it speaks our dialects. - /// - /// `None` covers every reason not to adopt one, and they are deliberately - /// indistinguishable to the caller: nothing running, an unreadable `/proc`, - /// a binary too old to know [`PROTOCOL_FLAG`], or one that answered with - /// dialects we cannot speak. All four mean "install ours", and none of them - /// is an error — a machine we cannot interrogate is a machine we install on, - /// exactly as before this existed. fn adoptable_running_server(&self) -> Result<Option<(String, RemoteProtocol)>, InstallError> { let Some(exe) = self.running_server_exe() else { return Ok(None); @@ -1279,49 +736,32 @@ impl<'a> Installer<'a> { Ok(Some((exe, spoken))) } - /// Ask a server *binary* what it speaks. `None` if it cannot say. - /// - /// Cheap by design: one SSH command against a file, no socket and no daemon, - /// so it can be asked before deciding whether to transfer anything. fn probe_protocol(&self, exe: &str) -> Option<RemoteProtocol> { let cmd = format!("{} {PROTOCOL_FLAG}", shell_quote(exe)); let out = self.ops.run(&cmd).ok()?; if !out.success() { - // A server older than the flag prints usage and exits non-zero. return None; } RemoteProtocol::parse(&out.stdout) } - /// The executable path of this user's running `tty7-server`, if any. fn running_server_exe(&self) -> Option<String> { let out = self.ops.run(RUNNING_EXE_COMMAND).ok()?; let exe = out.stdout.trim(); (!exe.is_empty()).then(|| exe.to_string()) } - /// Steps 3–5: download, verify, confirm, upload, publish. fn install( &self, asset: &'static str, paths: &RemotePaths, ) -> Result<(bool, Vec<u8>), InstallError> { - // --- 3. load + verify, both on the client --------------------------- - // - // Verification happens *before* the user is asked, not after: a prompt - // for an install that could only fail its own integrity check is worse - // than useless, and asking afterwards lets the prompt quote the exact - // byte count and digest rather than a Content-Length promise. let LoadedBinary { bytes, origin: asset_url, } = self.load_binary(asset)?; - // Kept past the consent prompt, which consumes the original: if the - // upload turns out to speak the wrong dialect, "where did these bytes - // come from" is the whole content of the error. let asset_origin = asset_url.clone(); - // --- consent, once per machine -------------------------------------- let confirmed = if self.is_first_install(paths) { let request = InstallRequest { host: self.host.clone(), @@ -1343,21 +783,14 @@ impl<'a> Installer<'a> { false }; - // --- 4. upload to the temp name -------------------------------------- for dir in &paths.dir_chain { self.ops.mkdir(dir).map_err(|reason| InstallError::Write { path: dir.clone(), reason, })?; } - // Best effort: a pre-existing directory we do not own (or a server that - // refuses SETSTAT) must not block an install that will otherwise work. let _ = self.ops.chmod(&paths.bin_dir, DIR_MODE); - // The dialect names one file, so two clients installing the same dialect - // at once would otherwise write the same temp path and interleave their - // bytes into it. The pid makes the staging area private; the final name - // is still the shared one, and `rename` is still what publishes it. let temp = unique_temp(&paths.temp); let sink = install_progress(); @@ -1371,11 +804,6 @@ impl<'a> Installer<'a> { reason, })?; - // --- 5. chmod, ask what it speaks, then rename ----------------------- - // - // chmod *before* the rename, so the binary is never visible at its final - // path in a non-executable state: a concurrent connect that finds - // `tty7-server-c<c>p<p>` present would otherwise try to exec a 0644 file. self.ops .chmod(&temp, BINARY_MODE) .map_err(|reason| InstallError::Write { @@ -1383,13 +811,6 @@ impl<'a> Installer<'a> { reason, })?; - // The file is about to be published under a name that *claims* a - // dialect. Earn the claim: the binary is on the machine and executable, - // so ask it, and publish nothing if the answer is not the one the name - // promises. Also the first moment an architecture mistake can surface as - // itself — a binary for the wrong machine cannot exec, so it cannot - // answer, and it is refused here instead of dying as `Exec format error` - // inside a daemon launch that has no visible connection to `uname`. let spoke = self.probe_protocol(&temp); if !spoke.as_ref().is_some_and(|s| s.serves(&self.dialect)) { let _ = self.ops.remove_file(&temp); @@ -1401,11 +822,6 @@ impl<'a> Installer<'a> { } if let Err(reason) = self.ops.rename(&temp, &paths.binary) { - // Some SFTP servers refuse a rename onto an existing name. The only - // way that path exists here is a leftover from an interrupted run - // (a *usable* binary short-circuits in `run`), so removing it and - // retrying the rename is recovery, not a fallback to a different - // location. let _ = self.ops.remove_file(&paths.binary); self.ops .rename(&temp, &paths.binary) @@ -1418,8 +834,6 @@ impl<'a> Installer<'a> { Ok((confirmed, bytes)) } - /// Where step 3's bytes come from: the injected source if there is one, - /// otherwise a [`ReleaseDownload`] over the injected fetcher. fn load_binary(&self, asset: &'static str) -> Result<LoadedBinary, InstallError> { let sink = install_progress(); let on_progress = |done: u64, total: Option<u64>| { @@ -1429,9 +843,6 @@ impl<'a> Installer<'a> { return source.load_with_progress(&self.version, asset, &on_progress); } let Some(fetch) = self.fetch else { - // Unreachable through either constructor; a plain error rather than - // a panic because an installer is holding someone else's machine - // open when it runs. return Err(InstallError::Download { url: String::new(), reason: "no binary source was configured".to_string(), @@ -1440,12 +851,6 @@ impl<'a> Installer<'a> { ReleaseDownload { fetch }.load_with_progress(&self.version, asset, &on_progress) } - /// Whether tty7 has ever written to this machine, decided from the remote's - /// own state: an absent or empty `bin` directory means no. - /// - /// Erring towards *asking* — an unreadable directory counts as "first" — - /// keeps the failure mode on the side of one extra prompt rather than one - /// silent write to a machine nobody agreed to. fn is_first_install(&self, paths: &RemotePaths) -> bool { match self.ops.list_dir(&paths.bin_dir) { Ok(Some(entries)) => !entries.iter().any(|name| name.starts_with("tty7-server-")), @@ -1454,16 +859,6 @@ impl<'a> Installer<'a> { } } - /// Step 6. Probe the remote control socket; if nothing answers, launch a - /// detached daemon and probe again until it does. - /// - /// The probe is `tty7-server --stdio --bridge` with stdin closed: `--bridge` - /// connects to the machine's control socket and refuses to serve in-process, - /// so its exit status *is* the answer, and a socket file a crash left behind - /// reads as "nothing there" rather than as a live server. Nothing here - /// parses a frame — the protocol handshake is end-to-end between the GUI and - /// the far server, and a second opinion about the version - /// living down here is exactly the coupling that design forbids. fn ensure_daemon( &self, paths: &RemotePaths, @@ -1509,33 +904,12 @@ impl<'a> Installer<'a> { .map_err(|reason| InstallError::Launch { reason }) } - /// Identify the daemon that is actually serving, and record a mismatch only - /// if it **cannot speak to us**. - /// - /// **A different build is not a mismatch.** The rule is to compare - /// `PROTOCOL_VERSION` and keep an older server that is compatible — the same judgement - /// `spawn::ensure_running` makes locally, where a daemon whose `build` - /// differs but whose `protocol` matches is reused in silence. Comparing - /// version *strings* here is what made a 26.7.6 client prompt about a - /// 26.7.7 server it could talk to perfectly well, and made it upload 8 MB to - /// a machine that needed nothing. - /// - /// Failing to read any of it is not an error: an unreadable `/proc`, or a - /// server too old to know [`PROTOCOL_FLAG`], means we have no opinion — and - /// no opinion must never be reported as a mismatch. fn check_running_build(&self, paths: &RemotePaths) -> Option<MismatchedRemoteDaemon> { let exe = self.running_server_exe()?; let exe = exe.as_str(); - // The name carries the dialects, so most of the time the path we already - // had in hand is the whole answer and no second round trip is spent. if asset::dialect_from_path(exe) == Some(self.dialect.dialect()) || exe == paths.binary { return None; } - // Either a dialect that is not ours, or a legacy version-named binary - // that claims nothing. Ask it directly. An unanswerable probe leaves the - // old behaviour in place: a server that predates the flag really might - // not understand us, and the prompt is the honest response to not - // knowing. let spoken = self.probe_protocol(exe); if spoken.as_ref().is_some_and(|s| s.serves(&self.dialect)) { log::info!( @@ -1560,19 +934,11 @@ impl<'a> Installer<'a> { Some(entry) } - /// Replace the running daemon with this client's build — the "restart the - /// service" branch of the version-mismatch prompt. Every pane it is hosting - /// dies; that is what the prompt warns about. pub fn restart_daemon(&self) -> Result<(), InstallError> { let home = self.ops.home_dir().map_err(InstallError::NoHome)?; let paths = self.paths_for(&home); - // Before the first timeout rather than after it: this is the only signal - // the user gets that the click landed. install_progress().report(&self.host, InstallPhase::Restarting); - // SIGTERM by the pid whose executable is a tty7-server: the daemon tears - // down like a local `Shutdown`, hanging every pane's child up with its - // usual grace period. let _ = self.ops.run(TERMINATE_RUNNING_COMMAND); let deadline = Instant::now() + REMOTE_SHUTDOWN_TIMEOUT; @@ -1603,25 +969,10 @@ impl<'a> Installer<'a> { } } -/// Find the executable path of this user's running `tty7-server`, if any. -/// -/// `readlink /proc/<pid>/exe` is readable only for the caller's own processes, -/// which is exactly the scope wanted: one `tty7-server` per user. -/// `|| true` on the loop keeps a `set -e` login shell from turning "no daemon -/// running" into a failed command. const RUNNING_EXE_COMMAND: &str = r#"for p in /proc/[0-9]*; do e=$(readlink "$p/exe" 2>/dev/null) || continue; case "$e" in */tty7-server-*) printf '%s' "${e% (deleted)}"; break;; esac; done; true"#; -/// SIGTERM this user's running `tty7-server`, if any. const TERMINATE_RUNNING_COMMAND: &str = r#"for p in /proc/[0-9]*; do e=$(readlink "$p/exe" 2>/dev/null) || continue; case "$e" in */tty7-server-*) kill -TERM "${p#/proc/}" 2>/dev/null; break;; esac; done; true"#; -/// The command that starts a detached remote daemon. -/// -/// `setsid` puts it in its own session so closing the SSH channel — which sends -/// SIGHUP to the session's foreground group — cannot take the daemon with it, -/// mirroring what `spawn::detach` does locally. Not every minimal image ships -/// `setsid`, so `nohup` is the fallback; both redirect all three streams to -/// `/dev/null`, without which the SSH channel would stay open holding the -/// daemon's inherited stdout for as long as the daemon lives. fn launch_command(binary: &str) -> String { let bin = shell_quote(binary); format!( @@ -1633,13 +984,6 @@ fn launch_command(binary: &str) -> String { ) } -/// The launch line, plus whatever wait the transport asked for in -/// [`RemoteOps::launch_settle`]. -/// -/// A free function so the one thing that must never happen — the wait replacing -/// the launch instead of following it — is testable without a machine of any -/// kind. A daemon that was never launched fails exactly like one that was -/// reaped, so this is cheap to get wrong and expensive to notice. fn launch_script(binary: &str, settle: Option<String>) -> String { let launch = launch_command(binary); match settle { @@ -1648,28 +992,6 @@ fn launch_script(binary: &str, settle: Option<String>) -> String { } } -/// A staging path private to this process, from the shared per-dialect one. -/// -/// `.tty7-server-c3p4.tmp` → `.tty7-server-c3p4.4711.tmp`. Inserted before the -/// suffix rather than appended so the name still ends in `.tmp` and still starts -/// with a dot: both are what keep a half-written upload from being mistaken for -/// an installed server. -/// -/// The litter this can leave (one file per install killed between `put` and -/// `rename`) is the price of the collision it prevents, and it is bounded by how -/// often that happens — which is "almost never", against "every time two clients -/// install the same dialect at once" for the shared name. -/// -/// **A pid, so private to a process and not to a client.** Two tty7 processes on -/// one machine (the released build and the one you are compiling) cannot collide; -/// two on *different* machines that happen to share a pid still can. That -/// remainder is left alone because the `--protocol` check now stands behind it: -/// bytes from two uploads interleaved into one file do not answer with our -/// dialect, so the outcome is a [`InstallError::DialectMismatch`] and a removed -/// temp rather than a published binary that lies. Two installs from *within* one -/// process share a pid and so share this path too — that is what `wsl`'s -/// `INSTALL_LOCKS` and `SshManager`'s per-key `ConnSlot` are for, and this is not -/// a second attempt at their job. fn unique_temp(shared: &str) -> String { let pid = std::process::id(); match shared.strip_suffix(".tmp") { @@ -1678,59 +1000,19 @@ fn unique_temp(shared: &str) -> String { } } -/// POSIX single-quote escaping. Home directories with spaces, apostrophes or -/// `$` in them are rare but real, and every command here interpolates a path. pub(crate) fn shell_quote(s: &str) -> String { format!("'{}'", s.replace('\'', r"'\''")) } -/// A stable label for a connection, for prompts and mismatch records. -/// -/// **This string crosses the process boundary.** It is what -/// [`MismatchedRemoteDaemon::host`] carries to the GUI, and the GUI resolves it -/// back to a machine through -/// [`RouteTarget::origin_key`](crate::daemon::router::RouteTarget::origin_key), -/// which produces the same [`ConnectionKey`](crate::daemon::ssh::ConnectionKey) -/// string for an SSH target. That is how "Restart Server" finds the box it is -/// about. Changing the shape here without changing that one breaks the restart -/// silently, so the two are pinned together by -/// `the_origin_key_of_an_ssh_target_is_its_connection_key`. fn connection_label(conn: &SshConnection) -> String { conn.key().as_str().to_string() } -// --------------------------------------------------------------------------- -// The entry point B1's transport calls. -// --------------------------------------------------------------------------- - -/// Make sure `conn`'s machine has this client's `tty7-server` installed and a -/// daemon serving on its control socket, and answer **where that binary is**. -/// -/// **This is the seam with the SSH transport**: call it before opening a link, -/// and on `Ok` `direct-streamlocal` (or the `--stdio` fallback) has something to -/// reach. It is idempotent and cheap on the common path — an already-installed -/// binary plus a live daemon costs two SSH commands and one SFTP stat, no -/// download, no prompt. -/// -/// The returned path is **absolute and never the bare name** -/// (`~/.local/share/tty7/bin/tty7-server-c<control>p<protocol>`, or the path of a -/// server already running there that answered with our dialects), and the -/// session-channel fallback must use it rather than the bare name. Nothing puts -/// that directory on a non-interactive `PATH`, and the file is not even called -/// `tty7-server` — so `exec tty7-server --stdio` is a `command not found` on a -/// machine where the install just succeeded. -/// -/// A dialect mismatch is *not* an error: an older daemon still owns every live -/// pane on that machine, so it keeps serving and the mismatch is recorded for -/// [`take_mismatched_remote_daemons`] to raise. Only a machine we cannot install -/// on, cannot verify a download for, or cannot get a daemon running on fails. pub fn ensure_remote_server(conn: &Arc<SshConnection>) -> io::Result<String> { let host = connection_label(conn); ensure_remote_server_labeled(conn, &host) } -/// [`ensure_remote_server`] with an explicit machine label for the prompt (the -/// GUI knows the user's own name for a host; the connection key does not). pub fn ensure_remote_server_labeled(conn: &Arc<SshConnection>, host: &str) -> io::Result<String> { let ops = ssh_ops::SshRemoteOps::new(conn.clone()); let fetch = default_fetcher(); @@ -1759,8 +1041,6 @@ pub fn ensure_remote_server_labeled(conn: &Arc<SshConnection>, host: &str) -> io Ok(report.paths.binary) } -/// Restart the remote daemon at this client's build, dropping every pane it -/// hosts. The "restart the service" answer to the dialect-mismatch prompt. pub fn restart_remote_daemon(conn: &Arc<SshConnection>) -> io::Result<()> { let host = connection_label(conn); let ops = ssh_ops::SshRemoteOps::new(conn.clone()); @@ -1770,10 +1050,6 @@ pub fn restart_remote_daemon(conn: &Arc<SshConnection>) -> io::Result<()> { Ok(()) } -/// Reinstall this client's server on `conn`'s machine even though one is -/// already at its path, and restart the daemon onto it. See -/// [`Installer::replace`] — this is what a handshake that failed against a -/// binary whose name lied about its dialect offers as the way out. pub fn replace_remote_server(conn: &Arc<SshConnection>) -> io::Result<()> { let host = connection_label(conn); let ops = ssh_ops::SshRemoteOps::new(conn.clone()); @@ -1784,16 +1060,11 @@ pub fn replace_remote_server(conn: &Arc<SshConnection>) -> io::Result<()> { Ok(()) } -/// The HTTPS fetcher, when this build has one. #[cfg(feature = "remote-install")] fn default_fetcher() -> Arc<dyn AssetFetcher> { Arc::new(download::HttpsFetcher::default()) } -/// A build without the `remote-install` feature — `tty7-server` itself, which -/// links no HTTP client — can still *use* an installed server, but cannot fetch -/// one. Failing here with a plain message beats failing at link time or, worse, -/// pretending the download was attempted. #[cfg(not(feature = "remote-install"))] fn default_fetcher() -> Arc<dyn AssetFetcher> { struct NoFetcher; diff --git a/crates/tty7-core/src/daemon/install/ssh_ops.rs b/crates/tty7-core/src/daemon/install/ssh_ops.rs index e353fc75..72be02e7 100644 --- a/crates/tty7-core/src/daemon/install/ssh_ops.rs +++ b/crates/tty7-core/src/daemon/install/ssh_ops.rs @@ -1,25 +1,3 @@ -//! [`RemoteOps`] over a live [`SshConnection`]: command execution on a session -//! channel, file manipulation over SFTP. -//! -//! This is the only file in `install` that talks to a network. Everything it -//! does is a thin, synchronous wrapper — the installer above it is a state -//! machine, and keeping the IO down here as dumb as possible is what lets that -//! state machine be tested against a fake. -//! -//! ## Why the SFTP work is split -//! -//! `stat` / `mkdir` / `chmod` / `rename` / `remove` / `list` all go through -//! [`SftpManager`], which owns one cached SFTP session per connection — the -//! installer costs no extra channel for them. The **byte write** does not: -//! `SftpManager::start_transfer` is the wrong upload path for an install: it is -//! a background job keyed by `pane_id` that reads from a local *file* and -//! reports progress to the GUI's transfer tray, and an install has no pane, no -//! local file (the bytes are in memory, already verified) and nothing to show -//! in a tray. [`SftpManager::put_bytes`] exists for exactly this shape, so the -//! write shares the connection's cached SFTP session — and its -//! retry-once-on-transport-failure behaviour — rather than opening a channel of -//! its own. - use std::sync::Arc; use std::time::Duration; @@ -30,16 +8,9 @@ use crate::daemon::ssh::{SshConnection, SshManager, sftp::SftpManager}; use super::{ExecOutput, RemoteOps, RemoteStat}; -/// How long any single remote command may take. Generous: `uname` is instant, -/// but the daemon probe opens a socket on a machine that may be busy, and a -/// distant host's round trips add up. Short enough that a hung sshd surfaces as -/// an error rather than as a connect that never returns. const COMMAND_TIMEOUT: Duration = Duration::from_secs(30); -/// Budget for the fire-and-forget daemon launch. The remote shell backgrounds -/// the daemon and exits immediately, so this only has to cover a round trip. const LAUNCH_TIMEOUT: Duration = Duration::from_secs(15); -/// [`RemoteOps`] backed by one authenticated SSH connection. pub struct SshRemoteOps { conn: Arc<SshConnection>, } @@ -49,7 +20,6 @@ impl SshRemoteOps { Self { conn } } - /// Run one SFTP op through the shared, cached session. fn sftp_op(&self, op: SftpOp) -> Result<SftpOpResult, String> { match SftpManager::global().op(&self.conn, &op) { SftpOpResult::Error(e) => Err(e), @@ -64,10 +34,6 @@ impl SshRemoteOps { impl RemoteOps for SshRemoteOps { fn home_dir(&self) -> Result<String, String> { - // SFTP's REALPATH against the session's own working directory, which is - // the login directory. The same trick the file browser uses to open - // somewhere better than `/`, and the only way to learn `$HOME` without - // trusting a shell to have one set. match self.sftp_op(SftpOp::Realpath { path: ".".to_string(), })? { @@ -97,11 +63,6 @@ impl RemoteOps for SshRemoteOps { fn spawn_detached(&self, cmd: &str) -> Result<(), String> { let conn = self.conn.clone(); let cmd = cmd.to_string(); - // The remote shell backgrounds the process and exits, so this *is* a - // normal exec — only the budget differs. Its exit status is ignored on - // purpose: `sh -c '... &'` reports on the backgrounding, never on the - // daemon, and whether the daemon really came up is settled by probing - // its socket, not by trusting a shell. self.block_on(async move { match tokio::time::timeout(LAUNCH_TIMEOUT, exec(&conn, &cmd)).await { Ok(Ok(_)) => Ok(()), @@ -123,9 +84,6 @@ impl RemoteOps for SshRemoteOps { is_dir: entry.kind == crate::daemon::protocol::SftpEntryKind::Dir, })), Ok(other) => Err(format!("unexpected SFTP reply for stat: {other:?}")), - // "Not there" is an answer, not a failure — it is the *expected* - // answer on the first install, and turning it into an error would - // make step 2 unable to say "go install it". Err(e) if is_not_found(&e) => Ok(None), Err(e) => Err(e), } @@ -136,9 +94,6 @@ impl RemoteOps for SshRemoteOps { path: path.to_string(), }) { Ok(_) => Ok(()), - // Servers disagree about which status an existing directory gets - // (`Failure`, `PermissionDenied`, a bare "file already exists"), so - // the authority on "does it exist" is a stat, not the error text. Err(e) => match self.stat(path) { Ok(Some(stat)) if stat.is_dir => Ok(()), _ => Err(e), @@ -191,12 +146,6 @@ impl RemoteOps for SshRemoteOps { } } -/// Whether a stringified SFTP error means "no such file", which every caller -/// here treats as a normal answer rather than a failure. -/// -/// russh-sftp renders a server status as `<code>: <message>`; the message text -/// is the server's, so this matches on the shapes OpenSSH and the common -/// non-OpenSSH servers produce rather than on a code we cannot see. fn is_not_found(msg: &str) -> bool { let lower = msg.to_ascii_lowercase(); lower.contains("no such file") @@ -205,11 +154,6 @@ fn is_not_found(msg: &str) -> bool { || lower.contains("does not exist") } -/// Run one command on its own session channel and collect everything it said. -/// -/// Loops until `wait()` returns `None` rather than breaking on `Eof`/`Close`: -/// the exit status arrives as its own message and can follow both, and the exit -/// status is the entire point of the daemon probe. async fn exec(conn: &Arc<SshConnection>, cmd: &str) -> Result<ExecOutput, String> { let mut channel = conn .open_session_channel() @@ -219,9 +163,6 @@ async fn exec(conn: &Arc<SshConnection>, cmd: &str) -> Result<ExecOutput, String .exec(true, cmd) .await .map_err(|e| format!("could not run `{cmd}`: {e}"))?; - // Close our end of the command's stdin immediately. Nothing here writes to - // a command, and the daemon probe specifically relies on its stdin ending - // so the bridge it starts hangs up instead of parking forever. let _ = channel.eof().await; let mut stdout = Vec::new(); @@ -247,10 +188,6 @@ async fn exec(conn: &Arc<SshConnection>, cmd: &str) -> Result<ExecOutput, String mod tests { use super::*; - /// The "absent" classification has to hold for the wordings the servers we - /// meet actually use, because step 2's whole decision ("is the right version - /// already installed?") rests on it — and misreading a missing file as an - /// error would turn every first install into a hard failure. #[test] fn missing_files_are_recognised_across_server_wordings() { for msg in [ @@ -264,9 +201,6 @@ mod tests { } } - /// And must not swallow the failures that have to be reported: a full - /// disk or a read-only home has to surface as an error with a path, never as - /// "the file isn't there, go ahead and install". #[test] fn real_failures_are_not_mistaken_for_absence() { for msg in [ diff --git a/crates/tty7-core/src/daemon/install/tests.rs b/crates/tty7-core/src/daemon/install/tests.rs index 180cd4a5..abe32835 100644 --- a/crates/tty7-core/src/daemon/install/tests.rs +++ b/crates/tty7-core/src/daemon/install/tests.rs @@ -1,17 +1,3 @@ -//! The install flow, driven end to end against an in-memory remote. -//! -//! Four things are asked for by name — `uname` parsing, version path -//! construction, atomic replacement, and the sha256 failure path — and none of -//! them may touch the network. The first two are unit-tested in -//! [`super::asset`] and [`super::checksums`]; the last two need the *whole* -//! sequence, which is what the fake remote here provides. -//! -//! The fake keeps a journal of every operation in order. That is what makes -//! "atomic" testable: atomicity is not a property of any single call, it is the -//! claim that the final path is only ever touched by a `rename` of an -//! already-`chmod`ed temp — which is a statement about the *order* of the -//! journal. - use std::collections::HashMap; use std::sync::Mutex; use std::time::Duration; @@ -20,31 +6,19 @@ use super::*; use crate::daemon::install::asset::{ASSET_X86_64, CHECKSUMS_ASSET}; const VERSION: &str = "26.7.5"; -/// The dialects the fixture's client speaks. Fixed literals rather than -/// [`RemoteProtocol::of_this_build`] so [`BINARY`] can be asserted as a string: -/// these tests are about *how* the name is built, and a name derived from the -/// same constants it is checked against would assert nothing. const CONTROL: u32 = 3; const PROTOCOL: u32 = 4; const HOME: &str = "/home/me"; const BIN_DIR: &str = "/home/me/.local/share/tty7/bin"; const BINARY: &str = "/home/me/.local/share/tty7/bin/tty7-server-c3p4"; -/// The shared per-dialect staging name. What actually gets written is -/// [`temp()`] — `unique_temp` of this. const TEMP_BASE: &str = "/home/me/.local/share/tty7/bin/.tty7-server-c3p4.tmp"; -/// The staging path this process writes to, which carries its pid. fn temp() -> String { unique_temp(TEMP_BASE) } -/// Stand-in for the release asset. Content is irrelevant; only its digest is. const SERVER_BYTES: &[u8] = b"\x7fELF...a static musl tty7-server, pretend it is 6 MB"; -// --------------------------------------------------------------------------- -// Fakes. -// --------------------------------------------------------------------------- - #[derive(Clone, Debug)] struct FakeFile { bytes: Vec<u8>, @@ -52,8 +26,6 @@ struct FakeFile { is_dir: bool, } -/// One entry in the journal. Only the operations that can change what is on -/// disk are recorded; reads are not, because no ordering claim depends on them. #[derive(Clone, Debug, PartialEq, Eq)] enum Journal { Mkdir(String), @@ -69,25 +41,11 @@ struct FakeRemote { files: Mutex<HashMap<String, FakeFile>>, journal: Mutex<Vec<Journal>>, uname: String, - /// Set to make every `put` fail, simulating a full disk / read-only home. put_error: Option<String>, daemon_running: Mutex<bool>, - /// What `readlink /proc/<pid>/exe` finds, when a daemon is running. running_exe: Mutex<Option<String>>, - /// Whether launching actually starts the fake daemon (false models a binary - /// that dies on exec). launch_works: bool, - /// What each binary answers to `--protocol`, by path. A path that is absent - /// models a server too old to know the flag: the probe fails, and the - /// installer falls back to having no opinion. speaks: Mutex<HashMap<String, RemoteProtocol>>, - /// What a *freshly uploaded* binary answers. Registered by `put` against the - /// path written, because the real installer asks the bytes it just staged - /// what they speak before publishing them — a fake whose uploads stayed mute - /// would model every install as a failed one. - /// - /// `None` models bytes that cannot answer at all: the wrong architecture, or - /// a build older than the flag. installed_speaks: Option<RemoteProtocol>, } @@ -115,22 +73,16 @@ impl FakeRemote { } } - /// Teach the binary at `exe` to answer `--protocol` with `spoken`. fn speaking(self, exe: &str, spoken: RemoteProtocol) -> Self { self.speaks.lock().unwrap().insert(exe.to_string(), spoken); self } - /// Make whatever gets uploaded answer with `spoken` — a source that hands - /// over a build other than the one the client asked for. `None` for bytes - /// that cannot answer at all. fn uploads_speaking(mut self, spoken: Option<RemoteProtocol>) -> Self { self.installed_speaks = spoken; self } - /// A machine tty7 has installed on before (so consent is not re-asked), with - /// this client's own dialect already published. fn with_previous_install(self) -> Self { self.preinstall(BINARY, 0o755); self.speaks @@ -140,8 +92,6 @@ impl FakeRemote { self } - /// A machine an *older, version-naming* client installed on: consent was - /// given once, and what it left behind claims no dialect. Returns the path. fn with_legacy_install(self, version: &str) -> (Self, String) { let path = format!("{BIN_DIR}/tty7-server-{version}"); self.preinstall(&path, 0o755); @@ -210,8 +160,6 @@ impl RemoteOps for FakeRemote { let exe = exe.trim_matches('\''); return match self.speaks.lock().unwrap().get(exe) { Some(spoken) => ok(&serde_json::to_string(spoken).unwrap()), - // What a server older than the flag does: usage on stderr, and - // a non-zero status. None => Ok(ExecOutput { status: Some(1), stdout: String::new(), @@ -313,8 +261,6 @@ impl RemoteOps for FakeRemote { is_dir: false, }, ); - // Uploaded bytes are a binary that can be asked what it speaks, which is - // exactly what the installer does with them next. let mut speaks = self.speaks.lock().unwrap(); match &self.installed_speaks { Some(spoken) => speaks.insert(path.to_string(), spoken.clone()), @@ -332,7 +278,6 @@ impl RemoteOps for FakeRemote { match files.remove(from) { Some(f) => { files.insert(to.to_string(), f); - // The binary keeps its answer when it changes name. let mut speaks = self.speaks.lock().unwrap(); match speaks.remove(from) { Some(spoken) => speaks.insert(to.to_string(), spoken), @@ -370,12 +315,8 @@ impl RemoteOps for FakeRemote { } } -/// Serves a canned release: the asset plus a manifest that really does contain -/// its digest, unless [`FakeRelease::corrupt`] says otherwise. struct FakeRelease { asset_bytes: Vec<u8>, - /// Bytes the manifest claims the asset hashes to. Differs from - /// `asset_bytes` in the tampering test. manifest_of: Vec<u8>, fetched: Mutex<Vec<String>>, fail: Option<String>, @@ -391,8 +332,6 @@ impl FakeRelease { } } - /// A release whose manifest does not describe the bytes it serves — a - /// corrupted download, a rewriting proxy, a tampered mirror. fn corrupt(mut self) -> Self { self.asset_bytes = b"something else entirely".to_vec(); self @@ -457,19 +396,6 @@ impl InstallConfirm for FakeUser { } } -/// The fixture's installer: this file's [`VERSION`], this file's dialect, and -/// timeouts a fake can satisfy. -/// -/// **Every test builds its installer through here**, and the dialect is why. -/// `Installer::new` starts at [`RemoteProtocol::of_this_build`], while -/// [`FakeRemote`] answers with [`ours`] — the fixture's fixed `c3p4`. A test that -/// hand-rolls the builder and forgets [`Installer::with_dialect`] passes only -/// while the real [`CONTROL_VERSION`](crate::daemon::control::CONTROL_VERSION) -/// happens to equal [`CONTROL`], and then fails on the next wire break with a -/// `DialectMismatch` that has nothing to do with whatever that bump changed. -/// Two tests did exactly that, so `release` is a trait object: a chunked or -/// throttled fetcher is a reason to vary the *source*, never a reason to leave -/// this function. fn installer<'a>( remote: &'a FakeRemote, release: &'a dyn AssetFetcher, @@ -482,13 +408,6 @@ fn installer<'a>( .with_timeouts(Duration::from_millis(200), Duration::from_millis(10)) } -// --------------------------------------------------------------------------- -// The happy path. -// --------------------------------------------------------------------------- - -/// All six steps on a machine that has never seen tty7: identify it, find -/// nothing installed, download and verify, ask once, publish atomically, and -/// launch a daemon. #[test] fn first_install_runs_all_six_steps() { let remote = FakeRemote::new(); @@ -519,7 +438,6 @@ fn first_install_runs_all_six_steps() { "the temp name is consumed by the rename" ); - // Both release artifacts were fetched from the same tag. assert_eq!( release.fetched(), vec![ @@ -529,10 +447,6 @@ fn first_install_runs_all_six_steps() { ); } -/// **Atomic replacement.** The final path must only ever be produced by -/// renaming a temp that is *already* executable — never written to directly, -/// and never chmod'ed after it is visible. Both would leave a window in which a -/// concurrent connect finds `tty7-server-c<c>p<p>` present and unusable. #[test] fn the_final_path_is_only_ever_reached_by_renaming_a_ready_temp() { let remote = FakeRemote::new(); @@ -544,7 +458,6 @@ fn the_final_path_is_only_ever_reached_by_renaming_a_ready_temp() { let writes = remote.writes(); - // Nothing writes the final path directly. assert!( !writes .iter() @@ -580,8 +493,6 @@ fn the_final_path_is_only_ever_reached_by_renaming_a_ready_temp() { ); } -/// The directory chain is created outermost-first (SFTP has no `mkdir -p`) and -/// the directory that holds the binaries ends up 0700. #[test] fn the_install_directory_is_created_in_order_and_locked_down() { let remote = FakeRemote::new(); @@ -611,14 +522,6 @@ fn the_install_directory_is_created_in_order_and_locked_down() { assert_eq!(remote.file(BIN_DIR).unwrap().mode, 0o700); } -// --------------------------------------------------------------------------- -// sha256 — the failure path. -// --------------------------------------------------------------------------- - -/// **A checksum mismatch aborts and writes nothing.** Not a retry, not an -/// unverified install, not a partially-written temp left behind: the remote -/// filesystem must be untouched, and the user must not even have been asked -/// (there is nothing to consent to). #[test] fn a_sha256_mismatch_aborts_before_touching_the_remote() { let remote = FakeRemote::new(); @@ -656,13 +559,10 @@ fn a_sha256_mismatch_aborts_before_touching_the_remote() { ); } -/// A release with no line for our asset is the same class of failure: stop, -/// do not install something unverified. #[test] fn a_release_missing_our_asset_aborts() { let remote = FakeRemote::new(); let mut release = FakeRelease::new(); - // Manifest describes a payload nobody serves, under a different name. release.manifest_of = b"unrelated".to_vec(); let user = FakeUser::approving(); @@ -673,12 +573,6 @@ fn a_release_missing_our_asset_aborts() { assert!(remote.writes().is_empty()); } -// --------------------------------------------------------------------------- -// Consent. -// --------------------------------------------------------------------------- - -/// The prompt has to carry everything it must say: which path, how big, -/// and where the bytes came from. #[test] fn the_confirmation_states_path_size_and_origin() { let remote = FakeRemote::new(); @@ -708,8 +602,6 @@ fn the_confirmation_states_path_size_and_origin() { assert_eq!(request.version, VERSION); } -/// Declining writes nothing and says so. The bytes were already downloaded and -/// verified by then; that is fine, they never left the client. #[test] fn declining_installs_nothing() { let remote = FakeRemote::new(); @@ -728,9 +620,6 @@ fn declining_installs_nothing() { assert!(remote.file(BINARY).is_none()); } -/// **With no UI attached the default is to refuse, not to proceed.** A daemon -/// running headless must not decide on the user's behalf that writing binaries -/// to their servers is acceptable. #[test] fn the_default_confirmation_declines() { let request = InstallRequest { @@ -749,13 +638,11 @@ fn the_default_confirmation_declines() { ); } -/// A machine tty7 has already written to is upgraded silently — the consent was -/// about "may tty7 put binaries here", and it was given. #[test] fn upgrading_a_known_machine_does_not_ask_again() { let (remote, legacy) = FakeRemote::new().with_legacy_install("26.7.4"); let release = FakeRelease::new(); - let user = FakeUser::declining(); // would refuse if asked + let user = FakeUser::declining(); let report = installer(&remote, &release, &user, "me@known-box:22") .run() @@ -767,18 +654,10 @@ fn upgrading_a_known_machine_does_not_ask_again() { user.asked().is_empty(), "no prompt on a machine we already use" ); - // The old binary is still there: one file per dialect, and the older one may - // still be the one a running daemon was exec'd from. assert!(remote.file(&legacy).is_some()); assert!(remote.file(BINARY).is_some()); } -// --------------------------------------------------------------------------- -// Skipping work. -// --------------------------------------------------------------------------- - -/// The common path: the right version is already installed and a daemon is -/// serving. No download, no prompt, no write, no launch. #[test] fn an_up_to_date_machine_downloads_nothing() { let remote = FakeRemote::new(); @@ -798,10 +677,6 @@ fn an_up_to_date_machine_downloads_nothing() { assert!(remote.writes().is_empty()); } -/// A binary that is present but not executable is a crashed install (the rename -/// landed, the chmod did not). Reinstalling beats launching something the kernel -/// will refuse with `Exec format error`'s equally opaque cousin, `Permission -/// denied`. #[test] fn a_present_but_unexecutable_binary_is_reinstalled() { let remote = FakeRemote::new(); @@ -817,12 +692,6 @@ fn a_present_but_unexecutable_binary_is_reinstalled() { assert_eq!(remote.file(BINARY).unwrap().mode, 0o755); } -// --------------------------------------------------------------------------- -// Refusals and write failures. -// --------------------------------------------------------------------------- - -/// An architecture we do not publish for is refused before anything is -/// downloaded or written, and the message quotes the machine string verbatim. #[test] fn an_unsupported_machine_is_refused_before_any_work() { for (uname, expect_linux) in [("Linux armv7l", true), ("Darwin arm64", false)] { @@ -850,9 +719,6 @@ fn an_unsupported_machine_is_refused_before_any_work() { } } -/// **A failed remote write reports the path and the server's reason, and is not -/// retried anywhere else**. A full disk must not become "let me try -/// /tmp". #[test] fn a_failed_write_names_the_path_and_does_not_fall_back() { let mut remote = FakeRemote::new(); @@ -878,7 +744,6 @@ fn a_failed_write_names_the_path_and_does_not_fall_back() { assert!(message.contains(&temp()), "{message}"); assert!(message.contains("no space left"), "{message}"); - // One attempt at one path. No second put, no alternative directory. let puts: Vec<_> = remote .journal() .into_iter() @@ -888,8 +753,6 @@ fn a_failed_write_names_the_path_and_does_not_fall_back() { assert!(remote.file(BINARY).is_none()); } -/// A download failure names the URL, so "which release did it even look for" is -/// answerable from the message alone. #[test] fn a_download_failure_names_the_url() { let remote = FakeRemote::new(); @@ -907,12 +770,6 @@ fn a_download_failure_names_the_url() { assert!(remote.writes().is_empty()); } -// --------------------------------------------------------------------------- -// Step 6: the daemon. -// --------------------------------------------------------------------------- - -/// Nothing serving → launch, then confirm by re-probing rather than by trusting -/// the shell's exit status. #[test] fn a_daemon_is_launched_when_the_socket_answers_nothing() { let remote = FakeRemote::new(); @@ -941,8 +798,6 @@ fn a_daemon_is_launched_when_the_socket_answers_nothing() { ); } -/// A binary that will not stay up fails with a message naming it, rather than -/// leaving the caller to discover it on the first frame. #[test] fn a_daemon_that_never_answers_is_an_error() { let mut remote = FakeRemote::new(); @@ -960,10 +815,6 @@ fn a_daemon_that_never_answers_is_an_error() { } } -/// **Dialect mismatch: keep the old daemon, record the mismatch.** It owns every -/// live pane on that machine; ending them at connect time is the user's call, -/// not the installer's — exactly as `spawn::ensure_running` treats the local -/// daemon. #[test] fn an_older_running_daemon_is_kept_and_reported() { let (remote, legacy) = FakeRemote::new().with_legacy_install("26.7.4"); @@ -988,7 +839,6 @@ fn an_older_running_daemon_is_kept_and_reported() { assert_eq!(mismatch.running_version.as_deref(), Some("26.7.4")); assert_eq!(mismatch.wanted_version, VERSION); - // And it reaches the GUI's take-once queue. let queued = take_mismatched_remote_daemons(); assert!( queued.iter().any(|m| m.host == "me@mismatch-box:22"), @@ -996,9 +846,6 @@ fn an_older_running_daemon_is_kept_and_reported() { ); } -/// A daemon we cannot identify (no readable `/proc`, a hand-placed binary) is -/// not a mismatch. Having no opinion must never be reported as a disagreement, -/// or every locked-down container would prompt on connect. #[test] fn an_unidentifiable_running_daemon_is_not_a_mismatch() { let remote = FakeRemote::new(); @@ -1013,7 +860,6 @@ fn an_unidentifiable_running_daemon_is_not_a_mismatch() { assert!(report.mismatch.is_none()); } -/// Restart is the other branch of the prompt: stop what is running, start ours. #[test] fn restart_replaces_the_running_daemon() { let remote = FakeRemote::new() @@ -1037,13 +883,6 @@ fn restart_replaces_the_running_daemon() { assert!(*remote.daemon_running.lock().unwrap()); } -// --------------------------------------------------------------------------- -// Remote command construction. -// --------------------------------------------------------------------------- - -/// The launch detaches the daemon from the SSH session and gives it no stream to -/// hold open. Without either half, closing the channel would kill it (SIGHUP to -/// the session's group) or the channel would never close (inherited stdout). #[test] fn the_launch_command_detaches_and_closes_every_stream() { let cmd = launch_command("/home/me/.local/share/tty7/bin/tty7-server-26.7.5"); @@ -1061,12 +900,6 @@ fn the_launch_command_detaches_and_closes_every_stream() { ); } -/// A transport's settle **follows** the launch; it never replaces it. Cheap to -/// get wrong in a `format!` and expensive to notice, because a daemon that was -/// never launched fails exactly like one that died right after being launched. -/// -/// And a transport that asks for nothing — every one but WSL — gets the launch -/// line by itself, with no trailing newline to change what the shell reads. #[test] fn a_launch_settle_follows_the_launch_and_never_replaces_it() { let plain = launch_script(BINARY, None); @@ -1084,9 +917,6 @@ fn a_launch_settle_follows_the_launch_and_never_replaces_it() { ); } -/// Every command interpolates a remote path, and home directories with spaces -/// or apostrophes exist. Unquoted, `/home/o'brien/...` would end the string -/// mid-path and run whatever followed. #[test] fn remote_paths_are_shell_quoted() { assert_eq!(shell_quote("/home/me/bin"), "'/home/me/bin'"); @@ -1095,10 +925,6 @@ fn remote_paths_are_shell_quoted() { "'/home/my box/tty7-server'" ); assert_eq!(shell_quote("/home/o'brien/x"), r"'/home/o'\''brien/x'"); - // A path that tries to break out stays one argument. The invariant that - // makes it safe: after the outer quotes, every remaining `'` belongs to a - // `'\''` escape — so there is no point at which the shell is outside a - // quoted string and could see `;` as a separator. let quoted = shell_quote("/tmp/x'; rm -rf ~; echo '"); let inner = quoted .strip_prefix('\'') @@ -1110,34 +936,19 @@ fn remote_paths_are_shell_quoted() { ); } -/// The launch command embeds a quoted path, so a hostile-looking home directory -/// cannot turn into a second command. #[test] fn the_launch_command_quotes_its_binary() { let cmd = launch_command("/home/me/a b/tty7-server-1.0.0"); assert!(cmd.contains("'/home/me/a b/tty7-server-1.0.0'"), "{cmd}"); } -/// The `/proc` sweep must survive a machine with no tty7-server running (the -/// common case) without the loop's failure becoming the command's — a `set -e` -/// login shell would otherwise report the probe as a broken connection. #[test] fn the_running_exe_probe_cannot_fail_the_command() { assert!(RUNNING_EXE_COMMAND.trim_end().ends_with("true")); assert!(TERMINATE_RUNNING_COMMAND.trim_end().ends_with("true")); - // It looks only at our own install shape, so it can never terminate - // something that merely happens to mention tty7. assert!(TERMINATE_RUNNING_COMMAND.contains("*/tty7-server-*")); } -// `connection_label` is now `ConnectionKey::as_str()` verbatim, so what used to -// be tested here — peeling the label out of the derived `Debug` — no longer -// exists. The key's own construction (including the jump chain, which is what -// keeps two hosts behind different bastions from sharing a label) is covered by -// `daemon::ssh::tests`, next to the `base_spec()` helper that builds one. - -/// `ExecOutput`'s failure summary prefers what the remote said over a bare -/// number, because "Permission denied" is actionable and "exit status 1" is not. #[test] fn exec_failures_quote_stderr_when_there_is_any() { let with_stderr = ExecOutput { @@ -1163,13 +974,6 @@ fn exec_failures_quote_stderr_when_there_is_any() { assert!(!killed.success()); } -// --------------------------------------------------------------------------- -// `BundledOrRelease` — installing from a local copy instead of a release. -// --------------------------------------------------------------------------- - -/// With no bundle configured this is the release download, unchanged. Pinned -/// because it is the path every ordinary user takes, and the whole feature is -/// only acceptable if it is inert until asked for. #[test] fn without_a_bundle_the_source_is_the_plain_download() { let release = FakeRelease::new(); @@ -1186,9 +990,6 @@ fn without_a_bundle_the_source_is_the_plain_download() { ); } -/// With one, the bytes come off the disk and **nothing is fetched** — which is -/// the point on an air-gapped client, behind a TLS-intercepting proxy, or on -/// any build with no published release (every developer build). #[test] fn a_bundle_is_used_instead_of_downloading() { let dir = std::env::temp_dir().join(format!("tty7-bundle-src-{}", std::process::id())); @@ -1215,10 +1016,6 @@ fn a_bundle_is_used_instead_of_downloading() { let _ = std::fs::remove_dir_all(&dir); } -/// A configured directory that lacks *this* asset fails, and does **not** -/// quietly download instead. Someone who pointed at a directory meant to -/// install from it; silently reaching for the network would defeat whichever -/// reason they had — and on an air-gapped box it would fail far from the cause. #[test] fn a_bundle_that_lacks_the_asset_does_not_fall_back_to_the_network() { let dir = std::env::temp_dir().join(format!("tty7-bundle-empty-{}", std::process::id())); @@ -1243,18 +1040,8 @@ fn a_bundle_that_lacks_the_asset_does_not_fall_back_to_the_network() { let _ = std::fs::remove_dir_all(&dir); } -/// The path the installer publishes to is **absolute and dialect-qualified**, -/// and that is what the session-channel fallback has to exec. -/// -/// Observed for real: the transport exec'd the bare name `tty7-server`, which -/// is a `command not found` on a machine where the install had just succeeded — -/// nothing puts `~/.local/share/tty7/bin` on a non-interactive `PATH`, and the -/// file there is not even called `tty7-server`. The remote process died at -/// once, taking the pane with it. #[test] fn the_published_path_is_absolute_and_dialect_qualified() { - // Built from *this crate's* dialects rather than the fixture's, because - // what the transport execs is whatever this build currently names. let real = RemoteProtocol::of_this_build(); let published = asset::remote_paths(HOME, real.control, real.protocol).binary; assert!( @@ -1285,12 +1072,6 @@ fn the_published_path_is_absolute_and_dialect_qualified() { ); } -// --------------------------------------------------------------------------- -// Progress (an 8 MB first install must not look like a hang). -// --------------------------------------------------------------------------- - -/// Records every report in order, which is what makes "monotonic" and "reaches -/// the total" testable — neither is a property of any single report. #[derive(Default)] struct Reports(Mutex<Vec<(String, InstallPhase)>>); @@ -1310,7 +1091,6 @@ impl Reports { } } -/// A release whose asset arrives in pieces, like a real HTTP body. struct ChunkedRelease { inner: FakeRelease, chunks: usize, @@ -1338,11 +1118,6 @@ impl AssetFetcher for ChunkedRelease { } } -/// **Both halves of the wait are reported, and each one finishes.** -/// -/// The download and the upload are separate network hops of the same ~8 MB, and -/// a bar that covered only one of them would sit at 100% through the other — -/// which is the exact failure this exists to prevent. #[test] fn an_install_reports_both_transfers_to_completion() { let remote = FakeRemote::new(); @@ -1396,8 +1171,6 @@ fn an_install_reports_both_transfers_to_completion() { "the upload reaches the byte count the consent prompt quoted" ); - // Order matters: the client cannot push bytes it has not fetched, and a UI - // that saw them interleaved would have to decide which one to draw. let first_upload = phases .iter() .position(|p| matches!(p, InstallPhase::Uploading { .. })) @@ -1412,11 +1185,6 @@ fn an_install_reports_both_transfers_to_completion() { ); } -/// **Every report names the machine it is about.** -/// -/// The GUI keys its progress slots by machine, so a report that arrived with the -/// wrong label — or an empty one — would paint one box's bytes under another's -/// name while both were installing. #[test] fn every_report_carries_the_host() { let remote = FakeRemote::new(); @@ -1440,11 +1208,6 @@ fn every_report_carries_the_host() { ); } -/// **An install that is already present reports nothing.** -/// -/// The common path — a machine tty7 has installed to before — does no transfer -/// at all, and a bar that flashed on every connect would train the user to -/// ignore it on the one connect where it means something. #[test] fn a_present_binary_reports_no_progress() { let remote = FakeRemote::new().with_previous_install(); @@ -1465,11 +1228,6 @@ fn a_present_binary_reports_no_progress() { ); } -/// **The scoped sink outranks the global one, and is put back afterwards.** -/// -/// Same contract as `with_install_confirm`, and it matters for the same reason: -/// in the daemon each routed connection has its own client, and a global would -/// send one machine's byte counts to the other machine's window. #[test] fn a_scoped_progress_sink_outranks_the_global_one() { let scoped = Arc::new(Reports::default()); @@ -1489,11 +1247,6 @@ fn a_scoped_progress_sink_outranks_the_global_one() { ); } -/// **`fraction` is safe to hand straight to a layout.** -/// -/// It feeds a width, so anything outside `0.0..=1.0` draws a bar that overflows -/// its track or inverts it. A zero or absent total is the interesting case: it -/// means "unknown", not "zero percent", and the caller has to be able to tell. #[test] fn a_fraction_is_either_absent_or_in_range() { assert_eq!( @@ -1529,11 +1282,6 @@ fn a_fraction_is_either_absent_or_in_range() { ); } -// --------------------------------------------------------------------------- -// Dialects, not build strings. -// --------------------------------------------------------------------------- - -/// What this client speaks, which is what a remote has to match. fn ours() -> RemoteProtocol { RemoteProtocol { control: CONTROL, @@ -1543,18 +1291,8 @@ fn ours() -> RemoteProtocol { } const OTHER_BUILD: &str = "26.7.9-nightly.20260801"; -/// A server installed by a client that named files after *versions* — every -/// binary already sitting on a user's machine when this naming shipped. Its path -/// claims no dialect, so it can only be adopted by being asked. const OTHER_EXE: &str = "/home/me/.local/share/tty7/bin/tty7-server-26.7.9-nightly.20260801"; -/// **A newer server this client can talk to is adopted, not overwritten.** -/// -/// The scene from the field: a `26.7.6` client meets a machine already serving -/// `26.7.7-nightly`, both speaking the same dialects. Before this, the client -/// stat'ed for its *own* version, missed, uploaded 8 MB nobody needed, and then -/// asked the user to choose between keeping their sessions and restarting a -/// server that was working fine. #[test] fn a_compatible_running_server_is_reused_without_installing() { let remote = FakeRemote::new().serving(OTHER_EXE).speaking( @@ -1600,11 +1338,6 @@ fn a_compatible_running_server_is_reused_without_installing() { ); } -/// **A server speaking a different dialect is still installed over.** -/// -/// The other half of the same judgement — adoption is not a blanket "reuse -/// whatever is there". A control dialect we cannot speak is exactly what the -/// prompt exists for. #[test] fn an_incompatible_running_server_is_not_adopted() { let remote = FakeRemote::new().serving(OTHER_EXE).speaking( @@ -1634,11 +1367,6 @@ fn an_incompatible_running_server_is_not_adopted() { ); } -/// **The pane dialect counts too, not just the control one.** -/// -/// A remote workspace uses both: control for the workspace, the pane protocol -/// for every terminal in it. Matching one and not the other would open the -/// workspace and then fail on the first pane. #[test] fn a_matching_control_dialect_is_not_enough_on_its_own() { let remote = FakeRemote::new().serving(OTHER_EXE).speaking( @@ -1663,14 +1391,8 @@ fn a_matching_control_dialect_is_not_enough_on_its_own() { assert!(report.reused.is_none()); } -/// **A server too old to answer `--protocol` is handled exactly as before.** -/// -/// It predates the flag, so it exits non-zero; we learn nothing, and "nothing -/// learnt" has to keep meaning "install ours and let the user decide", never -/// "assume it is fine". #[test] fn a_server_that_cannot_be_probed_is_installed_over() { - // `.serving` without `.speaking`: the probe fails. let remote = FakeRemote::new().serving(OTHER_EXE); let release = FakeRelease::new(); let user = FakeUser::approving(); @@ -1687,10 +1409,6 @@ fn a_server_that_cannot_be_probed_is_installed_over() { ); } -/// **Our own version already installed still short-circuits everything.** -/// -/// The fast path must not have grown a probe: a machine we have installed on -/// before should cost a `stat` and nothing more. #[test] fn the_matching_version_still_costs_no_probe() { let remote = FakeRemote::new().with_previous_install().serving(BINARY); @@ -1713,11 +1431,6 @@ fn the_matching_version_still_costs_no_probe() { ); } -/// **`serves` is symmetric in neither direction by accident — it is equality.** -/// -/// Written down because "newer can serve older" is the tempting wrong rule, and -/// the failure it produces (a wire error mid-session, long after the connect) -/// is far worse than the prompt it avoids. #[test] fn only_identical_dialects_serve() { let base = ours(); @@ -1746,10 +1459,6 @@ fn only_identical_dialects_serve() { ); } -/// **The probe's output survives a chatty login shell.** -/// -/// `.bashrc` on a shared box prints banners, `direnv` prints exports, and all of -/// it lands on the same stdout the JSON does. #[test] fn a_noisy_shell_does_not_break_the_probe() { let spoken = ours(); @@ -1767,18 +1476,6 @@ fn a_noisy_shell_does_not_break_the_probe() { assert_eq!(RemoteProtocol::parse("not json at all"), None); } -// --------------------------------------------------------------------------- -// The name is a promise, and it is checked before it is published. -// --------------------------------------------------------------------------- - -/// **Bytes that speak the wrong dialect are never published.** -/// -/// The whole naming scheme rests on `tty7-server-c<c>p<p>` really speaking -/// c/p, and nothing upstream of the upload can guarantee that: a -/// `TTY7_BUNDLED_SERVER_DIR` can hold a stale cross-compile, and a release tag -/// can predate a wire break. Publishing anyway writes a file that lies, and the -/// *next* connect trusts the name, skips the install, and dies in the handshake -/// with nothing to blame. #[test] fn an_upload_that_speaks_the_wrong_dialect_is_not_published() { let remote = FakeRemote::new().uploads_speaking(Some(RemoteProtocol { @@ -1815,12 +1512,6 @@ fn an_upload_that_speaks_the_wrong_dialect_is_not_published() { ); } -/// **Bytes that cannot answer at all are refused the same way.** -/// -/// A binary for the wrong architecture cannot exec, so it cannot answer. This -/// is the first moment that mistake can surface as itself; without the check it -/// used to reach a daemon launch and die as `Exec format error`, which names -/// nothing about `uname`. #[test] fn an_upload_that_cannot_answer_is_not_published() { let remote = FakeRemote::new().uploads_speaking(None); @@ -1838,11 +1529,6 @@ fn an_upload_that_cannot_answer_is_not_published() { assert!(remote.file(BINARY).is_none()); } -/// **A dialect already installed is reused without downloading or asking.** -/// -/// The hot path, stated as a cost: one `stat` of a path built from this -/// client's own two numbers, and no network at all — which is what has to hold -/// on a machine that cannot reach GitHub. #[test] fn a_machine_with_our_dialect_installed_costs_nothing() { let remote = FakeRemote::new().with_previous_install().serving(BINARY); @@ -1859,16 +1545,9 @@ fn a_machine_with_our_dialect_installed_costs_nothing() { assert!(release.fetched().is_empty(), "{:?}", release.fetched()); } -/// **A different build behind our dialect is used as-is.** -/// -/// The deliberate limit of the whole scheme: dialects decide whether a connect -/// works, and "is this the build I just compiled" is a different question that -/// must not cost an 8 MB upload on every connect. Someone else's install, or an -/// older client's, serves us fine. #[test] fn another_build_at_our_dialect_is_used_rather_than_replaced() { let remote = FakeRemote::new().with_previous_install().serving(BINARY); - // Same file, same dialect, a build string from a different release. let remote = remote.speaking( BINARY, RemoteProtocol { @@ -1890,15 +1569,9 @@ fn another_build_at_our_dialect_is_used_rather_than_replaced() { ); } -/// **A legacy version-named binary is not adopted on the strength of its name.** -/// -/// Every machine tty7 had already installed on carries one. The name claims no -/// dialect, so the only honest thing to do is ask — and if it cannot answer, -/// install ours beside it. #[test] fn a_legacy_named_binary_is_probed_not_assumed() { let (remote, legacy) = FakeRemote::new().with_legacy_install(VERSION); - // It answers, and it happens to speak our dialects: adopt it, no upload. let remote = remote.serving(&legacy).speaking( &legacy, RemoteProtocol { @@ -1925,12 +1598,6 @@ fn a_legacy_named_binary_is_probed_not_assumed() { assert!(report.mismatch.is_none()); } -/// **The staging name is private to this process.** -/// -/// One file per dialect means the shared temp name is the same string for every -/// client installing that dialect, so two of them at once would interleave -/// their bytes into it. The published name stays shared — `rename` is still -/// what makes an install visible. #[test] fn the_staging_path_carries_the_pid() { let staged = temp(); @@ -1948,17 +1615,6 @@ fn the_staging_path_carries_the_pid() { ); } -// --------------------------------------------------------------------------- -// "Replace Server" — the way out of a handshake this client lost. -// --------------------------------------------------------------------------- - -/// **A good binary already at our path is restarted onto, not re-downloaded.** -/// -/// The state `run` leaves behind every time it refuses to kill a daemon that -/// owns live panes: our binary published, an older one still serving. It is the -/// common case behind the handshake error, and the button that offers to fix it -/// must not need a network — least of all a released asset speaking a dialect -/// that, for any build between releases, does not exist yet. #[test] fn replacing_reuses_a_published_binary_that_already_serves_us() { let (remote, legacy) = FakeRemote::new().with_legacy_install("26.7.4"); @@ -2000,16 +1656,9 @@ fn replacing_reuses_a_published_binary_that_already_serves_us() { ); } -/// **A binary whose name lies is overwritten.** -/// -/// The other reason a handshake fails against a path this client trusts: -/// something outside tty7 put a file there. `run` cannot catch it — it trusts -/// the name, which is what makes the connect cheap — so this is the only thing -/// that does. #[test] fn replacing_overwrites_a_published_binary_that_does_not_serve_us() { let remote = FakeRemote::new().with_previous_install(); - // Someone replaced it: the name says our dialect, the bytes disagree. let remote = remote.speaking( BINARY, RemoteProtocol { diff --git a/crates/tty7-core/src/daemon/install/wsl.rs b/crates/tty7-core/src/daemon/install/wsl.rs index bbd0eb94..d7d8762d 100644 --- a/crates/tty7-core/src/daemon/install/wsl.rs +++ b/crates/tty7-core/src/daemon/install/wsl.rs @@ -1,50 +1,3 @@ -//! WSL — a distribution on *this* machine as a remote workspace host -//! (decision D9). -//! -//! ## Why WSL is its own transport instead of "just another SSH host" -//! -//! D9: requiring the user to install and configure an `sshd` inside a -//! distribution that is already running on their own computer is absurd, and it -//! also breaks the automatic-install story — there would be nothing to install -//! *onto* until the user had already done the hard part by hand. So a WSL host -//! is reached by spawning `wsl.exe -d <distro> -- <server> --stdio` and treating -//! that child's stdin/stdout as the link. **No SSH, no authentication, no -//! network, no host key, no port.** -//! -//! ## The layering, and where the untestable part is confined -//! -//! | Layer | What it is | Tested here | -//! |---|---|---| -//! | Command construction | [`wsl_args`], the `*_script` builders, [`shell_quote`] | ✅ pure | -//! | Output decoding | [`decode_wsl_text`], [`parse_distro_list`], [`parse_stat`], [`parse_list`] | ✅ pure | -//! | Binary discovery | [`bundled_search_dirs`], [`BundledServerBinary`] | ✅ against a temp dir | -//! | The install state machine | [`super::Installer`], shared verbatim with SSH | ✅ against a fake [`RemoteOps`] | -//! | Actually spawning `wsl.exe` | [`WslRemoteOps`]'s `invoke` | ❌ needs Windows + WSL | -//! -//! Everything above the last row runs in this crate's test suite on any OS. The -//! last row is deliberately the thinnest thing that could work: build an argv, -//! spawn, feed stdin, read stdout, decode. It is also **the only part that has -//! never been executed** — see the module's tests for exactly which strings the -//! untested layer is expected to produce. -//! -//! Note that none of this is `#[cfg(windows)]`. `wsl.exe` is spawned by name -//! like any other program, so every line here compiles (and the pure parts run) -//! on macOS and Linux; on those systems the spawn simply fails with "not found", -//! which is the honest answer. -//! -//! ## Two decisions worth writing down -//! -//! **Scripts travel on stdin, not on the command line.** Every probe runs as -//! `wsl.exe -d <distro> -- sh -s` with the script written to the child's stdin. -//! The alternative, `sh -c "<script>"`, would push a shell script through two -//! separate quoting layers — Rust's Windows command-line quoting on the way out, -//! and `wsl.exe`'s own command-line reconstruction on the way in — for a string -//! full of quotes, `$`, and newlines. `-s` reduces the argv to five fixed, -//! space-free words, so there is nothing left for either layer to get wrong. -//! -//! **The binary is written with `tee`, not through `\\wsl$`.** See -//! [`WslRemoteOps::put`]. - use std::io; use std::path::{Path, PathBuf}; use std::process::Stdio; @@ -58,41 +11,18 @@ use super::{ install_confirm, shell_quote, }; -/// The launcher. Resolved through `PATH` rather than `%SystemRoot%\System32`: -/// that is where it lives, and hard-coding a system path buys nothing over -/// letting the OS answer. pub const WSL_EXE: &str = "wsl.exe"; -/// Prefix on every line this module's probes emit, so a distribution whose -/// startup prints anything of its own cannot be mistaken for an answer. Same -/// tactic as [`super::super::remote_link::REMOTE_ENV_PROBE`]. const MARKER: &str = "__tty7_wsl__"; -/// Budget for one probe. Generous, because the *first* command against a -/// stopped distribution pays for booting its VM and init. const COMMAND_TIMEOUT: Duration = Duration::from_secs(60); -/// Budget for the daemon launch. It backgrounds and returns, but not -/// immediately — see [`launch_settle`], which holds the invocation open until the -/// daemon answers. const LAUNCH_TIMEOUT: Duration = Duration::from_secs(30); -/// Budget for writing the server binary — a few megabytes over a pipe, plus -/// whatever the distribution's disk is doing. const PUT_TIMEOUT: Duration = Duration::from_secs(180); -// --------------------------------------------------------------------------- -// Distribution names. -// --------------------------------------------------------------------------- - -/// Why a string cannot be used as a `wsl.exe -d` argument. #[derive(Debug, Clone, PartialEq, Eq)] pub enum DistroNameError { Empty, - /// A name starting with `-` would be read as an option by `wsl.exe`, not as - /// a distribution — including, in the worst case, one that does something. LeadingDash(String), - /// Control characters (NUL especially) cannot survive the trip through a - /// Windows command line intact, and a name containing one did not come from - /// `wsl.exe -l -q`. Control(String), TooLong(usize), } @@ -122,16 +52,8 @@ impl From<DistroNameError> for io::Error { } } -/// Longest name we will pass along. `wsl.exe` itself has no documented limit; -/// this exists so a corrupted config cannot hand a megabyte to `CreateProcess`. const MAX_DISTRO_NAME: usize = 255; -/// Whether `name` can be handed to `wsl.exe -d`. -/// -/// Deliberately permissive about everything else — spaces, dots, non-ASCII and -/// `+` all occur in real distribution names (`Ubuntu-22.04`, `openSUSE-Leap-15.5`, -/// names the user typed when importing a tarball) and they are passed as a -/// single argv element, never through a shell. pub fn validate_distro(name: &str) -> Result<(), DistroNameError> { if name.is_empty() { return Err(DistroNameError::Empty); @@ -148,47 +70,16 @@ pub fn validate_distro(name: &str) -> Result<(), DistroNameError> { Ok(()) } -/// The full argument list for `wsl.exe`, running `argv` inside `distro`. -/// -/// `--` is mandatory, not decoration: without it `wsl.exe` keeps parsing options -/// and a command whose first word happens to start with `-` would be swallowed. -/// -/// **Deliberately no `--cd ~`.** It would be the tidy way to run everything from -/// the distribution's home — which is where an SSH session channel already -/// starts, and it matters, because `wsl.exe` otherwise runs the command in the -/// *Windows* working directory translated across the mount (`C:\Users\me` → -/// `/mnt/c/Users/me`) and the daemon never `chdir`s, so a process that outlives -/// every window would sit on a drvfs handle for as long as it ran. -/// -/// But `--cd` only exists in the Store/preview `wsl.exe` (0.51.2+); the inbox one -/// on Windows 10 rejects it, and an unknown option makes `wsl.exe` fail *before* -/// the distribution starts. That turns a cosmetic problem into total breakage on -/// a large set of machines. The working directory is fixed inside the shell -/// instead — see [`CD_HOME`], which costs nothing and needs no `wsl.exe` -/// features. pub fn wsl_args(distro: &str, argv: &[&str]) -> Vec<String> { let mut args = vec!["-d".to_string(), distro.to_string(), "--".to_string()]; args.extend(argv.iter().map(|a| (*a).to_string())); args } -/// The machine label a WSL distribution is known by, matching -/// [`RemoteTarget::connection_key`](crate::core::session::RemoteTarget) so a -/// consent prompt, a mismatch record and a session all name the same thing. pub fn host_label(distro: &str) -> String { format!("wsl:{distro}") } -// --------------------------------------------------------------------------- -// Enumerating distributions — the entry point the "connect to a host" UI needs. -// --------------------------------------------------------------------------- - -/// Installed WSL distributions, or empty when WSL is not installed (or this is -/// not Windows). -/// -/// Blocking and cheap-ish, but it does spawn a process: call it off the UI -/// thread. Empty is a normal answer and never an error — "no WSL here" is -/// exactly what a Mac or a Windows box without WSL should report. pub fn list_distros() -> Vec<String> { let mut cmd = std::process::Command::new(WSL_EXE); cmd.args(["-l", "-q"]) @@ -204,12 +95,6 @@ pub fn list_distros() -> Vec<String> { parse_distro_list(&out.stdout) } -/// Decode `wsl.exe -l -q` output into distribution names. -/// -/// `wsl.exe` writes UTF-16LE, one name per line, and Docker Desktop's two -/// internal distributions are filtered out: they are not user environments, -/// they have no shell worth landing in, and offering them as workspace hosts -/// only ever produces a confusing failure. pub fn parse_distro_list(bytes: &[u8]) -> Vec<String> { decode_wsl_text(bytes) .lines() @@ -219,25 +104,6 @@ pub fn parse_distro_list(bytes: &[u8]) -> Vec<String> { .collect() } -/// Decode bytes that came from a WSL-related process. -/// -/// `wsl.exe`'s *own* output — the distribution list, and its error messages — is -/// UTF-16LE, while anything produced *inside* the distribution is UTF-8. Both -/// arrive on the same pipes, so the encoding has to be sniffed rather than -/// assumed: a NUL byte is impossible in valid UTF-8 text and unavoidable in -/// UTF-16LE ASCII, which makes it a reliable discriminator in exactly this -/// setting. -/// **Line by line**, because the two encodings really do share a pipe. The -/// mixing happens on stderr: `wsl.exe` prepends its own warnings (the -/// "Detected localhost proxy configuration" notice is emitted on essentially -/// every invocation on affected hosts) to whatever the distribution's shell then -/// writes. Decoding the whole buffer by one verdict would turn the *other* -/// half into mojibake — and on stderr that half is the diagnostic -/// [`ExecOutput::failure_reason`] is about to show the user. -/// -/// A newline is `0A` in UTF-8 and `0A 00` in UTF-16LE, so splitting on `0A` -/// separates lines under either encoding and leaves at most one stray `00` at -/// the head of the next line, which is dropped before the per-line verdict. pub fn decode_wsl_text(bytes: &[u8]) -> String { if !bytes.contains(&0) { return strip_bom(&String::from_utf8_lossy(bytes)); @@ -250,7 +116,6 @@ pub fn decode_wsl_text(bytes: &[u8]) -> String { } fn decode_wsl_line(line: &[u8]) -> String { - // The low half of the `0A 00` that ended the previous UTF-16 line. let line = line.strip_prefix(&[0]).unwrap_or(line); if !line.contains(&0) { return strip_bom(&String::from_utf8_lossy(line)); @@ -266,93 +131,20 @@ fn strip_bom(s: &str) -> String { s.trim_start_matches('\u{feff}').to_string() } -// --------------------------------------------------------------------------- -// The scripts, and how their answers are read back. -// --------------------------------------------------------------------------- - -/// `$HOME` as the distribution's default user sees it. Not guessed from the -/// distro name, and not `\\wsl$\<distro>\home\<user>` with `<user>` inferred — -/// the default user is configurable per distribution (`/etc/wsl.conf`) and is -/// routinely not the one whose name appears anywhere Windows can see. const HOME_SCRIPT: &str = "printf '__tty7_wsl__ home=%s\\n' \"$HOME\"\n"; -/// Prefixed to every script, because `wsl.exe` starts the command in the -/// translated Windows working directory (`/mnt/c/…`) and -/// [`wsl_args`] explains why `--cd ~` cannot be used to fix that. -/// -/// The one that really matters is the daemon launch: `tty7-server --daemon` never -/// `chdir`s, so without this it would hold a drvfs handle open for its entire -/// life — and its life is "until the machine reboots". `|| cd /` covers a -/// distribution whose default user has no home directory. const CD_HOME: &str = "cd \"$HOME\" 2>/dev/null || cd /\n"; -/// How many times the settle asks the daemon whether it is serving, and how long -/// it waits between asks — a little over five seconds in all, generous for a -/// distribution that is booting its VM, and far inside [`LAUNCH_TIMEOUT`]. const SETTLE_TRIES: u32 = 25; const SETTLE_STEP: &str = "0.2"; -/// How long to hold the invocation open *after* the daemon answers. See -/// [`launch_settle`]. const SETTLE_GRACE: &str = "0.3"; -/// The wait that runs after the daemon launch, in the same `wsl.exe` -/// invocation, so `wsl.exe` does not exit while the daemon is still detaching. -/// -/// **WSL reaps what an interop session started when that session's `wsl.exe` -/// exits**, and `setsid` does not make the child safe the instant it runs. -/// [`launch_command`](super::launch_command) backgrounds the daemon and returns -/// in milliseconds, so without this the sequence is: `sh` exits, `wsl.exe` -/// exits, WSL tears the session down, and the daemon dies before it can bind. -/// What the user sees is [`ensure_daemon`](super::Installer::ensure_daemon)'s -/// "started but nothing was answering on the control socket", with a correctly -/// installed binary and nothing else to go on. -/// -/// Reproduced by hand against `Ubuntu-24.04`, running the launch line by -/// itself: with nothing after it the daemon is gone; with as little as -/// `sleep 0.3` after it the daemon lives and serves. Nothing about the launch -/// line itself is wrong — `nohup`, `setsid --fork` and a double-forked subshell -/// all die the same way — so the wait is the fix, not a different spelling of -/// the detach. -/// -/// The SSH path needs none of this (an exec channel's close is unhurried), which -/// is why the shared [`launch_command`](super::launch_command) stays as it is -/// and this is a [`RemoteOps::launch_settle`](super::RemoteOps::launch_settle) -/// instead. -/// -/// # It waits for an answer, not for a fixed number of seconds -/// -/// A flat `sleep` is a bet on how long a distribution takes to detach, and the -/// one that loses that bet is the cold or loaded distribution — the same -/// condition the failure needed in the first place. So the wait asks the daemon -/// the only question that settles it, `--stdio --bridge`, exactly as -/// [`ensure_daemon`](super::Installer::ensure_daemon) asks it from this side, and -/// stops as soon as it is answered. The normal case is therefore *shorter* than -/// the flat second it replaces, and the cold case is allowed the seconds it -/// actually needs instead of failing with nothing to go on. -/// -/// **Waiting on `<config dir>/daemon.sock` would be wrong**, which is why this -/// waits on the answer and not on the file. -/// [`ensure_daemon`](super::Installer::ensure_daemon) only reaches the launch -/// when the socket did *not* answer, and the ordinary reason for that is a socket -/// file a dead daemon left behind — `wsl --shutdown` is a routine thing to run. -/// A `-S` test would pass on that stale file immediately, skip the wait, and -/// restore the bug in exactly the state that produced it. A daemon that answers -/// cannot be a leftover file. -/// -/// [`SETTLE_GRACE`] follows the answer because what makes the daemon safe is not -/// observable from here — 0.3s of *any* wait was enough in the reproduction, so -/// the answer is followed by that much regardless. pub(super) fn launch_settle(binary: &str) -> String { settle_script(binary, SETTLE_TRIES, SETTLE_STEP, SETTLE_GRACE) } -/// [`launch_settle`] with its budget spelled out, so a test can run the real -/// script against a real `sh` without waiting out the real budget. fn settle_script(binary: &str, tries: u32, step: &str, grace: &str) -> String { let bin = shell_quote(binary); - // `< /dev/null` on the probe matters twice over: it is what makes `--bridge` - // answer and exit rather than proxy, and this script itself arrives on the - // shell's stdin, so a probe left reading stdin would eat the rest of it. format!( "__tty7_settle=0\n\ while [ \"$__tty7_settle\" -lt {tries} ]; do\n\ @@ -364,16 +156,11 @@ fn settle_script(binary: &str, tries: u32, step: &str, grace: &str) -> String { ) } -/// `HOME_SCRIPT` has to spell the marker out, because a `const` cannot -/// `format!`. This is the guard that a rename of [`MARKER`] cannot slip past: -/// it fails at compile time, not on a distribution nobody can reach from CI. const _: () = assert!( konst_contains(HOME_SCRIPT, MARKER), "HOME_SCRIPT must carry MARKER, or `home_dir` silently stops parsing" ); -/// `str::contains` is not `const`; this is the same search, spelled so it can run -/// in a `const` block. const fn konst_contains(haystack: &str, needle: &str) -> bool { let (h, n) = (haystack.as_bytes(), needle.as_bytes()); if n.len() > h.len() { @@ -393,17 +180,6 @@ const fn konst_contains(haystack: &str, needle: &str) -> bool { false } -/// `stat`, reduced to the three facts the installer actually decides on: is it a -/// directory, is it executable, how big is it. -/// -/// Built from `test` and `wc` rather than `stat(1)`, whose format flags differ -/// between GNU coreutils and BusyBox — and a minimal distribution image is -/// exactly where an install is most likely to be attempted. -/// -/// The reported mode is therefore a *reconstruction*: `0755` for something -/// executable, `0644` for something that is not. That is all -/// [`Installer::run`](super::Installer::run) reads it for (`mode & 0o100`), and -/// claiming more precision than the probe has would be a lie in a struct field. fn stat_script(path: &str) -> String { let p = shell_quote(path); format!( @@ -418,8 +194,6 @@ fn stat_script(path: &str) -> String { ) } -/// Read [`stat_script`]'s answer. `Ok(None)` is "not there", which is the -/// expected answer on a first install and must never surface as an error. fn parse_stat(stdout: &str) -> Result<Option<RemoteStat>, String> { for line in stdout.lines() { let Some(rest) = marked(line, "stat=") else { @@ -456,10 +230,6 @@ fn parse_stat(stdout: &str) -> Result<Option<RemoteStat>, String> { )) } -/// `ls -A` on one directory, one marked line per entry, plus a marker saying -/// whether the directory was there at all — the two answers have to be -/// distinguishable, because "empty" and "absent" mean the same thing to the -/// first-install prompt but not to anything else. fn list_script(dir: &str) -> String { let d = shell_quote(dir); format!( @@ -498,7 +268,6 @@ fn parse_list(stdout: &str) -> Result<Option<Vec<String>>, String> { } } -/// The payload of a marked line, once past the marker and an optional key. fn marked<'a>(line: &'a str, key: &str) -> Option<&'a str> { line.trim() .strip_prefix(MARKER)? @@ -506,8 +275,6 @@ fn marked<'a>(line: &'a str, key: &str) -> Option<&'a str> { .strip_prefix(key) } -/// Enough of an unexpected answer to diagnose it, without pasting a whole -/// distribution's MOTD into an error message. fn truncate(s: &str) -> String { let s = s.trim(); if s.chars().count() <= 200 { @@ -516,18 +283,6 @@ fn truncate(s: &str) -> String { s.chars().take(200).collect::<String>() + "…" } -// --------------------------------------------------------------------------- -// `RemoteOps` over `wsl.exe`. -// --------------------------------------------------------------------------- - -/// [`RemoteOps`] against one WSL distribution. -/// -/// The SSH implementation gets file operations from SFTP; there is no SFTP here, -/// so every one of them is a small POSIX shell script run inside the -/// distribution. That is not a downgrade: the "remote" filesystem is a local -/// disk one syscall away, and a shell is the only interface that is guaranteed -/// to exist in *every* distribution, including ones whose entire userland is a -/// single BusyBox binary. pub struct WslRemoteOps { distro: String, } @@ -539,14 +294,11 @@ impl WslRemoteOps { } } - /// Run a shell script inside the distribution, script on stdin, from the - /// distribution's home directory ([`CD_HOME`]). fn sh(&self, script: &str, timeout: Duration) -> Result<ExecOutput, String> { let script = format!("{CD_HOME}{script}"); self.invoke(&["sh", "-s"], Some(script.into_bytes()), false, timeout) } - /// A script whose only interesting output is whether it worked. fn sh_ok(&self, script: &str) -> Result<(), String> { let out = self.sh(script, COMMAND_TIMEOUT)?; if out.success() { @@ -556,7 +308,6 @@ impl WslRemoteOps { } } - /// The one place a process is spawned. Everything above this is strings. fn invoke( &self, argv: &[&str], @@ -565,8 +316,6 @@ impl WslRemoteOps { timeout: Duration, ) -> Result<ExecOutput, String> { let args = wsl_args(&self.distro, argv); - // The daemon's runtime, the same one `ssh_ops` crosses into. These calls - // arrive on plain connection threads, never on a runtime worker. crate::daemon::ssh::SshManager::global() .handle() .block_on(async move { invoke_async(&args, input, discard_stdout, timeout).await }) @@ -592,7 +341,6 @@ async fn invoke_async( Stdio::piped() }) .stderr(Stdio::piped()) - // A timed-out `wsl.exe` must not outlive the future that gave up on it. .kill_on_drop(true); crate::core::proc::hide_console_tokio(&mut cmd); @@ -608,24 +356,16 @@ async fn invoke_async( if let (Some(mut stdin), Some(bytes)) = (stdin, input) { stdin.write_all(&bytes).await?; stdin.flush().await?; - // The close is the message: `sh -s` runs nothing until its script - // ends, and `tee` writes nothing until its input does. stdin.shutdown().await?; } Ok::<(), io::Error>(()) }; - // Feeding and draining concurrently, because doing them in sequence - // deadlocks the moment either pipe's buffer fills — and the binary upload is - // several megabytes. let both = async { tokio::join!(feed, child.wait_with_output()) }; let (written, waited) = tokio::time::timeout(timeout, both) .await .map_err(|_| format!("the distribution did not answer within {timeout:?}"))?; - // A broken pipe here usually means the child died first, in which case its - // own exit status and stderr are the better diagnosis — so this is only - // reported when nothing else went wrong. let write_err = written.err(); let out = waited.map_err(|e| format!("{WSL_EXE} failed: {e}"))?; @@ -667,13 +407,9 @@ impl RemoteOps for WslRemoteOps { } fn spawn_detached(&self, cmd: &str) -> Result<(), String> { - // The status is ignored for the same reason it is over SSH: the script - // reports on the backgrounding, never on the daemon. Whether the daemon - // came up is settled by probing its socket. self.sh(cmd, LAUNCH_TIMEOUT).map(|_| ()) } - /// WSL is the transport that needs one. See [`launch_settle`]. fn launch_settle(&self, binary: &str) -> Option<String> { Some(launch_settle(binary)) } @@ -687,8 +423,6 @@ impl RemoteOps for WslRemoteOps { } fn mkdir(&self, path: &str) -> Result<(), String> { - // `-p`, so an existing directory is success rather than the - // server-specific error SFTP forces the SSH path to disambiguate. self.sh_ok(&format!("mkdir -p {}\n", shell_quote(path))) } @@ -696,39 +430,11 @@ impl RemoteOps for WslRemoteOps { self.sh_ok(&format!("chmod {mode:o} {}\n", shell_quote(path))) } - /// Write `bytes` to `path` inside the distribution. - /// - /// **Through `wsl.exe -- tee <path>`, not through a `\\wsl$\<distro>\…` UNC - /// path**, and this is the one design decision in this file that had a real - /// alternative. The UNC path is the worse of the two: - /// - /// | | `\\wsl$` UNC write | `tee` over stdio | - /// |---|---|---| - /// | Needs to know the distro's user | Yes — the path embeds `\home\<user>` | No — `$HOME` is asked for, and `/etc/wsl.conf` can make it anything | - /// | Executable bit | Set by the 9p server's defaults, or by a later `chmod` that may not stick without the `metadata` mount option | A real `chmod` inside Linux, on a real filesystem | - /// | Share name | `\\wsl$` on older builds, `\\wsl.localhost` on newer; both work on some, one on others | Not involved | - /// | Throughput | 9p is famously slow for Windows→Linux writes | A pipe | - /// | Compiles/works off Windows | No | Yes (fails cleanly at spawn) | - /// - /// The UNC path's only advantage is that it needs no process inside the - /// distribution — which is irrelevant, because [`RemoteOps`] has to run - /// commands there anyway. - /// - /// `tee` rather than `sh -c 'cat > …'` keeps the path a single argv element - /// instead of a shell word, and `tee` is present in both coreutils and - /// BusyBox. Its stdout — a second full copy of the binary — is discarded at - /// the Windows end rather than redirected inside, which is what lets this - /// stay shell-free. fn put(&self, path: &str, bytes: &[u8]) -> Result<(), String> { let out = self.invoke(&["tee", path], Some(bytes.to_vec()), true, PUT_TIMEOUT)?; if !out.success() { return Err(out.failure_reason()); } - // Verify the length. This is not paranoia about `tee`: it is the check - // that catches any newline translation on the way through `wsl.exe`, - // which is the one way these bytes could plausibly be altered, and which - // would otherwise publish a corrupt binary that fails at exec with - // nothing pointing back here. match self.stat(path)? { Some(stat) if stat.size == bytes.len() as u64 => Ok(()), Some(stat) => Err(format!( @@ -761,38 +467,10 @@ impl RemoteOps for WslRemoteOps { } } -// --------------------------------------------------------------------------- -// The bundled binary (WSL does not download). -// --------------------------------------------------------------------------- - -/// Overrides where the bundled Linux server binaries are looked for. Exists so -/// this can be tested, and so a developer running an unpackaged build can point -/// at a `cargo build --target x86_64-unknown-linux-musl` output. -/// -/// **The file in it has to carry the asset name**, not cargo's — [`Self::locate`] -/// joins the directory with -/// [`asset::ASSET_X86_64`](crate::daemon::install::asset::ASSET_X86_64) and -/// nothing translates, so a cross-compile has to be copied to -/// `tty7-server-linux-x86_64-musl` rather than left as plain `tty7-server`. -/// -/// [`Self::locate`]: BundledServerBinary::locate pub const BUNDLED_DIR_ENV: &str = "TTY7_BUNDLED_SERVER_DIR"; -/// The subdirectory beside the client executable that a packaged build puts the -/// Linux server binaries in. **This is the contract with the release workflow**: -/// the Windows installer must place -/// `<install dir>/server/tty7-server-linux-x86_64-musl` (and the `aarch64` one, -/// for WSL on ARM Windows) — the filenames -/// [`asset::ASSET_X86_64`](crate::daemon::install::asset::ASSET_X86_64) names, -/// because [`BundledServerBinary::locate`] joins the directory with the asset -/// name and nothing translates between the two. pub const BUNDLED_SUBDIR: &str = "server"; -/// Directories a bundled Linux server binary is looked for in, most specific -/// first. -/// -/// Pure, and takes both inputs, so the search order is testable without an -/// installed build. pub fn bundled_search_dirs(exe: Option<&Path>, override_dir: Option<&Path>) -> Vec<PathBuf> { let mut dirs: Vec<PathBuf> = Vec::new(); let mut push = |d: PathBuf| { @@ -804,12 +482,8 @@ pub fn bundled_search_dirs(exe: Option<&Path>, override_dir: Option<&Path>) -> V push(dir.to_path_buf()); } if let Some(exe_dir) = exe.and_then(Path::parent) { - // The packaged layout. push(exe_dir.join(BUNDLED_SUBDIR)); - // A flat layout, and where `cargo build` output lands. push(exe_dir.to_path_buf()); - // A macOS `.app`, so a developer on a Mac can exercise the discovery - // half of this without a Windows box. if let Some(parent) = exe_dir.parent() { push(parent.join("Resources").join(BUNDLED_SUBDIR)); } @@ -817,19 +491,11 @@ pub fn bundled_search_dirs(exe: Option<&Path>, override_dir: Option<&Path>) -> V dirs } -/// The Linux `tty7-server` this client shipped with. -/// -/// A WSL install never downloads: the client's own bundled Linux binary is -/// copied across instead. -/// The version question answers itself, because the bundled binary was built -/// from the same workspace version as the client asking for it; there is no tag -/// to resolve and no manifest to verify against. pub struct BundledServerBinary { dirs: Vec<PathBuf>, } impl BundledServerBinary { - /// Look beside this executable, and at [`BUNDLED_DIR_ENV`] if it is set. pub fn discover() -> Self { let exe = std::env::current_exe().ok(); let over = std::env::var_os(BUNDLED_DIR_ENV) @@ -842,21 +508,11 @@ impl BundledServerBinary { Self { dirs } } - /// Only the directory [`BUNDLED_DIR_ENV`] names, or `None` if it names - /// nothing. - /// - /// [`Self::discover`] also searches beside the executable, which is right - /// for WSL — the Windows installer puts a Linux binary there on purpose — - /// and wrong for anything else: a macOS client has its *own* `tty7-server` - /// next to it, and shipping that to a Linux box would install a binary that - /// cannot run. So the "install from a local copy" path for real remotes - /// takes the explicit directory and nothing else. pub fn from_env_only() -> Option<Self> { let dir = std::env::var_os(BUNDLED_DIR_ENV).filter(|v| !v.is_empty())?; Some(Self::in_dirs(vec![PathBuf::from(dir)])) } - /// The first directory that holds `asset`. pub fn locate(&self, asset: &str) -> Option<PathBuf> { self.dirs .iter() @@ -880,9 +536,6 @@ impl ServerBinarySource for BundledServerBinary { let bytes = std::fs::read(&path).map_err(|e| missing(Some(format!("{} ({e})", path.display()))))?; if bytes.is_empty() { - // A zero-byte file is a broken packaging step, not a binary. Copying - // it would produce a "cannot execute" failure inside the - // distribution with nothing pointing back at the installer. return Err(missing(Some(format!("{} (empty file)", path.display())))); } Ok(LoadedBinary { @@ -892,30 +545,6 @@ impl ServerBinarySource for BundledServerBinary { } } -// --------------------------------------------------------------------------- -// The entry point the router calls. -// --------------------------------------------------------------------------- - -/// One lock per distribution, held across the whole of [`Installer::run`]. -/// -/// **Not an optimisation — a correctness requirement.** Restoring a workspace -/// opens one routed connection per pane, all at once, on separate daemon -/// threads. Without this they run the installer concurrently against the same -/// distribution, and the interleaving is destructive rather than merely wasteful: -/// two runs in *this* process share a pid and so share -/// `.tty7-server-c<c>p<p>.<pid>.tmp`, the first renames it into place -/// and reports success, and the second's rename then fails — which sends it down -/// [`Installer::install`]'s recovery branch, whose `remove_file(&paths.binary)` -/// **deletes the binary the first run just published**. Every later pane then -/// execs a path that is no longer there. -/// -/// That recovery branch is correct for SSH, where `SshManager`'s per-key -/// `ConnSlot` already serialises connects. WSL has no connection object and so -/// had nothing playing that role; this is it. -/// -/// A `std::sync::Mutex` because every caller is a blocking thread, and poisoning -/// is absorbed: a panicked installer leaves no state behind that the next run -/// cannot simply redo. static INSTALL_LOCKS: Mutex<Vec<(String, Arc<Mutex<()>>)>> = Mutex::new(Vec::new()); fn install_lock(distro: &str) -> Arc<Mutex<()>> { @@ -930,58 +559,8 @@ fn install_lock(distro: &str) -> Arc<Mutex<()>> { lock } -/// Make sure `distro` has this client's `tty7-server` installed and a daemon -/// serving, and return the absolute path of that binary inside the distribution. -/// -/// **The WSL counterpart of [`ensure_remote_server`](super::ensure_remote_server).** -/// That one takes an `&Arc<SshConnection>` because everything it does needs SFTP -/// and session channels; there is no connection here to take, and the two share -/// everything that matters anyway — the version-carrying install path, the -/// temp-then-`chmod`-then-`rename` ordering, the `--stdio --bridge` exit-status -/// probe, the launch-and-poll loop, the mismatch record. All of that is -/// [`Installer`], reached through [`Installer::with_source`]; the only -/// differences are which [`RemoteOps`] it drives and where the bytes come from. -/// -/// Unlike the SSH entry point this returns the path rather than `()`, because -/// the WSL transport has no `PATH` to fall back on: the link is -/// `wsl.exe -d <distro> -- <this exact path> --stdio`. -/// -/// # Call this from the GUI first, on a distribution's first connect -/// -/// The router calls this too, but the router runs in the **local daemon -/// process** — and [`set_install_confirm`](super::set_install_confirm) is -/// registered in the **GUI process**. A consent prompt raised from the daemon -/// therefore reaches [`DenyInstall`](super::DenyInstall) and the first install -/// on a distribution fails with "was not confirmed". The same is true of -/// [`take_mismatched_remote_daemons`](super::take_mismatched_remote_daemons), -/// whose registry is a process-local static. -/// -/// WSL is the one transport where that is trivially fixable, because there is -/// no connection to own: [`WslRemoteOps`] is just `wsl.exe` invocations, and the -/// GUI is on the *same machine* as the distribution. So the GUI should call this -/// itself before writing a [`RouteHeader`](super::super::router::RouteHeader) — -/// the prompt is then raised where it can be answered, and the daemon's own call -/// a moment later finds the binary installed and asks nobody. -/// -/// # No result is cached -/// -/// It would be easy to remember the resolved path and skip the rest, and it -/// would be wrong. `wsl --shutdown` is a routine thing for a user to run, and it -/// kills the daemon inside the distribution without touching the binary — so a -/// cached path would keep answering while step 6 no longer held. Every later -/// pane would launch `<path> --stdio`, which with neither `--serve` nor -/// `--bridge` **serves in-process**, and each pane would silently get its own -/// isolated server: sessions stop being shared, panes stop surviving their -/// window, and nothing anywhere reports an error. -/// -/// So every connect re-runs the whole check. That is what -/// [`ensure_remote_server`](super::ensure_remote_server) promises too ("it must -/// be safe to call before every link"), and on the common path it costs a -/// `uname`, a stat and the bridge probe against a distribution that is already -/// running. pub fn ensure_wsl_server(distro: &str) -> io::Result<String> { validate_distro(distro)?; - // Held across the whole run: see `INSTALL_LOCKS`. let lock = install_lock(distro); let _held = lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); @@ -1013,8 +592,6 @@ pub fn ensure_wsl_server(distro: &str) -> io::Result<String> { Ok(report.paths.binary) } -/// Restart `distro`'s daemon at this client's build — the "restart the service" -/// answer to the version-mismatch prompt, dropping every pane it hosts. pub fn restart_wsl_daemon(distro: &str) -> io::Result<()> { validate_distro(distro)?; let ops = WslRemoteOps::new(distro); @@ -1033,9 +610,6 @@ mod tests { use std::collections::BTreeMap; use std::sync::Mutex as StdMutex; - // -- names -------------------------------------------------------------- - - /// Real distribution names, including the awkward ones people actually have. #[test] fn real_distro_names_are_accepted() { for name in [ @@ -1055,8 +629,6 @@ mod tests { } } - /// The refusals. A leading `-` is the one that matters: `wsl.exe` would read - /// it as an option, and `--` protects the *command*, not the `-d` argument. #[test] fn names_that_could_not_be_passed_safely_are_refused() { assert_eq!(validate_distro(""), Err(DistroNameError::Empty)); @@ -1082,16 +654,12 @@ mod tests { )); } - /// The WSL command line, spelled out. This is the exact argv the - /// transport is specified to produce, and it has never been run — so the - /// string is pinned here instead. #[test] fn the_transport_command_line_is_the_designs() { assert_eq!( wsl_args("Ubuntu", &["tty7-server", "--stdio"]), vec!["-d", "Ubuntu", "--", "tty7-server", "--stdio"] ); - // With a resolved absolute path, which is what actually ships. assert_eq!( wsl_args( "Ubuntu-22.04", @@ -1108,21 +676,14 @@ mod tests { "--stdio", ] ); - // `--` is present even with no command, so a later argv cannot slip in - // front of it. assert_eq!(wsl_args("Ubuntu", &[]), vec!["-d", "Ubuntu", "--"]); - // `--cd` is deliberately absent: the inbox `wsl.exe` on Windows 10 - // rejects it outright, which would break every invocation rather than - // just the working directory. See `wsl_args`. assert!( !wsl_args("Ubuntu", &["sh", "-s"]) .iter() .any(|a| a == "--cd") ); - // The distro name is `-d`'s argument and sits *before* `--`, which is - // why it has to be validated rather than protected by the separator. let args = wsl_args("Ubuntu", &["sh", "-s"]); assert!( args.iter().position(|a| a == "Ubuntu").unwrap() @@ -1130,10 +691,6 @@ mod tests { ); } - /// The label a WSL host is prompted about, recorded under, and keyed by has - /// to be the one `RemoteTarget` already defined — these are - /// compared as strings in the mismatch registry and in log lines a user is - /// meant to correlate. #[test] fn the_host_label_is_the_connection_key() { for distro in ["Ubuntu", "Ubuntu-22.04", "my dev box"] { @@ -1144,8 +701,6 @@ mod tests { } } - // -- decoding ----------------------------------------------------------- - fn utf16le(s: &str, bom: bool) -> Vec<u8> { let mut out = Vec::new(); if bom { @@ -1157,31 +712,17 @@ mod tests { out } - /// `wsl.exe` speaks UTF-16LE; the shell inside the distribution speaks - /// UTF-8. Both come back on the same pipes, so both have to decode. #[test] fn both_encodings_on_the_pipe_decode() { assert_eq!(decode_wsl_text(&utf16le("Ubuntu\r\n", true)), "Ubuntu\r\n"); assert_eq!(decode_wsl_text(&utf16le("Ubuntu\r\n", false)), "Ubuntu\r\n"); assert_eq!(decode_wsl_text(b"/home/me\n"), "/home/me\n"); assert_eq!(decode_wsl_text(b""), ""); - // Non-ASCII through both paths. assert_eq!(decode_wsl_text("héllo".as_bytes()), "héllo"); assert_eq!(decode_wsl_text(&utf16le("héllo", false)), "héllo"); - // A UTF-8 BOM from a chatty distribution is stripped, not shown. assert_eq!(decode_wsl_text("\u{feff}ok".as_bytes()), "ok"); } - // -- the scripts, against a real POSIX shell ----------------------------- - // - // `wsl.exe` cannot be run here, but the *scripts it would carry* are plain - // POSIX and there is a POSIX shell on this machine. Running them through - // `sh -s` on stdin — the exact invocation shape the WSL path uses — moves - // everything except the launcher itself from "never executed" to "executed - // on every CI platform", and it is the same `/bin/sh` contract a - // distribution offers. - - /// Feed `script` to a real `sh -s` on stdin and return `(stdout, success)`. #[cfg(unix)] fn real_sh(script: &str) -> (String, bool) { use std::io::Write as _; @@ -1213,8 +754,6 @@ mod tests { dir } - /// A stand-in for the remote `tty7-server`: a script that answers the - /// settle's probe (`code` 0) or never answers it (anything else). #[cfg(unix)] fn fake_server(dir: &std::path::Path, name: &str, code: u8) -> String { use std::os::unix::fs::PermissionsExt as _; @@ -1224,11 +763,6 @@ mod tests { path.to_str().unwrap().to_string() } - /// The settle asks the daemon with *the daemon's own binary*, quoted, in the - /// control dialect — the same question - /// [`Installer::ensure_daemon`] asks from this side. Not `--pane` (that is - /// the other socket and would answer for the wrong thing) and not a test on - /// the socket file, which a stale one passes. #[test] fn the_settle_asks_the_control_dialect_with_the_quoted_binary() { let script = settle_script("/home/a b/tty7-server-26.7.6", 4, "0.2", "0.3"); @@ -1241,15 +775,6 @@ mod tests { assert!(script.contains("-lt 4"), "{script}"); } - /// **The settle, executed against a daemon that answers.** It stops on the - /// first answer and still holds the invocation for [`SETTLE_GRACE`] — so the - /// normal launch is *shorter* than the flat second this replaced, and the - /// wait is still really there. - /// - /// This is the assertion with teeth. A settle that silently became a no-op — - /// a `sleep` a distro rejects, a line lost in a `format!` — reads as a - /// passing test everywhere except against a real distribution, which is - /// where it already cost an afternoon. #[cfg(unix)] #[test] fn the_settle_stops_as_soon_as_the_daemon_answers() { @@ -1271,9 +796,6 @@ mod tests { ); } - /// **The settle, executed against a daemon that never answers.** It spends - /// its whole budget and then returns — a launch that failed has to become - /// `ensure_daemon`'s verdict, not a hung invocation. #[cfg(unix)] #[test] fn the_settle_gives_up_rather_than_hanging() { @@ -1295,10 +817,6 @@ mod tests { ); } - /// The real budget stays well inside the one its caller allows the *whole* - /// launch, or a slow daemon turns into a killed invocation instead of a - /// launched one. Also the guard that both steps are still numbers a `sleep` - /// accepts. #[test] fn the_settle_budget_fits_inside_the_launch_timeout() { let step: f64 = SETTLE_STEP.parse().expect("a number for `sleep`"); @@ -1310,9 +828,6 @@ mod tests { ); } - /// **The stat probe, executed.** Against a real directory, a real - /// executable, a real non-executable file and a real absent path — the four - /// answers [`Installer::run`] branches on. #[cfg(unix)] #[test] fn the_stat_script_really_runs_and_answers_correctly() { @@ -1334,9 +849,6 @@ mod tests { assert!(ok, "{out}"); let stat = parse_stat(&out).unwrap().unwrap(); assert!(!stat.is_dir); - // The size really came back — and `wc -c < f` pads its output with - // spaces on BSD and not on GNU, which is why the parser splits on - // whitespace rather than slicing. assert_eq!(stat.size, 10); assert_eq!(stat.mode & 0o100, 0, "not executable yet"); @@ -1348,8 +860,6 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } - /// **The listing probe, executed**, including a filename with a space and - /// the empty-vs-absent distinction the first-install prompt rests on. #[cfg(unix)] #[test] fn the_list_script_really_runs_and_answers_correctly() { @@ -1379,9 +889,6 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } - /// A path with a space and an apostrophe survives the quoting, executed - /// rather than asserted on the string. This is the case that would silently - /// produce a *different* answer (about the wrong path) rather than an error. #[cfg(unix)] #[test] fn awkward_paths_survive_into_a_running_shell() { @@ -1404,7 +911,6 @@ mod tests { let _ = std::fs::remove_dir_all(dir.parent().unwrap()); } - /// The home probe, executed. #[cfg(unix)] #[test] fn the_home_script_really_reports_an_absolute_path() { @@ -1414,17 +920,9 @@ mod tests { assert!(home.starts_with('/'), "{home:?}"); } - /// **A command inside the script does not eat the rest of the script.** - /// - /// This is the hazard `sh -s` introduces and the reason every command the - /// installer sends redirects its own stdin: the shell is reading its program - /// from the same pipe, so a child left attached to it would consume the - /// remaining lines. `daemon_is_serving`'s `< /dev/null` is exactly that - /// guard, and here it is proven rather than assumed. #[cfg(unix)] #[test] fn a_redirected_command_does_not_consume_the_script_behind_it() { - // `cat` would swallow everything after it without the redirect. let script = format!("cat < /dev/null\nprintf '{MARKER} stat=file 755 42\\n'\n"); let (out, ok) = real_sh(&script); assert!(ok, "{out}"); @@ -1434,8 +932,6 @@ mod tests { "the line after a stdin-reading command must still run" ); - // And the real shape: the daemon probe the installer sends, with a - // stand-in for the server binary that reports "nothing is serving". let probe = format!("{} --stdio --bridge < /dev/null\n", shell_quote("false")); let (_, ok) = real_sh(&probe); assert!(!ok, "a non-serving machine must exit non-zero"); @@ -1444,11 +940,6 @@ mod tests { assert!(ok, "a serving machine must exit zero"); } - /// **The two encodings really do share a pipe**, and the buffer has to - /// survive it line by line. `wsl.exe` prepends its own UTF-16 warnings to - /// stderr in front of whatever the distribution's shell then writes in - /// UTF-8; a single verdict for the whole buffer would turn one half into - /// mojibake — and on stderr that half is the reason the user is shown. #[test] fn a_mixed_encoding_pipe_keeps_both_halves_readable() { let mut mixed = utf16le("wsl: Detected localhost proxy configuration\n", true); @@ -1463,8 +954,6 @@ mod tests { "the distribution's half must survive: {text:?}" ); - // The order the other way round, and with a marker line that a parser - // downstream still has to find. let mut mixed = Vec::from(&b"__tty7_wsl__ home=/home/me\n"[..]); mixed.extend_from_slice(&utf16le("wsl: something happened\n", false)); let text = decode_wsl_text(&mixed); @@ -1476,9 +965,6 @@ mod tests { assert!(text.contains("something happened"), "{text:?}"); } - /// The four mutating scripts, executed. They are the ones with no output to - /// parse, so nothing else would notice if they were malformed — the - /// installer would just report a failed step with the shell's own message. #[cfg(unix)] #[test] fn the_mutating_scripts_really_run() { @@ -1486,12 +972,9 @@ mod tests { let nested = dir.join("a b").join("c'\''d"); let q = |p: &Path| p.to_str().unwrap().to_string(); - // mkdir -p, through a chain with a space and an apostrophe in it. let (_, ok) = real_sh(&format!("mkdir -p {}\n", shell_quote(&q(&nested)))); assert!(ok); assert!(nested.is_dir(), "mkdir did not create {nested:?}"); - // …and again, because the installer walks the chain and re-creates - // levels that already exist. let (_, ok) = real_sh(&format!("mkdir -p {}\n", shell_quote(&q(&nested)))); assert!(ok, "mkdir -p must be idempotent"); @@ -1520,18 +1003,12 @@ mod tests { let (_, ok) = real_sh(&format!("rm -f {}\n", shell_quote(&q(&to)))); assert!(ok); assert!(!to.exists(), "rm did not remove"); - // `rm -f` on something absent is success, which the rename-recovery - // path relies on. let (_, ok) = real_sh(&format!("rm -f {}\n", shell_quote(&q(&to)))); assert!(ok, "rm -f must not fail on a missing file"); let _ = std::fs::remove_dir_all(&dir); } - /// Every script is prefixed with a `cd` to the home directory, because - /// `wsl.exe` would otherwise start it in the translated Windows working - /// directory and the launched daemon would hold a drvfs handle for its whole - /// life. Executed, including the fallback for a user with no home. #[cfg(unix)] #[test] fn scripts_run_from_the_home_directory() { @@ -1540,15 +1017,11 @@ mod tests { let home = std::env::var("HOME").unwrap(); assert_eq!(out.trim(), home.trim_end_matches('/'), "cd did not land"); - // A distribution whose default user has no home must still run the - // script rather than abort it. let (out, ok) = real_sh(&format!("HOME=/no/such/place\n{CD_HOME}pwd\n")); assert!(ok, "{out}"); assert_eq!(out.trim(), "/", "the fallback must be `/`, not a failure"); } - /// The distribution list, as `wsl.exe -l -q` really emits it: UTF-16LE, CRLF, - /// a BOM, and Docker Desktop's internal distributions mixed in. #[test] fn the_distro_list_is_decoded_and_filtered() { let raw = utf16le( @@ -1557,18 +1030,10 @@ mod tests { ); assert_eq!(parse_distro_list(&raw), vec!["Ubuntu-22.04", "Arch"]); assert!(parse_distro_list(&[]).is_empty()); - // WSL on some builds pads names with NULs rather than emitting clean - // lines; the trim covers it. let padded = utf16le("Ubuntu\0\r\n", true); assert_eq!(parse_distro_list(&padded), vec!["Ubuntu"]); } - // -- scripts ------------------------------------------------------------ - - /// Paths are single-quoted into the scripts, so a home directory with a - /// space or an apostrophe cannot end the quoting early. The scripts - /// themselves never reach a Windows command line — they go in on stdin — - /// which is what makes this the only quoting layer involved. #[test] fn script_paths_are_shell_quoted() { let script = stat_script("/home/o'brien/my dir/tty7-server-1.0.0"); @@ -1578,12 +1043,9 @@ mod tests { ); let script = list_script("/home/a b/bin"); assert!(script.contains("d='/home/a b/bin'"), "{script}"); - // No double quotes around anything we interpolate: the only `"` in the - // scripts are the fixed `"$p"` / `"$d"` expansions. assert!(!list_script("/x").contains("\"/x\"")); } - /// The three facts the installer decides on, read back out of the probe. #[test] fn the_stat_probe_round_trips_its_three_answers() { assert_eq!(parse_stat("__tty7_wsl__ stat=none 0 0\n").unwrap(), None); @@ -1591,7 +1053,6 @@ mod tests { let dir = parse_stat("__tty7_wsl__ stat=dir 0 0\n").unwrap().unwrap(); assert!(dir.is_dir); - // Executable: the installer treats this as a usable binary. let exe = parse_stat("__tty7_wsl__ stat=file 755 6291456\n") .unwrap() .unwrap(); @@ -1599,18 +1060,12 @@ mod tests { assert_eq!(exe.size, 6_291_456); assert_ne!(exe.mode & 0o100, 0, "must read as executable"); - // Not executable: a half-finished install, which must be redone rather - // than exec'd. let half = parse_stat("__tty7_wsl__ stat=file 644 6291456\n") .unwrap() .unwrap(); assert_eq!(half.mode & 0o100, 0, "must read as not executable"); } - /// A distribution that printed something of its own before answering is - /// still parsed; one that never answered is an error rather than a - /// confident "not there", because "not there" would trigger a reinstall on - /// every connect. #[test] fn the_stat_probe_ignores_noise_but_not_silence() { let noisy = "Welcome to Ubuntu 22.04\n__tty7_wsl__ stat=file 755 10\nlast login: …\n"; @@ -1620,10 +1075,6 @@ mod tests { assert!(parse_stat("__tty7_wsl__ stat=weird 0 0\n").is_err()); } - /// "Empty" and "absent" are different answers — the first-install prompt - /// treats both as "never written here", but a directory listing that - /// silently became `None` on a real directory would suppress the mismatch - /// bookkeeping too. #[test] fn a_listing_distinguishes_empty_from_absent() { assert_eq!(parse_list("__tty7_wsl__ nolisting\n").unwrap(), None); @@ -1643,8 +1094,6 @@ mod tests { ".tty7-server-26.7.5.tmp".to_string() ]) ); - // A filename with a space survives, because `read -r` and the `%s` - // format keep it on one line. assert_eq!( parse_list("__tty7_wsl__ listing\n__tty7_wsl__ entry=a b c\n").unwrap(), Some(vec!["a b c".to_string()]) @@ -1652,8 +1101,6 @@ mod tests { assert!(parse_list("nothing at all\n").is_err()); } - // -- the bundled binary ------------------------------------------------- - #[test] fn the_search_order_is_specific_first_and_deduplicated() { let exe = PathBuf::from("/opt/tty7/bin/tty7.exe"); @@ -1666,21 +1113,14 @@ mod tests { let dirs = bundled_search_dirs(Some(&exe), Some(&over)); assert_eq!(dirs[0], over, "the override outranks everything"); - // The override being one of the derived directories must not produce a - // duplicate probe. let same = PathBuf::from("/opt/tty7/bin/server"); let dirs = bundled_search_dirs(Some(&exe), Some(&same)); assert_eq!(dirs.iter().filter(|d| **d == same).count(), 1); - // No executable path at all (a test binary, an embedded context): the - // override still works, and nothing else is invented. assert!(bundled_search_dirs(None, None).is_empty()); assert_eq!(bundled_search_dirs(None, Some(&over)), vec![over]); } - /// The bundled source loads bytes and reports where they came from, and its - /// absence is a *named* failure rather than a silent fall back to a - /// download — which is the whole point of the WSL exception. #[test] fn a_missing_bundled_binary_names_every_place_it_looked() { let tmp = std::env::temp_dir().join(format!("tty7-wsl-src-{}", std::process::id())); @@ -1696,7 +1136,6 @@ mod tests { assert!(msg.contains(&tmp.display().to_string()), "{msg}"); assert!(matches!(err, InstallError::MissingBundled { .. })); - // A zero-byte file is a broken packaging step, not an install. let path = tmp.join(super::super::asset::ASSET_X86_64); std::fs::write(&path, b"").unwrap(); let err = source @@ -1714,18 +1153,11 @@ mod tests { let _ = std::fs::remove_dir_all(&tmp); } - // -- the whole install, without a Windows machine ------------------------ - - /// A [`RemoteOps`] that answers like a distribution would, so the WSL - /// install path can be driven end to end on a Mac. Only the process spawn is - /// missing; every command string, path and ordering decision below this is - /// the real one. #[derive(Default)] struct FakeDistro { files: StdMutex<BTreeMap<String, (Vec<u8>, u32)>>, dirs: StdMutex<Vec<String>>, commands: StdMutex<Vec<String>>, - /// Set once the daemon has been "launched". serving: StdMutex<bool>, } @@ -1767,9 +1199,6 @@ mod tests { .trim() .strip_suffix(crate::daemon::install::PROTOCOL_FLAG) { - // Every binary this fake holds is one the installer just put - // there, so it speaks what this build speaks. Anything else - // cannot answer, exactly like a server older than the flag. let exe = exe.trim().trim_matches('\''); return if self.files.lock().unwrap().contains_key(exe) { ok(&crate::daemon::install::RemoteProtocol::of_this_build().to_line()) @@ -1781,7 +1210,6 @@ mod tests { }) }; } - // The `/proc` sweep: no other build is running. ok("") } @@ -1889,10 +1317,6 @@ mod tests { dir } - /// **The WSL install, whole.** The bundled binary is copied into the - /// distribution, published atomically at a versioned path, and a daemon is - /// launched — with no network, no checksum manifest, and no release tag - /// anywhere in it. #[test] fn a_wsl_install_copies_the_bundled_binary_and_launches_a_daemon() { let dir = scratch("install"); @@ -1932,7 +1356,6 @@ mod tests { "the temp file is renamed away, not left behind" ); - // The prompt quotes the *local* file it is about to copy, not a URL. let asked = confirm.0.lock().unwrap(); assert_eq!(asked.len(), 1); assert_eq!( @@ -1945,8 +1368,6 @@ mod tests { assert_eq!(asked[0].size_bytes, b"\x7fELF pretend server".len() as u64); assert!(!asked[0].source_url.starts_with("https://"), "no download"); - // And the probe that decides "is a daemon already serving" is the exit - // status of `--stdio --bridge`, not a socket file's existence. assert!( ops.ran() .iter() @@ -1957,9 +1378,6 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } - /// Second connect to the same distribution: nothing is written, nothing is - /// asked, and no daemon is launched. This is the path every pane after the - /// first takes, so it has to be the cheap one. #[test] fn a_second_connect_installs_nothing_and_asks_nothing() { let dir = scratch("idempotent"); @@ -1983,9 +1401,6 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } - /// Declining writes nothing. Same rule as SSH — writing a binary onto a - /// machine is worth asking about once, and a distribution is still a - /// filesystem the user owns and did not ask us to touch. #[test] fn declining_writes_nothing_into_the_distribution() { let dir = scratch("declined"); @@ -2001,9 +1416,6 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } - /// A client that shipped without the Linux binary fails with a message that - /// says so and names the paths — the packaging bug diagnosed at the moment - /// it bites, rather than as a mysterious connect failure. #[test] fn a_client_with_no_bundled_binary_says_so() { let dir = scratch("nobinary"); @@ -2025,9 +1437,6 @@ mod tests { ); } - /// A distribution running an ARM kernel gets the ARM binary — the asset - /// selection is the same `uname -sm` mapping the SSH path uses, so WSL on - /// ARM Windows needs the aarch64 binary bundled too. #[test] fn the_bundled_asset_follows_the_distributions_architecture() { struct Arm(FakeDistro); @@ -2072,8 +1481,6 @@ mod tests { } let dir = scratch("arm"); - // Only the x86_64 asset is bundled, so an ARM distribution must fail - // rather than be handed a binary that cannot exec. let source = bundled(&dir, b"\x7fELF x86"); let ops = Arm(FakeDistro::default()); let confirm = Approve(StdMutex::new(Vec::new())); @@ -2084,7 +1491,6 @@ mod tests { let msg = err.to_string(); assert!(msg.contains("linux-aarch64-musl"), "{msg}"); - // With it bundled, the same install goes through. std::fs::write(dir.join(super::super::asset::ASSET_AARCH64), b"\x7fELF arm").unwrap(); let source = BundledServerBinary::in_dirs(vec![dir.clone()]); let report = Installer::with_source(&ops, &source, &confirm, host_label("Ubuntu")) @@ -2099,9 +1505,6 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } - /// The install lock is per distribution: two distros never block each other, - /// and asking twice for the same one yields the *same* lock (a fresh lock per - /// call would serialise nothing at all). #[test] fn the_install_lock_is_shared_per_distro_and_not_across_them() { let a1 = install_lock("test-lock-a"); @@ -2110,7 +1513,6 @@ mod tests { assert!(Arc::ptr_eq(&a1, &a2), "same distro must share one lock"); assert!(!Arc::ptr_eq(&a1, &b), "different distros must not block"); - // And it really excludes: while A is held, a second acquire cannot pass. let held = a1.lock().unwrap(); assert!(a2.try_lock().is_err(), "the lock must actually exclude"); assert!(b.try_lock().is_ok(), "another distro is unaffected"); @@ -2118,8 +1520,6 @@ mod tests { assert!(a2.try_lock().is_ok()); } - /// A name that could be misread by `wsl.exe` never reaches a process spawn, - /// and fails as an argument error rather than as a connect timeout. #[test] fn ensure_refuses_an_unusable_distro_name_before_spawning_anything() { let err = ensure_wsl_server("--shutdown").expect_err("refused"); diff --git a/crates/tty7-core/src/daemon/mod.rs b/crates/tty7-core/src/daemon/mod.rs index 5d7da480..b875c74c 100644 --- a/crates/tty7-core/src/daemon/mod.rs +++ b/crates/tty7-core/src/daemon/mod.rs @@ -1,29 +1,3 @@ -//! Persistent terminal daemon: keeps PTYs + their child processes alive across -//! GUI restarts (tmux-style detach/reattach), with the GUI acting as a thin -//! client over a Unix-domain socket. -//! -//! Layout: -//! - [`protocol`] — the framed wire messages shared by client and daemon. -//! - [`control`] — the *control* dialect: the same framing, but multiplexed by -//! request id, carrying the filesystem/git RPCs a remote workspace runs -//! against a machine that isn't this one. -//! - [`transport`] — the cross-platform local stream the protocol rides on -//! (Unix-domain socket on Unix, loopback TCP on Windows). -//! - `pane` (daemon side) — owns one PTY/child, a replay ring, and fan-out. -//! - `server` (daemon side) — the listener, pane registry, `--daemon` -//! entry point. -//! - `spawn` — endpoint resolution + auto-launching the daemon from the GUI. -//! - [`pidfile`] — the daemon's pid marker, letting takeover paths in `spawn` -//! reap a live-but-unreachable daemon instead of stranding it. -//! - [`shell_integration`] — builds the throwaway `ZDOTDIR` (plus the bash/fish -//! equivalents) whose rc files emit OSC 7 / OSC 133. Lives here because the -//! PTY-owning `pane` is the sole injector; keeping it beside its only caller -//! is what lets `daemon` avoid depending back on `terminal`. -//! -//! The client-side terminal that talks this protocol lives in -//! `terminal::remote::RemoteTerminal`, exposing the same surface as the old -//! in-process `Terminal` so the view layer is largely unchanged. - pub mod control; pub mod duplex; pub mod install; @@ -36,18 +10,12 @@ pub mod remote_link; pub mod router; pub mod server; pub mod spawn; -/// Native (russh) SSH session engine — see the module docs. pub mod ssh; pub mod transport; pub(crate) const DETECTED_SHELL_ENV: &str = "TTY7_DETECTED_SHELL"; -// `pub(crate)` rather than private so a future non-daemon spawn path could reuse -// the exact same rc-file setup; today `pane` is the only caller. pub(crate) mod shell_integration; -// Windows process-table helpers (foreground-command title + descendant teardown). -// Windows-only: the Unix path gets the same information from the pty's foreground -// process group and signals. #[cfg(windows)] pub(crate) mod winproc; diff --git a/crates/tty7-core/src/daemon/pane.rs b/crates/tty7-core/src/daemon/pane.rs index a5f9d112..33a07dfa 100644 --- a/crates/tty7-core/src/daemon/pane.rs +++ b/crates/tty7-core/src/daemon/pane.rs @@ -1,32 +1,3 @@ -//! `DaemonPane`: the daemon-side owner of one PTY + child shell. -//! -//! This is the daemon analogue of the client's mirror terminal, but **headless**: -//! it runs no alacritty `Term` and does no rendering. The reader thread instead -//! (a) appends raw PTY bytes to a bounded *replay ring*, (b) forwards them to the -//! currently-attached client as `DaemonMsg::Output`, and (c) feeds an OSC sniffer -//! that learns the cwd (OSC 7) and prompt state (OSC 133) and pushes those to the -//! client. The client rebuilds the screen locally from the attach replay (the -//! ring's segments, a `Size` + `Snapshot` pair each — see [`ReplayRing`]) plus -//! the live `Output` tail. -//! -//! The PTY is driven by [`portable-pty`](portable_pty): a Unix pty on Unix and a -//! ConPTY on Windows, behind one blocking `Read`/`Write`/`resize` API. That keeps -//! this module single-path across platforms — no fd/ioctl/signal code. What stays -//! platform-specific is the foreground-process query behind the pane title / cwd -//! fallback (macOS/Linux proc APIs; a Windows process-table walk in -//! [`winproc`](crate::daemon::winproc)) and the hangup that tears the child's -//! whole process tree down. -//! -//! Shell integration (the hooks that make the shell emit OSC 7 / OSC 133) lives -//! in the sibling [`shell_integration`](crate::daemon::shell_integration) module: -//! the PTY owner is the one place that injects it, so there's a single source of -//! truth and no duplicated rc logic. It covers zsh, bash and fish; on -//! Windows (and any other shell) the pane simply launches bare and the -//! cwd/prompt sniffing stays dormant. A shell that ends up uninstrumented -//! anyway — an rc file that `exec`s into another shell, a nested one started by -//! hand — has its cwd tracked from the process table instead, on the reader's -//! foreground poll (see [`apply_probed_cwd`]). - use std::collections::VecDeque; use std::io::{Read, Write}; use std::path::{Path, PathBuf}; @@ -44,21 +15,11 @@ use crate::daemon::protocol::{ }; use crate::daemon::shell_integration; -/// 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 — PowerShell 7 (`pwsh`) when -/// installed, probed once by `core::shells`, else the `powershell.exe` that -/// ships with every supported Windows. Mirrors Windows Terminal's preference -/// for the modern shell. #[cfg(windows)] fn default_prog() -> CommandBuilder { CommandBuilder::new(crate::core::shells::windows_default_shell()) } -/// On Unix, start from `portable-pty`'s login-shell builder, but switch to an -/// explicit command when the GUI has already detected the shell that launched -/// tty7 and forwarded it to the detached daemon. LaunchServices may otherwise -/// give the daemon a stale config dir and stale `$SHELL`. #[cfg(not(windows))] fn default_prog() -> CommandBuilder { default_prog_with_override(detected_shell_override()) @@ -89,12 +50,6 @@ fn usable_shell_path(path: std::ffi::OsString) -> Option<String> { path.into_os_string().into_string().ok() } -/// The program name used to detect which shell integration applies for the -/// *default* shell. On Unix this is the login shell `portable-pty` resolved -/// (`$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 -/// the same PowerShell `default_prog()` resolved instead. #[cfg(windows)] fn default_shell_name(_cmd: &CommandBuilder) -> String { crate::core::shells::windows_default_shell().to_string() @@ -105,37 +60,17 @@ fn default_shell_name(cmd: &CommandBuilder) -> String { cmd.get_shell() } -/// The shell a spawn resolved to, plus who authored its args. #[cfg_attr(test, derive(Debug, PartialEq, Eq))] struct ChosenShell { program: String, args: Vec<String>, - /// True when `args` are tty7's own defaults from shell discovery rather - /// than the user's, so shell integration may replace them. See - /// [`ShellSpec::args_are_tty7_defaults`]. args_are_tty7_defaults: bool, } -/// Whether the chosen shell carries args tty7 must not second-guess, which is -/// what makes `shell_integration::setup` decline bash and PowerShell (their -/// injections replace argv rather than extend it). Two things have to hold for -/// the args to be off-limits: -/// -/// - there are some — an empty `args: []` (just picking the program) leaves -/// nothing for bash's `--rcfile … -i` to conflict with; and -/// - the *user* wrote them. The new-tab dropdown's args are tty7's own -/// (`core::shells::detect_shells`), so integration is free to express the -/// same intent its own way: Git Bash's `-i -l` means "interactive login -/// shell", exactly what `setup_bash` rebuilds out of `--rcfile … -i` plus a -/// replayed login-file chain. See [`ShellSpec::args_are_tty7_defaults`]. fn has_custom_args(chosen: Option<&ChosenShell>) -> bool { chosen.is_some_and(|c| !c.args.is_empty() && !c.args_are_tty7_defaults) } -/// 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>)>, @@ -147,7 +82,6 @@ fn choose_shell( args_are_tty7_defaults: s.args_are_tty7_defaults, }) .or_else(|| { - // Straight from `config.json` — the user wrote these. configured.map(|(program, args)| ChosenShell { program, args, @@ -161,11 +95,6 @@ fn apply_shell_integration( resolved_program: &str, integration: &shell_integration::Injection, ) { - // `CommandBuilder::new_default_prog()` preserves the Unix login-shell argv0 - // shape, but portable-pty intentionally panics if argv is appended to that - // sentinel builder. Integrations that need argv (fish `-C`, bash `--rcfile`, - // PowerShell flags) must use an explicit command builder first. Env-only zsh - // integration keeps the default login-shell path. if integration.replaces_argv || (cmd.is_default_prog() && !integration.args.is_empty()) { *cmd = CommandBuilder::new(resolved_program); } @@ -187,11 +116,6 @@ fn build_spawn_config( shell: Option<ShellSpec>, ) -> anyhow::Result<SpawnConfig> { let initial_cwd = initial_working_directory(cwd); - // Resolved here rather than inside `build_shell_command` because the WSL tag - // must be read off the shell we *actually* launch. `shell` is only the - // per-spawn override; `config.json` supplies the program when it is `None`, - // and a `wsl.exe` configured there is just as much a WSL pane as one picked - // from the dropdown. let configured = choose_shell(shell, crate::core::config::shell_command()); let remote = wsl_remote_context(configured.as_ref()); let (cmd, integration_dir) = build_shell_command(configured, &initial_cwd)?; @@ -203,20 +127,6 @@ fn build_spawn_config( }) } -/// Tag a `wsl.exe` pane as living in another filesystem namespace, from the -/// resolved shell rather than the process table — `wsl.exe` is exactly what tty7 -/// launched, so there is nothing to detect. -/// -/// This is what makes `TerminalView::local_cwd` decline the distro's cwd, and -/// so what keeps the local git probe, path completion, link resolution and cwd -/// inheritance away from a path that means nothing on this side (and that -/// Windows would read as drive-relative). It is set whether or not shell -/// integration succeeded: an unintegrated WSL pane reports no cwd today, but if -/// it ever does the gate must already be in place. -/// -/// Takes the post-[`choose_shell`] program, not the per-spawn override: a -/// `wsl.exe` written into `config.json` reaches the same integration and so must -/// reach the same tag. fn wsl_remote_context(shell: Option<&ChosenShell>) -> Option<RemoteContext> { if !cfg!(windows) { return None; @@ -232,17 +142,10 @@ fn wsl_remote_context(shell: Option<&ChosenShell>) -> Option<RemoteContext> { Some(RemoteContext { kind: RemoteKind::Wsl, argv: Vec::new(), - // The distro, when the args name one; otherwise `wsl.exe` picks the - // default and we have no name for it without another probe. Shared with - // the integration so the two can't disagree about which distro an argv - // names — they are handed the very same args. target: shell_integration::wsl_distro(&chosen.args).unwrap_or_default(), }) } -/// Build the argv for a spawn from an already-resolved shell (see -/// [`choose_shell`]); `None` means the platform default (the login shell on -/// Unix, PowerShell on Windows). fn build_shell_command( configured: Option<ChosenShell>, initial_cwd: &Option<PathBuf>, @@ -255,21 +158,11 @@ fn build_shell_command( } None => default_prog(), }; - // The program tty7 is actually about to spawn, used (rather than `$SHELL`, - // which can disagree) to detect which shell integration applies. For a - // configured shell this is just its program string; for the platform default - // it's whatever `default_prog()` resolved (passwd/`$SHELL` on Unix, - // `powershell.exe` on Windows — see `default_shell_name`). let resolved_program = match &configured { Some(chosen) => chosen.program.clone(), None => default_shell_name(&cmd), }; - // Shell integration: inject OSC 7 / OSC 133 hooks (zsh/fish/bash/PowerShell, - // and through `wsl.exe` into a distro — see `daemon::shell_integration`). - // Best effort — `None` (an unsupported shell, or one with unpreservable - // custom args) means we launch bare. The args go in because the WSL path - // reads the distro out of them. let integration = shell_integration::setup( Some(&resolved_program), configured.as_ref().map_or(&[][..], |c| c.args.as_slice()), @@ -284,41 +177,17 @@ fn build_shell_command( } fn initial_working_directory(cwd: Option<PathBuf>) -> Option<PathBuf> { - // Working directory for the shell: an explicit `cwd` from the client wins - // (new tab/split inheriting the active pane's dir, or session restore). - // Otherwise fall back to the daemon's own cwd — but skip a bare "/", which - // is what Launch Services hands a `.app` started from Finder/Dock/`open` - // (there's no meaningful inherited dir there). In that case default to the - // user's home, matching Terminal.app / iTerm. Launching from a shell - // (`cargo dev`) still inherits that shell's dir, since it isn't "/". let fallback = std::env::current_dir() .ok() .filter(|d| d != std::path::Path::new("/")) .or_else(|| std::env::var_os("HOME").map(std::path::PathBuf::from)); - // A `working_directory` of Home/Custom forces a base dir, but only when the - // client didn't pass an explicit cwd (tab-inherit / session restore still - // win). Inherit -> `forced` is `None`, so we keep the fallback as before. let forced = crate::core::config::working_directory_base(); - // Whatever wins must actually be a directory *here*. A client cwd is only - // as good as the OSC 7 that produced it, and a shell that reports a path - // this machine cannot resolve — a remote namespace, or an msys path like - // `/c/Users/x` that Windows reads as drive-relative — would otherwise turn - // a new tab or split into a hard spawn failure ("The directory name is - // invalid") instead of quietly falling back. Cheap to check, and it bounds - // the whole class rather than one shell's spelling at a time. [cwd, forced, fallback] .into_iter() .flatten() .find(|d| d.is_dir()) } -/// Whether a GUI-launched child needs tty7 to supply a character locale. -/// -/// tmux checks these variables in this exact order to decide whether its client -/// supports UTF-8; when all three are absent or empty it deliberately renders -/// each Unicode cell as `_`. Any configured locale key is authoritative, even -/// when its value is empty, so the generic `env` override remains capable of -/// opting out of this fallback. #[cfg(any(target_os = "macos", test))] fn locale_fallback_is_needed( extra_env: &std::collections::HashMap<String, String>, @@ -329,30 +198,12 @@ fn locale_fallback_is_needed( }) } -/// Where macOS keeps its locale definitions. A name is usable as a locale only -/// if it has a directory here — the check that keeps us from ever exporting a -/// locale the C library will fail to load. #[cfg(target_os = "macos")] const LOCALE_DEFINITION_DIR: &str = "/usr/share/locale"; -/// Last-resort character locales, in preference order. `C.UTF-8` says exactly -/// what we mean — Unicode character handling with no regional bias — and is -/// built into modern glibc, so it also survives the trip to a Linux host over -/// ssh. It is a recent addition to macOS though, so `en_US.UTF-8` (present on -/// every macOS) backs it up. #[cfg(any(target_os = "macos", test))] const FALLBACK_CHARACTER_LOCALES: [&str; 2] = ["C.UTF-8", "en_US.UTF-8"]; -/// The UTF-8 locale to hand a GUI-launched shell, derived from the user's -/// system locale the way Terminal.app and iTerm2 do it. -/// -/// Every candidate is checked against the locale definitions actually installed -/// (`exists`) before it is used. That check is the whole point: a name the C -/// library cannot load is worse than none at all, because `LC_CTYPE` outranks -/// the `LANG` a remote host sets for itself, and ssh forwards `LC_*` by default -/// (`SendEnv LANG LC_*` ships in the stock `ssh_config`). Exporting an -/// unloadable name would therefore *break* non-ASCII output on every host we -/// ssh into — the bug this seeds a locale to avoid. #[cfg(any(target_os = "macos", test))] fn character_locale(identifier: Option<&str>, exists: impl Fn(&str) -> bool) -> Option<String> { identifier @@ -363,13 +214,6 @@ fn character_locale(identifier: Option<&str>, exists: impl Fn(&str) -> bool) -> .find(|candidate| exists(candidate)) } -/// Reduce a CFLocale/`AppleLocale` identifier to its POSIX `lang_REGION` stem: -/// `zh_Hans_CN@calendar=gregorian` -> `zh_CN`, `en-US` -> `en_US`. -/// -/// POSIX locale names carry no script subtag, so `Hans` in the middle is -/// dropped. A region is required — a bare `zh` cannot name a locale, and -/// guessing a default region for a language would be inventing a user -/// preference rather than reading one. #[cfg(any(target_os = "macos", test))] fn posix_locale_stem(identifier: &str) -> Option<String> { let base = identifier.split('@').next()?; @@ -377,8 +221,6 @@ fn posix_locale_stem(identifier: &str) -> Option<String> { let language = parts .next() .filter(|l| (2..=3).contains(&l.len()) && l.chars().all(|c| c.is_ascii_alphabetic()))?; - // ISO 3166-1 alpha-2 ("CN") or UN M.49 numeric ("419"); anything else in the - // trailing position is a script or variant subtag, which POSIX has no slot for. let region = parts.next_back().filter(|r| { (r.len() == 2 && r.chars().all(|c| c.is_ascii_alphabetic())) || (r.len() == 3 && r.chars().all(|c| c.is_ascii_digit())) @@ -390,10 +232,6 @@ fn posix_locale_stem(identifier: &str) -> Option<String> { )) } -/// The current system locale's identifier (e.g. `en_CN`, `zh_Hans_CN`). -/// -/// Read from CoreFoundation rather than the `defaults` domain so it reflects -/// the same resolved preference the rest of the system sees. #[cfg(target_os = "macos")] fn system_locale_identifier() -> Option<String> { use core_foundation::base::TCFType; @@ -412,8 +250,6 @@ fn system_locale_identifier() -> Option<String> { if locale.is_null() { return None; } - // `Get` rule: the identifier is owned by the locale, so it is wrapped - // without taking ownership and only the locale itself is released. let identifier = CFLocaleGetIdentifier(locale); let out = (!identifier.is_null()).then(|| CFString::wrap_under_get_rule(identifier).to_string()); @@ -422,22 +258,10 @@ fn system_locale_identifier() -> Option<String> { } } -/// What tty7 answers to in `TERM_PROGRAM`. Terminals name themselves in the -/// form they brand themselves in — `Apple_Terminal`, `iTerm.app`, `WezTerm`, -/// `ghostty`, `vscode` — so ours is the lowercase product name. const TERM_PROGRAM_NAME: &str = "tty7"; -/// Env keys that describe our emulator's real capabilities. A user's `env` map -/// must not override these: the answer isn't a preference, it's a fact about -/// what the pane on the other end can decode. const CAPABILITY_ENV: [&str; 2] = ["TERM", "COLORTERM"]; -/// Whether a configured `env` key names one of [`CAPABILITY_ENV`]. Windows -/// environment blocks are case-insensitive — `portable-pty` keeps one slot per -/// lowercased key, so a configured `Term` there would replace `TERM` just as -/// surely as the exact spelling — so the filter must use the platform's own -/// notion of "the same variable". On Unix a differently-cased key is a genuinely -/// distinct variable and stays the user's to set. fn names_capability_env(key: &str) -> bool { CAPABILITY_ENV.iter().any(|cap| { if cfg!(windows) { @@ -448,39 +272,17 @@ fn names_capability_env(key: &str) -> bool { }) } -/// The environment every pane starts with, in application order — tty7's own -/// advertisements first, then the user's `env` map, which overrides all but -/// [`CAPABILITY_ENV`]. Returned as a list rather than applied in place so the -/// precedence is testable without a `CommandBuilder` or a real `config.json`. fn pane_environment( extra_env: &std::collections::HashMap<String, String>, ) -> Vec<(String, String)> { let version = env!("CARGO_PKG_VERSION"); let mut env = vec![ - // A widely-available terminfo + truecolor. ("TERM".to_string(), "xterm-256color".to_string()), ("COLORTERM".to_string(), "truecolor".to_string()), - // Mark the session as tty7's, for tooling that adapts to its host - // terminal — most importantly the `tty7 agent-hook` emitter, which - // stays silent without it so globally-installed agent hooks can't leak - // escape sequences into other terminals (see `core::agent_hooks`). ( crate::core::agent_hooks::TTY7_ENV_MARKER.to_string(), version.to_string(), ), - // The de-facto standard pair for "which terminal is this": Apple - // Terminal introduced it, and iTerm2, WezTerm, Ghostty, VS Code and - // tmux all set it. `TERM` describes terminfo capabilities and can't - // answer this — but capability probes (`supports-color`, - // `supports-hyperlinks` and the JS CLI ecosystem built on them), - // editors applying terminal-specific workarounds, and shell prompts all - // branch on the program name, falling back to their most conservative - // behaviour when it's missing. `TTY7` doesn't help them: it's ours, and - // nothing third-party knows to look for it. - // - // Deliberately overridable below, unlike the capability keys: this - // names an identity, and posing as another terminal is a legitimate way - // to get a tool that only recognises a fixed list to light up. ("TERM_PROGRAM".to_string(), TERM_PROGRAM_NAME.to_string()), ("TERM_PROGRAM_VERSION".to_string(), version.to_string()), ]; @@ -502,13 +304,6 @@ fn apply_common_command_setup(cmd: &mut CommandBuilder, initial_cwd: &Option<Pat cmd.env(k, v); } - // LaunchServices commonly starts a macOS app with no locale variables at - // all, so a GUI-launched shell runs in the C locale: `ls` prints one `?` per - // non-ASCII byte, and tmux substitutes one `_` per Unicode cell. Seed only - // LC_CTYPE — character handling is the broken part, and leaving LANG alone - // keeps message/date/number localization as the user set it. Respect any - // inherited non-empty locale and every user-configured locale key, including - // `C` or an empty value used to opt out of the fallback. #[cfg(target_os = "macos")] if locale_fallback_is_needed(&extra_env, |key| std::env::var(key).ok()) && let Some(locale) = character_locale(system_locale_identifier().as_deref(), |name| { @@ -521,54 +316,19 @@ fn apply_common_command_setup(cmd: &mut CommandBuilder, initial_cwd: &Option<Pat } } -/// 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 -/// from some recent point onward, and a client's emulator tolerates a truncated -/// prefix far better than a hole punched in the middle. const RING_CAP: usize = 8 * 1024 * 1024; -/// Cap on the ring's geometry segments. The client does a full grid reflow per -/// replayed `Size`, and drag-resizing a pane whose TUI redraws on every -/// SIGWINCH cuts a segment per column change — tiny segments that never fill -/// `RING_CAP`, so over a long-lived pane's life they would accumulate without -/// bound and attach would degrade linearly. Past the cap the two *oldest* -/// segments merge (the older one's bytes replay at the newer one's geometry): -/// like the byte cap, precision degrades from the oldest scrollback first. const MAX_RING_SEGMENTS: usize = 64; const REMOTE_CONTEXT_POLL_INTERVAL: Duration = Duration::from_millis(500); -/// Backpressure between a pane's PTY reader and its connection writer: counts -/// the `Output` bytes sitting in the (unbounded) channel, and parks the reader -/// while the backlog is at the high-water mark. Without it the daemon slurps -/// the PTY far faster than a client can parse (the ring append no longer -/// throttles reads), so a long-running flood (`yes` in a pane) would grow the -/// queue without bound. Pausing the *reader* is exactly PTY backpressure: the -/// kernel buffer fills and the child blocks on write, like a slow real tty. pub struct OutputGate { - /// Bytes handed to the writer channel but not yet written out. Atomic — - /// `add` runs per PTY read (~100k/s at full drain) and `sub` per socket - /// write, so the hot paths must not take a lock. Signed so a late - /// decrement racing a `reset` only drifts permissive (negative) instead - /// of underflowing. queued: AtomicI64, - /// Guards no data — it exists so a `sub`/`reset` notify can't slip between - /// a parked reader's re-check of `queued` and its condvar wait (the - /// classic lost-wakeup race). Only touched on the slow paths: an actual - /// park, and the wakeup that crosses back below the mark. park: Mutex<()>, drained: Condvar, } impl OutputGate { - /// Max Output bytes in flight before the PTY reader pauses. Sized to - /// swallow a big burst whole (a 10+ MB `cat`, a build log dump) so the - /// PTY drains at device speed and the client parses in its own time — - /// while still bounding what a nonstop flood (`yes`) can pin per pane. const HIGH_WATER: i64 = 16 * 1024 * 1024; - /// Upper bound on one backpressure pause. Attach/detach reset the counter; - /// if an accounting slip ever left it stuck high anyway, this degrades to - /// slow-drain instead of a wedged PTY. const MAX_WAIT: Duration = Duration::from_secs(2); pub(crate) fn new() -> Self { @@ -579,36 +339,24 @@ impl OutputGate { } } - /// Record `n` Output bytes handed to the writer channel. fn add(&self, n: usize) { self.queued.fetch_add(n as i64, Ordering::Relaxed); } - /// Record `n` Output bytes leaving the channel (written to the socket, or - /// dropped with a failed one — either way they no longer occupy memory). pub fn sub(&self, n: usize) { let prev = self.queued.fetch_sub(n as i64, Ordering::Relaxed); - // Wake the parked reader only when this decrement crosses back below - // the mark — not on every frame written. The lock makes the notify - // ordered against a parking reader's re-check (see `park`). if prev >= Self::HIGH_WATER && prev - (n as i64) < Self::HIGH_WATER { let _park = self.park.lock().unwrap(); self.drained.notify_all(); } } - /// Forget all in-flight accounting: the subscriber changed and any queued - /// frames died with the old channel. fn reset(&self) { self.queued.store(0, Ordering::Relaxed); let _park = self.park.lock().unwrap(); self.drained.notify_all(); } - /// Park the caller (the PTY reader; it must hold no locks) while the - /// backlog is at/over the high-water mark, up to [`Self::MAX_WAIT`]. - /// Lock-free when the backlog is below the mark — the common case, checked - /// before every PTY read. fn wait_below_high_water(&self) { if self.queued.load(Ordering::Relaxed) < Self::HIGH_WATER { return; @@ -626,176 +374,58 @@ impl OutputGate { } } -/// Shared, mutable inner state of a pane. Split from the immutable handles (the -/// PTY master, writer, child) so a single `Mutex` guards everything the reader -/// thread and the connection threads both touch. struct PaneState { - /// The registry id of the pane this state belongs to — [`DaemonPane::id`], - /// duplicated here so the code paths that only ever see the state (the - /// signal appliers, [`DeathReporter::report`]) can name the pane when - /// publishing an observation to the machine tree - /// ([`crate::core::machine::observe_pane`]). id: u64, - /// The replay ring: raw PTY bytes bounded to `RING_CAP`, segmented by the - /// geometry they were recorded under so `attach` can replay each stretch - /// at the width it was written for. Also the owner of the pane's current - /// size (the tail segment's geometry). See [`ReplayRing`]. ring: ReplayRing, - /// The currently-attached client's outbound channel, or `None` when detached. - /// v1 is single-subscriber: a new attach replaces this, and the old - /// connection's receiver then sees its sender dropped and ends. subscriber: Option<Sender<DaemonMsg>>, - /// Monotonic generation bumped on every `attach`. A connection remembers the - /// epoch it installed; `detach` only clears the subscriber if it still owns - /// that epoch, so a *replaced* connection tearing down can't blank the live - /// subscriber a newer attach just installed (e.g. session-restore reattach, - /// where the old GUI's connection lingers while the new one takes over). subscriber_epoch: u64, - /// The pane's current directory, so a fresh attach can be told immediately. - /// Seeded with the spawn directory, refined by the shell's OSC 7 reports, - /// and — for shells that emit none — corrected from the process table by the - /// reader's poll (see [`apply_probed_cwd`]). cwd: Option<PathBuf>, - /// Shell prompt/command state from OSC 133. shell: ShellState, - /// Trusted foreground remote context from the local process table. remote: Option<RemoteContext>, - /// The third-party CLI coding agent running in the foreground, detected from - /// the foreground `argv` (same process-table poll as `remote`). `None` when - /// no known agent runs — see [`crate::core::cli_agent`]. agent: Option<crate::core::cli_agent::CLIAgent>, - /// The argv the detected agent was launched with, held here until a rich - /// session exists to stamp it into (the sentinel events that create the - /// session can land before the first argv poll, and vice versa). Cleared - /// with the chip. See [`stamp_launch_argv`]. agent_argv: Option<Vec<String>>, - /// The rich agent-session status (idle/working/waiting/done + native - /// session id), folded from the sentinel OSC events the agent's hooks emit - /// (with an opaque OSC 9/777 fallback). Cleared when the agent exits. - /// See [`crate::core::cli_agent::AgentSessionState`]. agent_session: Option<crate::core::cli_agent::AgentSessionState>, - /// False once the child has exited; the pane lingers so its ring stays - /// readable by a late attach. alive: bool, } -/// The byte source behind a pane. The PTY path (`Pty`) is byte-for-byte the -/// original local-shell backend; `NativeSsh` is a russh shell channel bridged to -/// the same blocking reader/writer contract (see [`crate::daemon::ssh::session`]). -/// The reader thread, replay ring, `OutputGate`, and OSC sniffer are identical for -/// both — only the handle-owning bits (resize, kill/hangup, foreground queries, -/// reap) differ, and dispatch on this enum. enum PaneBackend { Pty(PtyBackend), NativeSsh(NativeSshBackend), } -/// The reader thread's two off-hot-path foreground probes, bundled so -/// [`DaemonPane::spawn_reader`] takes them as one argument. Both are -/// process-table reads (foreground process-group leader → `argv`) run together -/// on the reader's 0.5 s poll: `remote` classifies an SSH context, `agent` -/// classifies a third-party coding agent. Boxed rather than generic because -/// they're invoked at most twice a second — the indirection is free here and -/// keeps the reader's signature readable. struct ForegroundProbes { remote: Box<dyn Fn() -> Option<RemoteContext> + Send>, - /// Outer `None` means this backend has no process-table view of the PTY - /// foreground at all (native SSH; Windows, where ConPTY has no foreground - /// process group) — "no opinion", never applied, so it can't wipe an agent - /// identified another way (sentinel events, the Windows `133;C;<cmd>` - /// mark). `Some(answer)` is a real poll result; its inner `None` ("polled, - /// no agent") clears the chip. A detected agent travels with the `argv` it - /// was identified from, kept for flag carry-over on session resume. agent: Box<dyn Fn() -> Option<Option<(crate::core::cli_agent::CLIAgent, Vec<String>)>> + Send>, - /// The PTY owner's cwd straight from the process table. `None` means "no - /// reading" (native SSH, Windows, or a process we can't inspect), never - /// "no cwd" — see [`apply_probed_cwd`] for how a reading is reconciled with - /// the shell's own OSC 7 report. cwd: Box<dyn Fn() -> Option<PathBuf> + Send>, } -/// The local-PTY backend: the same handles `DaemonPane` has always owned. struct PtyBackend { - /// The PTY master. Kept for the pane's lifetime to `resize` it and to query the - /// foreground process group (macOS title / cwd fallback, and the reader - /// thread's remote-prompt gate — see [`foreground_command_running`]). Behind a - /// `Mutex` because the trait object is `Send` but not `Sync`; wrapped in an - /// `Arc` so the reader thread can hold its own handle for that gate. master: Arc<Mutex<Box<dyn MasterPty + Send>>>, - /// The child shell. Behind a `Mutex` so `kill` (and `Drop`'s reap) can take it - /// `&mut`. `kill()` hangs the child up (SIGHUP on Unix); `Drop` then waits it. child: Mutex<Box<dyn Child + Send + Sync>>, - /// Child shell pid, when the platform reports one. Used to signal the - /// process group on Unix and as the proc-query fallback target on - /// macOS/Linux (hence dead on Windows). #[cfg_attr(windows, allow(dead_code))] shell_pid: Option<u32>, - /// Throwaway dir backing shell integration (zsh's `ZDOTDIR`, bash's - /// `--rcfile`), removed on drop. `None` if bare, or if the shell (fish) - /// needed no on-disk file at all. integration_dir: Option<PathBuf>, } -/// The native-SSH backend: a handle to the async channel driver (for resize / -/// close) plus the resolved remote context reported to the GUI. Resize becomes a -/// `window-change`; kill/hangup closes the channel; foreground/pgid queries are -/// `None` (a remote session has no local process group — the OSC 133 gate is a -/// no-op, which is correct, per the pipeline brief §9). struct NativeSshBackend { handle: Arc<crate::daemon::ssh::SshSessionHandle>, - /// The pane's russh connection, published by the connect task once - /// authenticated (a `Weak`, upgraded on demand). The seam WS4/WS5 reach - /// through [`DaemonPane::ssh_connection`]. connection: crate::daemon::ssh::SharedConnection, } -/// One live pane: a byte-source backend plus the shared [`PaneState`]. Shared -/// across connection threads via `Arc`; all mutable stream state lives behind the -/// locks. pub struct DaemonPane { pub id: u64, - /// The workspace this pane was spawned for (a `WorkspaceId` uuid string), - /// when the spawning client said — see `ClientMsg::Spawn`'s `owner`. - /// Immutable for the pane's lifetime: ownership is decided at spawn, and a - /// pane that could change hands would be exactly the ambiguity this field - /// exists to close. Reported in `List` ([`PaneInfo::owner`]) so restore can - /// refuse to attach a workspace to a pane another one owns. owner: Option<String>, - /// The byte source (local PTY or native-SSH channel). backend: PaneBackend, - /// The input side (keyboard input / pasted text): the PTY writer, or the - /// native-SSH channel writer. Behind a `Mutex` because writes can arrive from - /// different connection threads. writer: Mutex<Box<dyn Write + Send>>, - /// Set during teardown so the reader doesn't emit a spurious exit. shutting_down: Arc<AtomicBool>, - /// Output backpressure shared by the reader thread (adds + waits) and the - /// connection's writer thread (drains). See [`OutputGate`]. gate: Arc<OutputGate>, state: Arc<Mutex<PaneState>>, - /// The reader `JoinHandle`, taken and joined in `Drop`. reader: Mutex<Option<JoinHandle<()>>>, - /// Auth/host-key prompt broker for native-SSH panes; `None` for PTY panes. - /// `run_stream` routes `ClientMsg::AuthResponse` here via - /// [`DaemonPane::deliver_auth_response`]. broker: Option<Arc<crate::daemon::ssh::PromptBroker>>, } -/// Fires a pane's "child gone" notification exactly once, whichever thread -/// notices the death first. On Unix the reader thread sees it as a PTY `read()` -/// EOF and reports here. On Windows the ConPTY output pipe does *not* EOF when -/// the shell exits on its own — it only closes on `ClosePseudoConsole`, so a -/// natural `exit` / Ctrl-D would leave the reader blocked forever and the pane -/// wedged open (see [`DaemonPane::spawn_exit_monitor`]). There a separate thread -/// waits on the child handle and reports here instead. The `reported` latch keeps -/// whichever route fires second a no-op, so a subscriber never sees two `Exited`s -/// and `on_dead` runs at most once. struct DeathReporter { reported: AtomicBool, - /// The server's reclaim hook, consumed the first time the pane dies with - /// nobody attached. Behind a `Mutex<Option<…>>` because it's a `FnOnce` - /// shared between the reader and (on Windows) the monitor — whichever fires - /// first takes it. on_dead: Mutex<Option<Box<dyn FnOnce() + Send>>>, } @@ -807,11 +437,6 @@ impl DeathReporter { } } - /// Mark the pane not-alive and, unless the owner already began teardown - /// (`shutting_down` — the killer owns cleanup then), tell the attached - /// subscriber it exited; with nobody attached, hand the pane to `on_dead` so - /// the server drops it instead of leaking the zombie child + replay ring. - /// Idempotent: only the first call has any effect. fn report(&self, state: &Mutex<PaneState>, shutting_down: &AtomicBool) { if self.reported.swap(true, Ordering::SeqCst) { return; @@ -821,10 +446,6 @@ impl DeathReporter { let pane = st.id; if shutting_down.load(Ordering::SeqCst) { drop(st); - // Even a teardown the owner initiated is a death the tree must - // hear about: the record's `live == false` *is* the client-visible - // "awaiting revival" state, and it must not depend on which thread - // noticed the child go. crate::core::machine::observe_pane(pane, |p| p.live = false); return; } @@ -834,8 +455,6 @@ impl DeathReporter { } drop(st); crate::core::machine::observe_pane(pane, |p| p.live = false); - // A subscriber's later detach reclaims the pane, so only an *unattached* - // death needs `on_dead` — and it fires at most once. if subscribed { return; } @@ -846,15 +465,6 @@ impl DeathReporter { } 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. `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, or on Windows the child-exit monitor) 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>, @@ -871,15 +481,8 @@ impl DaemonPane { let child = pair.slave.spawn_command(spawn.cmd)?; let shell_pid = child.process_id(); - // Drop the slave handle now: the child holds its own slave fds, and our - // extra handle must close so the master read side reports EOF when the - // child exits (otherwise the reader thread would never see the hangup). drop(pair.slave); - // An independent, *blocking* reader handle for the reader thread; the - // master itself stays for resize + fg-process queries, and the writer is - // taken once for input. (This is what makes the daemon's threaded model - // work identically on Unix and Windows.) let reader_handle = pair.master.try_clone_reader()?; let writer = pair.master.take_writer()?; @@ -918,14 +521,8 @@ impl DaemonPane { broker: None, }); - // Both the reader's EOF and (on Windows) the child-exit monitor report a - // death through this shared, run-once latch — see [`DeathReporter`]. let death = Arc::new(DeathReporter::new(on_dead)); - // Windows: the ConPTY output pipe never EOFs on a *natural* child exit, so - // the reader alone would never notice `exit` / Ctrl-D and the pane would - // hang open. Watch the shell handle directly and report through the same - // latch. No-op on Unix, where the reader's `read()` EOF already covers it. #[cfg(windows)] Self::spawn_exit_monitor( shell_pid, @@ -934,10 +531,6 @@ impl DaemonPane { death.clone(), ); - // The reader gates a foreground program's OSC 133 prompt marks (a remote - // shell over ssh emitting its own) out of `at_prompt`, so tty7's local - // line editor stays disengaged for whatever is really reading the - // keyboard — see `foreground_command_running` / issue #26. let fg_master = master.clone(); let remote_master = master.clone(); let agent_master = master.clone(); @@ -960,29 +553,17 @@ impl DaemonPane { Ok(pane) } - /// Spawn a native-SSH pane: a russh shell channel bridged into the *same* - /// reader/ring/gate/sniffer pipeline as a PTY pane. Returns immediately; the - /// connect → auth → shell sequence runs on the SSH engine's runtime and drives - /// this pane through the bridge. Auth/host-key prompts and progress ride this - /// pane's own connection via the prompt broker; a failed connect surfaces as a - /// normal `Exited` (the driver drops its data sender, EOFing the reader). pub fn spawn_native_ssh( id: u64, size: WinSize, spec: Box<NativeSshSpec>, on_dead: impl FnOnce() + Send + 'static, ) -> anyhow::Result<Arc<Self>> { - // The async↔blocking bridge: blocking reader/writer for the daemon threads, - // plus the async ends the channel driver takes. let bridge = crate::daemon::ssh::session::make_bridge(); let reader_handle: Box<dyn Read + Send> = Box::new(bridge.reader); let writer: Box<dyn Write + Send> = Box::new(bridge.writer); - // The connect task fills this once authenticated; the pane exposes it to - // WS4/WS5 via `ssh_connection()`. let connection: crate::daemon::ssh::SharedConnection = Arc::new(Mutex::new(Weak::new())); - // The remote context the GUI reads to label this as a native-SSH pane. - // Forwarding/SFTP reach the connection through the in-memory registry. let target = spec .display_name .clone() @@ -998,12 +579,9 @@ impl DaemonPane { ring: ReplayRing::new(size), subscriber: None, subscriber_epoch: 0, - // The remote cwd is unknown until the remote shell's OSC 7 arrives. cwd: None, shell: ShellState::default(), remote: Some(remote), - // A native-SSH pane has no local process group, so foreground-argv - // agent detection never runs for it. agent: None, agent_session: None, agent_argv: None, @@ -1012,9 +590,6 @@ impl DaemonPane { let shutting_down = Arc::new(AtomicBool::new(false)); let gate = Arc::new(OutputGate::new()); - // The prompt broker emits `AuthPrompt`/`SshStatus` frames to whatever - // client is currently subscribed to this pane, and returns whether a - // subscriber was present (so a prompt can wait for the attach to land). let broker = { let state = state.clone(); crate::daemon::ssh::PromptBroker::new(Box::new(move |msg: DaemonMsg| { @@ -1027,9 +602,6 @@ impl DaemonPane { let pane = Arc::new(Self { id, - // Native-SSH spawns don't carry an owner yet: their leaves persist - // an `ssh_spec` and reconnect from it rather than by pane id, so - // the ownership check has nothing to protect there today. owner: None, backend: PaneBackend::NativeSsh(NativeSshBackend { handle: bridge.handle, @@ -1045,15 +617,6 @@ impl DaemonPane { let death = Arc::new(DeathReporter::new(on_dead)); - // A remote session has no local PTY foreground process group, so both - // gate closures answer "nothing local": OSC 133 marks from the remote - // shell are trusted verbatim (correct — the remote shell *is* the session), - // and no process-table SSH detection runs (this pane already *is* SSH). - // The agent probe's `None` is "no opinion" (never applied), so an agent - // identified from its sentinel events keeps its chip — the poll used to - // wipe it within half a second. The cwd probe likewise: this pane's - // directory lives in the remote's namespace, and the only cwd the local - // process table could offer is meaningless here. let reader = Self::spawn_reader( state, shutting_down, @@ -1069,7 +632,6 @@ impl DaemonPane { ); *pane.reader.lock().unwrap() = Some(reader); - // Kick off the connection on the SSH engine's runtime. crate::daemon::ssh::SshManager::global().spawn_native_session( id, spec, @@ -1083,22 +645,13 @@ impl DaemonPane { Ok(pane) } - /// Deliver a GUI `AuthResponse` to a native-SSH pane's pending auth prompt. - /// A no-op for PTY panes (no broker). pub fn deliver_auth_response(&self, request_id: u64, response: AuthResponse) { if let Some(broker) = &self.broker { broker.deliver(request_id, response); } } - /// The shared russh connection behind a native-SSH pane, if any — the seam WS4 - /// (port-forwards) and WS5 (SFTP) use to open further channels on the pane's - /// existing connection (`open_direct_tcpip` / `open_session_channel`). Returns - /// `None` for a PTY pane, or for a native pane that hasn't finished - /// authenticating (or whose connection has since dropped). Upgraded from a - /// `Weak`, so holding the returned `Arc` keeps the connection alive only for as - /// long as the caller needs it. - #[allow(dead_code)] // seam consumed by WS4 (forwards) / WS5 (SFTP) + #[allow(dead_code)] pub fn ssh_connection(&self) -> Option<Arc<crate::daemon::ssh::SshConnection>> { match &self.backend { PaneBackend::NativeSsh(b) => b.connection.lock().unwrap().upgrade(), @@ -1106,24 +659,11 @@ impl DaemonPane { } } - /// Reader thread: blocking-reads PTY bytes and, for each chunk, (a) appends to - /// the ring (dropping the oldest bytes past `RING_CAP`), (b) forwards them to - /// the subscriber as `Output`, (c) sniffs OSC 7 / OSC 133 and pushes `Cwd` / - /// `Prompt` on change. On EOF it reports the death through `death` — marking - /// the pane not-alive and sending `Exited`, keeping the ring for a later - /// attach, or handing an unattached pane to `on_dead` (see [`DeathReporter`]). - /// - /// The off-hot-path foreground probes (remote context, coding agent, cwd) - /// travel together in [`ForegroundProbes`]: all are process-table reads run - /// on the same 0.5 s poll, and bundling them keeps the signature at arity. fn spawn_reader( state: Arc<Mutex<PaneState>>, shutting_down: Arc<AtomicBool>, gate: Arc<OutputGate>, mut reader: Box<dyn Read + Send>, - // "Is a foreground command (not the shell) currently on the PTY?" Consulted - // when a prompt mark arrives, to reject marks a foreground program emits — - // see the call site and [`foreground_command_running`]. foreground_running: impl Fn() -> bool + Send + 'static, probes: ForegroundProbes, death: Arc<DeathReporter>, @@ -1136,16 +676,10 @@ impl DaemonPane { std::thread::Builder::new() .name("tty7-daemon-pane-reader".to_string()) .spawn(move || { - // Every microsecond this thread spends off `read()` stalls the - // child's writes (macOS PTY buffers are ~1 KiB deep), so don't - // let the scheduler park it on an efficiency core. crate::core::threads::promote_to_user_interactive(); let mut sniffer = OscSniffer::new(); let mut buf = [0u8; 65536]; - // TTY7_TRACE=1: per-second PTY-drain accounting on stderr (the - // daemon must run in the foreground to see it), to localize - // throughput stalls (PTY wait vs lock+dispatch). let trace = std::env::var("TTY7_TRACE").is_ok_and(|v| !v.is_empty() && v != "0"); let mut tr_last = std::time::Instant::now(); let mut tr_bytes: u64 = 0; @@ -1170,12 +704,10 @@ impl DaemonPane { tr_read_t = std::time::Duration::ZERO; tr_disp_t = std::time::Duration::ZERO; } - // Backpressure: let the writer drain before pulling more - // out of the PTY (no locks are held here). gate.wait_below_high_water(); let tr0 = trace.then(std::time::Instant::now); match reader.read(&mut buf) { - Ok(0) => break, // EOF: child exited / was hung up. + Ok(0) => break, Ok(n) => { if let Some(tr0) = tr0 { tr_read_t += tr0.elapsed(); @@ -1183,59 +715,21 @@ impl DaemonPane { tr_bytes += n as u64; } let bytes = &buf[..n]; - // Sniff first (cheap, over the same bytes); collect any - // cwd/prompt change to emit while we hold the lock. let mut signals = sniffer.feed(bytes); - // Reject a prompt mark emitted by a *foreground program* - // rather than the shell tty7 spawned. The shell only - // emits its OSC 133 marks while it is the PTY's own - // foreground group (idle at its prompt); a mark arriving - // while a command owns the PTY therefore comes from that - // command — most visibly a remote shell over ssh drawing - // its own prompt. Trusting it would flip `at_prompt` true - // and engage tty7's *local* line editor, whose completion - // and history are local-only and wrong for the remote - // session (Tab completed local paths instead of the - // remote's). Drop the flag so keys pass raw to whatever is - // really reading them. The proc query runs only when a - // mark actually claims the prompt — about once per prompt. - // See issue #26. if signals.shell.iter().any(|s| s.at_prompt) && foreground_running() { for s in signals.shell.iter_mut() { s.at_prompt = false; } - // Clearing the flag can leave neighbours identical; - // they no longer describe a crossing, so don't spend - // a frame on each. signals.shell.dedup(); } - // SSH-context + coding-agent detection are process-table - // queries (sysctl/procfs). Keep them out of the state lock - // and off the per-chunk hot path; half-second freshness is - // enough for link hover/click state and the agent tab chip - // while keeping PTY drain latency predictable. Both ride the - // one poll gate so we read the foreground process at most - // twice per interval. let poll_now = std::time::Instant::now() >= next_remote_check; if poll_now { next_remote_check = std::time::Instant::now() + REMOTE_CONTEXT_POLL_INTERVAL; } let remote = if poll_now { - // A pane tty7 itself spawned as remote (native SSH, - // or WSL) already carries its own context from the - // spawn spec; process-table detection must not - // clobber it. Only `Ssh` — the kind this very probe - // produces — may be replaced, so a pane that has - // since left a foreground `ssh` clears correctly. - // - // Testing `!= Ssh` rather than `== NativeSsh` is - // load-bearing for WSL: `wsl.exe` is not `ssh`, so - // the probe returns `None` and would blank the - // context on the very next poll — twice a second, - // each time also clearing the pane's cwd. let managed = { let st = state.lock().unwrap(); st.remote @@ -1246,28 +740,13 @@ impl DaemonPane { } else { None }; - // Flattened: a fired poll whose probe has no - // process-table view (native SSH, Windows) folds to - // "no opinion" and is never applied — see - // [`ForegroundProbes::agent`]. let agent = poll_now.then(&foreground_agent_fn).flatten(); - // Same gate, same flattening: `None` is "nothing to - // read", not "no cwd", so it never clears one. let probed_cwd = poll_now.then(&foreground_cwd_fn).flatten(); let tr1 = trace.then(std::time::Instant::now); - // Whether this chunk carries anything that *could* - // move a fact the tree records. An ordinary output - // chunk carries none of them, and must not pay for - // two snapshots and a compare per read: a build's - // worth of stdout is thousands of chunks and no - // facts at all. let may_change_facts = signals.cwd.is_some() || !signals.agent_events.is_empty() || signals.notification.is_some() - // A prompt boundary: on Windows the agent - // identity rides the `133;C` capture, and - // everywhere the OSC 7 cwd travels with it. || !signals.shell.is_empty() || remote.is_some() || agent.is_some() @@ -1276,10 +755,6 @@ impl DaemonPane { let facts_before = may_change_facts.then(|| observed_facts(&st)); st.ring.append(bytes); if let Some(sub) = &st.subscriber { - // A send error just means the client is gone; ignore - // it and let the next attach install a new sender. - // Successful sends are counted against the gate; the - // connection's writer thread credits them back. if sub.send(DaemonMsg::Output(bytes.to_vec())).is_ok() { gate.add(n); } @@ -1291,64 +766,24 @@ impl DaemonPane { if let Some(agent) = agent { apply_agent(&mut st, agent); } - // Last: a remote transition in this same chunk has - // already cleared the cwd, and `apply_probed_cwd` - // declines to speak for a remote pane. apply_probed_cwd(&mut st, probed_cwd); if let Some(tr1) = tr1 { tr_disp_t += tr1.elapsed(); } - // Publish what this chunk changed to the machine - // tree — outside the state lock, because the store - // broadcasts to every client of this machine and - // this thread's stalls are the child's write - // stalls. Gated twice over: `may_change_facts` - // keeps plain output free, and the compare below - // keeps a re-reported cwd from becoming a store - // mutation. let pane = st.id; - // Read *with* the facts, not assumed: on Windows - // the exit monitor can report the death (flipping - // `alive`) while this thread is still draining - // ConPTY's buffered output, and the death report - // is latched — a "proof of life" published here - // after it would mark a dead pane live forever. let alive = st.alive; let facts_after = may_change_facts.then(|| observed_facts(&st)); drop(st); - // …and a third time, on teardown. From `hangup` on, - // nothing this thread still reads describes a pane - // in use — while the facts in the record are what - // the *next* open builds a successor from. The kill - // takes the whole process group down, so a poll - // landing between the coding agent's death and the - // PTY's EOF reports "nothing recognizable in the - // foreground" and would publish that as "the agent - // left", wiping the session id `--resume` needs. - // That race is why ending a workspace's sessions - // sometimes came back to a bare shell instead of - // the conversation. The last steady-state answer is - // the one worth keeping; `live` is not ours to - // write here either — `DeathReporter` owns it. if !shutting_down.load(Ordering::SeqCst) && let (Some(before), Some(after)) = (facts_before, facts_after) && facts_changed(&before, &after) { let (cwd, agent) = after; crate::core::machine::observe_pane(pane, |p| { - // An unknown cwd never clears a seeded one: - // the spawn directory in the record is - // better revival information than nothing. if cwd.is_some() { p.cwd = cwd; } - // The agent fact applies wholesale — its - // `None` means the agent left the - // foreground, and a revival must not - // resume a session that already ended. p.agent = agent; - // Output is proof of life — but only while - // the pane still is; see `alive` above. if alive { p.live = true; } @@ -1356,86 +791,45 @@ impl DaemonPane { } } Err(err) if err.kind() == std::io::ErrorKind::Interrupted => continue, - Err(_) => break, // EIO after hangup, etc. + Err(_) => break, } } - // Child gone (EOF): report the death — mark not-alive and notify - // the subscriber, or hand an unattached pane to `on_dead` — unless - // we initiated teardown. On Windows the monitor may have already - // reported this same death; the latch makes the second call a - // no-op. See [`DeathReporter::report`]. death.report(&state, &shutting_down); }) .expect("spawn daemon pane reader thread") } - /// Become this pane's sole subscriber (replacing any prior one): replay - /// the ring (a `Size` + `Snapshot` pair per geometry segment), then push - /// the currently-known `Cwd` / `Prompt` so the fresh client is immediately - /// in sync. - /// - /// The PTY is deliberately *not* resized here. A re-attaching client only - /// knows a pre-layout placeholder size at this point; resizing to it would - /// SIGWINCH the shell into redrawing its prompt at a bogus width — and - /// those redraw bytes land in the ring, corrupting every later replay. The - /// client instead sizes its grid from our `Size` frame for the replay, and - /// sends a real `Resize` once it is laid out. pub fn attach(&self, subscriber: Sender<DaemonMsg>) -> u64 { let mut st = self.state.lock().unwrap(); let epoch = attach_subscriber(&mut st, subscriber); - // Frames queued to the *previous* subscriber died with its channel: - // start this connection's accounting from zero so stale backlog can't - // park the PTY reader against bytes nobody will ever drain. Ordered - // with the reader's `add` by the state lock both run under. self.gate.reset(); epoch } - /// Clear the current subscriber (the pane keeps running), but only if `epoch` - /// still names the current subscriber — a connection that was already replaced - /// by a newer attach must not blank its successor. Idempotent. - /// - /// Returns `true` when, *after* detaching, the pane is reclaimable: the child - /// has already exited (`!alive`) and no subscriber remains. The caller can then - /// drop it from the registry instead of leaking it — a dead pane is never - /// re-attached (clients spawn fresh for `!alive` panes), so removal is invisible. - /// Computed under the one state lock with the detach, so a concurrent re-attach - /// can't slip a subscriber in between the clear and the check. pub fn detach(&self, epoch: u64) -> bool { let mut st = self.state.lock().unwrap(); if st.subscriber_epoch == epoch { st.subscriber = None; - // Whatever was still queued dies with the channel; clear its - // accounting so the reader isn't left throttled against it. self.gate.reset(); } !st.alive && st.subscriber.is_none() } - /// The pane's Output backpressure gate, shared with the connection's writer - /// thread (which credits bytes back as it drains them to the socket). pub fn gate(&self) -> Arc<OutputGate> { self.gate.clone() } - /// Write raw bytes to the PTY (keyboard input / pasted text). pub fn write_input(&self, bytes: &[u8]) { if bytes.is_empty() { return; } if let Ok(mut writer) = self.writer.lock() { - // A failed write just means the child/pty is gone; the reader will see - // the same EOF and tear the pane down, so swallow it here. let _ = writer.write_all(bytes); let _ = writer.flush(); } } - /// The pane's process tree and listening ports, for the GUI's details panel - /// (`QueryProcs`). A native-SSH pane has no local process tree at all — its - /// commands run on the far side — so it answers empty rather than reporting - /// the daemon's own descendants. pub fn procs(&self) -> crate::daemon::protocol::PaneProcs { let Some(pty) = self.pty() else { return Default::default(); @@ -1446,9 +840,6 @@ impl DaemonPane { crate::daemon::procinfo::snapshot(shell_pid, pty_foreground_pgid(&pty.master)) } - /// The local-PTY backend, or `None` for a native-SSH pane. PTY-only - /// operations (resize via master, signal groups, foreground proc queries) - /// short-circuit when this is `None`. fn pty(&self) -> Option<&PtyBackend> { match &self.backend { PaneBackend::Pty(p) => Some(p), @@ -1456,15 +847,9 @@ impl DaemonPane { } } - /// Resize the byte source: a PTY gets `SIGWINCH` (Unix) / console resize - /// (Windows); a native-SSH channel gets a `window-change` request. The daemon - /// holds no grid to resize. Seals the ring's current segment so bytes from - /// here on are recorded — and later replayed — under the new geometry. pub fn resize(&self, size: WinSize) { self.state.lock().unwrap().ring.resize(size); match &self.backend { - // A failure just means the pty is gone, which the reader will observe - // as EOF; `MasterPty::resize` itself takes `&self`. PaneBackend::Pty(p) => { if let Ok(master) = p.master.lock() { let _ = master.resize(pty_size(size)); @@ -1474,15 +859,11 @@ impl DaemonPane { } } - /// Whether the child is still running. Part of the pane's public surface for - /// the integration phase (session restore / pickers); `info()` also carries it. #[allow(dead_code)] pub fn alive(&self) -> bool { self.state.lock().unwrap().alive } - /// Metadata for `List`: cwd prefers the OSC 7 report (falling back to a proc - /// query), `title` is the foreground process basename when readable (macOS). pub fn info(&self) -> PaneInfo { let (cwd, alive) = { let st = self.state.lock().unwrap(); @@ -1502,55 +883,28 @@ impl DaemonPane { cached.or_else(|| self.foreground_remote_context()) } - /// Hang up the child now; the pane's `Drop` then reaps it. Used by the `Kill` - /// control message and on registry teardown. pub fn kill(&self) { self.hangup(); } - /// Terminate the child and its whole process group. Signals the group with - /// SIGHUP (graceful), lets `portable-pty` escalate on the shell pid (SIGHUP → - /// ~200ms grace → SIGKILL), then SIGKILLs any group survivors so *every* holder - /// of the slave PTY dies and the reader's blocking `read()` can finally EOF. - /// Sets `shutting_down` so that EOF is treated as teardown, not a spurious exit. - /// Idempotent — safe to call from `kill()` and again from `Drop`. fn hangup(&self) { self.shutting_down.store(true, Ordering::SeqCst); match &self.backend { PaneBackend::Pty(p) => { - // Graceful hangup of the whole group first (lets a shell run EXIT traps). #[cfg(unix)] Self::signal_group(p, libc::SIGHUP); - // Windows has no process group to signal: `portable-pty`'s `kill` - // below terminates only the shell process, so capture and kill its - // descendant tree *first*, while their parent links still point at - // the (still-live) shell. Otherwise those children reparent and - // linger — some still attached to the ConPTY, which would keep the - // reader's blocking read from EOFing. #[cfg(windows)] Self::kill_descendants(p); if let Ok(mut child) = p.child.lock() { let _ = child.kill(); } - // Force-kill anything in the group that ignored/outlived the hangup - // (a foreground job in its own process group, a `trap '' HUP` - // child): without this they keep the slave PTY open and the reader - // thread never EOFs. #[cfg(unix)] Self::signal_group(p, libc::SIGKILL); } - // A native-SSH pane has no local child/pgid: closing the channel ends - // the driver, which drops its data sender and EOFs the reader — the - // same teardown a PTY hangup produces. `shutting_down` (set above) makes - // that EOF a silent teardown, not a spurious `Exited`. PaneBackend::NativeSsh(b) => b.handle.close(), } } - /// Terminate every descendant of the shell (children, grandchildren, …). The - /// shell itself is left to `child.kill()`; this reaches the process tree the - /// ConPTY's own teardown doesn't. Best effort — a snapshot failure or an - /// already-exited process just means nothing to do. #[cfg(windows)] fn kill_descendants(pty: &PtyBackend) { if let Some(pid) = pty.shell_pid { @@ -1561,18 +915,6 @@ impl DaemonPane { } } - /// Windows-only: watch the shell for a *natural* exit (`exit`, Ctrl-D, a - /// crash) the ConPTY reader can't observe. The ConPTY output pipe reports EOF - /// only once the pseudoconsole is closed (`ClosePseudoConsole`), not when the - /// child dies — and the reader itself holds a `master` handle (the fg-gate - /// clone), so it can keep its own pipe open. Without this a shell that exits - /// on its own leaves the reader blocked and the pane wedged open forever. - /// - /// We open a wait-only handle to the shell *now*, while it's alive, so pid - /// reuse can't retarget the wait, then block a thread on it. When it signals, - /// the death flows through the shared latch — the same `Exited` / `on_dead` - /// the reader's EOF drives on Unix. The kill path is unaffected: it sets - /// `shutting_down`, under which `report` is a silent no-op. #[cfg(windows)] fn spawn_exit_monitor( shell_pid: Option<u32>, @@ -1586,27 +928,16 @@ impl DaemonPane { }; let Some(pid) = shell_pid else { return }; - // SAFETY: `OpenProcess` on a currently-live pid for wait-only access. A null - // return (already gone, or access denied) is handled below; on success the - // handle is closed by the monitor thread after its single wait. let handle = unsafe { OpenProcess(PROCESS_SYNCHRONIZE, 0, pid) }; if handle.is_null() { - // Couldn't watch it — report at once rather than risk wedging the pane - // open. Opening a just-spawned child essentially never fails. death.report(&state, &shutting_down); return; } - // `HANDLE` is a raw pointer and thus `!Send`; move it across the thread - // boundary as an integer and rebuild it inside. It names a kernel object we - // own for the handle's lifetime, so this is just relocating ownership. let handle = handle as isize; std::thread::Builder::new() .name("tty7-daemon-pane-exit-monitor".to_string()) .spawn(move || { let handle = handle as windows_sys::Win32::Foundation::HANDLE; - // SAFETY: `handle` is our live process handle; waited once (any - // return — signaled or failed — means the child is effectively - // gone), then closed exactly once. unsafe { WaitForSingleObject(handle, INFINITE); CloseHandle(handle); @@ -1616,17 +947,8 @@ impl DaemonPane { .expect("spawn daemon pane exit monitor thread"); } - /// Post `sig` to the child's process group(s), not just the shell pid. The - /// shell is a session/group leader (`portable-pty` `setsid`s it), so its pgid - /// equals `shell_pid`; a job-control child (vim, less, a pager…) runs in the - /// terminal's *foreground* process group instead, which that pgid doesn't - /// cover — so signal both. Only the process group reaches the descendants that - /// inherited the slave PTY; signalling the bare pid (what `child.kill()` does) - /// leaves them holding it open and wedges the reader. #[cfg(unix)] fn signal_group(pty: &PtyBackend, sig: libc::c_int) { - // SAFETY: `killpg` only posts a signal to a process group; a nonexistent or - // already-dead group returns `ESRCH`, which we intentionally ignore. if let Some(pid) = pty.shell_pid { unsafe { libc::killpg(pid as libc::pid_t, sig); @@ -1646,17 +968,11 @@ impl DaemonPane { } } - /// Best-effort foreground cwd read straight from the process table — what - /// `List` reports for a pane whose shell has yet to emit an OSC 7, and (via - /// the reader's 0.5 s poll) what keeps an *unintegrated* shell's cwd honest. - /// `None` for a native-SSH pane: there is no local process to read. fn foreground_cwd(&self) -> Option<PathBuf> { let pty = self.pty()?; foreground_cwd(&pty.master, pty.shell_pid) } - /// Executable basename of the PTY's foreground process-group leader, used as the - /// pane title (macOS/Linux). #[cfg(any(target_os = "macos", target_os = "linux"))] fn foreground_title(&self) -> String { let Some(pty) = self.pty() else { @@ -1670,11 +986,6 @@ impl DaemonPane { .unwrap_or_default() } - /// Windows has no pty foreground-process-group concept, so derive the title - /// from the process table instead: the deepest command running under the shell - /// (see [`winproc::foreground_name`](crate::daemon::winproc::foreground_name)). - /// Empty while the shell sits idle at its prompt, which leaves the pane's - /// existing title in place. #[cfg(windows)] fn foreground_title(&self) -> String { let Some(pty) = self.pty() else { @@ -1692,9 +1003,6 @@ impl DaemonPane { String::new() } - /// Process-table SSH detection over the local PTY's foreground command. A - /// native-SSH pane has no local process to inspect (it already carries its own - /// `RemoteContext`), so this is `None` there. fn foreground_remote_context(&self) -> Option<RemoteContext> { match &self.backend { PaneBackend::Pty(p) => foreground_remote_context(&p.master), @@ -1705,32 +1013,15 @@ impl DaemonPane { impl Drop for DaemonPane { fn drop(&mut self) { - // A native-SSH pane's managed forwards (WS4) are attributed to this pane; - // tear them down as the pane dies so listeners close and remote bindings are - // cancelled — the FR-C2 blast radius when a shared connection drops takes - // every pane through here. Detached, so it never blocks this connection - // thread. if matches!(self.backend, PaneBackend::NativeSsh(_)) { crate::daemon::ssh::SshManager::global().teardown_pane_forwards(self.id); } - // Hang up the byte source: SIGHUP → SIGKILL for a PTY child + its group, or - // channel close for a native-SSH session — so the reader's `read()` can EOF. self.hangup(); - // Reap the (now SIGKILLed) shell so it isn't left a zombie. It can't block - // on a live process: SIGKILL can't be caught, so the shell is dead/dying. - // A native-SSH pane has no local child to reap. if let PaneBackend::Pty(p) = &self.backend { if let Ok(mut child) = p.child.lock() { let _ = child.wait(); } } - // Join the reader, but *bounded*. Normally the group-kill above closed the - // slave and the reader EOFed at once, so this returns immediately. But a - // fully-detached descendant (its own session, still holding the slave) can - // be beyond the reach of our signals; never let that wedge this thread — - // `Drop` runs on a connection thread, and blocking it forever is the P0 - // hang this guards against. If the reader doesn't finish in time, leave it - // detached (it ends on its own if the slave ever closes). if let Some(handle) = self.reader.lock().unwrap().take() { join_bounded(handle, Duration::from_secs(2)); } @@ -1742,12 +1033,6 @@ impl Drop for DaemonPane { } } -/// Join `handle`, waiting at most `timeout`. Returns `true` if the thread finished -/// (and was joined), `false` if it didn't finish in time — in which case it's left -/// running/detached. This is the backstop that keeps a stuck reader thread (blocked -/// on a `read()` that never EOFs because some descendant still holds the slave PTY) -/// from wedging the connection thread that `DaemonPane::drop` runs on. Uses a -/// throwaway joiner thread because `std::thread::JoinHandle` has no timed join. fn join_bounded(handle: JoinHandle<()>, timeout: Duration) -> bool { let (tx, rx) = mpsc::channel(); if std::thread::Builder::new() @@ -1758,17 +1043,11 @@ fn join_bounded(handle: JoinHandle<()>, timeout: Duration) -> bool { }) .is_err() { - // Couldn't even spawn the joiner; don't block. The reader (if stuck) leaks, - // but the connection thread is freed — the whole point. return false; } rx.recv_timeout(timeout).is_ok() } -/// Map our `WinSize` (cell grid + per-cell pixel size) to `portable-pty`'s -/// `PtySize`. `pixel_width`/`pixel_height` are the *total* window pixel -/// dimensions (cols × cell_w), matching the `ws_xpixel`/`ws_ypixel` semantics the -/// PTY layer ultimately reports to the child; most programs ignore them. fn pty_size(size: WinSize) -> PtySize { PtySize { rows: size.rows.max(1), @@ -1778,33 +1057,13 @@ fn pty_size(size: WinSize) -> PtySize { } } -/// The replay ring: raw PTY bytes, oldest-first, segmented by the geometry -/// they were recorded under. -/// -/// Raw bytes are only replayable at the width the program wrote them for. A -/// TUI that redraws with cursor-up + erase (Claude Code's inline renderer is -/// the canonical case) computes its row counts from the then-current width; -/// replaying the whole ring at the *final* width re-wraps every older frame, -/// so those redraws land mid-frame and each one leaks stale rows into -/// scrollback — duplication that never existed live. Cutting a new segment at -/// every resize lets `attach` replay each stretch of history at its recorded -/// geometry (a `Size` → `Snapshot` pair per segment), re-wrapping between -/// segments exactly where the live client did. struct ReplayRing { - /// Oldest-first, never empty: the back segment is the live tail, and its - /// geometry is the PTY's current size. segments: VecDeque<RingSegment>, - /// Total payload bytes across all segments, kept ≤ `RING_CAP`. len: usize, } -/// One stretch of PTY output recorded under a single geometry. struct RingSegment { size: WinSize, - /// A `VecDeque` so evicting the oldest bytes is O(evicted): with a `Vec`, - /// every append to a full ring memmoved the whole 8 MiB to close the front - /// gap — at the ~1 KiB-per-read cadence macOS PTYs deliver, that memmove - /// dominated the daemon's read loop and capped drain throughput at ~5 MB/s. bytes: VecDeque<u8>, } @@ -1816,8 +1075,6 @@ impl RingSegment { } } - /// The segment's bytes, oldest-first, as one contiguous `Vec` (the - /// `Snapshot` payload). One copy over the deque's two slices. fn to_vec(&self) -> Vec<u8> { let (a, b) = self.bytes.as_slices(); let mut out = Vec::with_capacity(self.bytes.len()); @@ -1839,12 +1096,6 @@ impl ReplayRing { self.segments.back_mut().expect("ring always has a tail") } - /// Seal the tail at a new geometry: bytes appended from here on belong to - /// a fresh segment. A same-size resize is a no-op, and an empty tail is - /// retagged in place, so repeated resizes with no output in between (a - /// window drag over an idle pane) collapse into one segment instead of - /// piling up empty ones. At `MAX_RING_SEGMENTS` the two oldest segments - /// merge to make room, mis-wrapping only the oldest scrollback. fn resize(&mut self, size: WinSize) { let tail = self.tail(); if tail.size == size { @@ -1857,9 +1108,6 @@ impl ReplayRing { if self.segments.len() >= MAX_RING_SEGMENTS { let old = self.segments.pop_front().expect("len >= cap"); let head = self.segments.front_mut().expect("cap >= 2"); - // The merged segment keeps `head`'s (newer) geometry; prepending - // the older bytes shifts the wrap error onto history that was - // already the least accurate. let mut merged = old.bytes; merged.extend(head.bytes.drain(..)); head.bytes = merged; @@ -1867,10 +1115,6 @@ impl ReplayRing { self.segments.push_back(RingSegment::empty(size)); } - /// Append `bytes` to the live tail, dropping the oldest bytes — and any - /// segments this empties — past `RING_CAP`. A single write larger than - /// the cap keeps only its trailing `RING_CAP` bytes (the most recent - /// screen state), all recorded under the tail's geometry. fn append(&mut self, bytes: &[u8]) { if bytes.len() >= RING_CAP { let size = self.tail().size; @@ -1899,12 +1143,6 @@ impl ReplayRing { } } - /// Replay the ring through `subscriber`: a `Size` + `Snapshot` pair per - /// segment, oldest first. The client applies each `Size` to its grid - /// right before advancing the paired `Snapshot` (see the client reader's - /// `pending_size`), reflowing between segments exactly like the live - /// resizes did. The tail's pair always goes out — even empty — so the - /// replay ends at the PTY's current geometry. fn replay(&self, subscriber: &Sender<DaemonMsg>) { for seg in &self.segments { let _ = subscriber.send(DaemonMsg::Size(seg.size)); @@ -1912,8 +1150,6 @@ impl ReplayRing { } } - /// All payload bytes, oldest-first, geometry boundaries elided. Test-only: - /// production replay must keep the per-segment sizes. #[cfg(test)] fn flatten(&self) -> Vec<u8> { let mut out = Vec::with_capacity(self.len); @@ -1924,18 +1160,6 @@ impl ReplayRing { } } -/// Install `subscriber` as the pane's sole subscriber (replacing any prior -/// one) and replay the pane's known state through it. Called with the state -/// lock held (the pure core of [`DaemonPane::attach`], split out so it is -/// testable without a PTY). -/// -/// Send the ring replay + known signals *through the new channel* before we -/// install it, so the client's first frames are the replay, ahead of any live -/// `Output` the reader enqueues next. The replay is a `Size` → `Snapshot` -/// pair per ring segment: each Size leads its segment so the client's grid is -/// at the recorded geometry before those bytes advance (see [`ReplayRing`]). -/// Installing drops the previous sender (its receiver then ends — v1 -/// single-client takeover). Returns the new subscriber epoch. fn attach_subscriber(st: &mut PaneState, subscriber: Sender<DaemonMsg>) -> u64 { st.subscriber_epoch += 1; @@ -1959,11 +1183,6 @@ fn attach_subscriber(st: &mut PaneState, subscriber: Sender<DaemonMsg>) -> u64 { if st.agent_session.is_some() { let _ = subscriber.send(DaemonMsg::AgentStatus(st.agent_session.clone())); } - // A dead pane's reader thread — the one that reports the child's exit — is - // long gone, so replay its exit too: without this an attach racing the - // child's death (it exited between the client's `List` and its `Attach`) - // renders the snapshot and then waits forever on a pane that will never - // speak again, input silently swallowed. if !st.alive { let _ = subscriber.send(DaemonMsg::Exited { code: None }); } @@ -1971,21 +1190,11 @@ fn attach_subscriber(st: &mut PaneState, subscriber: Sender<DaemonMsg>) -> u64 { st.subscriber_epoch } -/// The slice of a pane's state the machine tree records about it — the cwd a -/// successor would spawn in, and the agent facts a successor would resume. -/// Captured before and after a chunk's signal application so the (rare) change -/// is published outside the state lock; see the reader loop. -/// -/// The cwd crosses as a `String` because the tree's records do (the dialect's -/// path rule); the loss, if any, happens here where it can be seen next to the -/// path that caused it. fn observed_facts(st: &PaneState) -> (Option<String>, Option<crate::core::machine::AgentFacts>) { let cwd = st.cwd.as_ref().map(|p| p.to_string_lossy().into_owned()); let agent = st.agent.map(|agent| crate::core::machine::AgentFacts { agent, session_id: st.agent_session.as_ref().and_then(|s| s.session_id.clone()), - // The session's own argv record wins — it survives the chip clearing — - // with the identity poll's capture as the fallback until it is stamped. launch_argv: st .agent_session .as_ref() @@ -1996,13 +1205,6 @@ fn observed_facts(st: &PaneState) -> (Option<String>, Option<crate::core::machin (cwd, agent) } -/// Whether a chunk's facts are worth a store mutation. -/// -/// The coarse agent status is deliberately **outside** the gate: it flips on -/// every hook event (working ↔ waiting ↔ idle), each of which would otherwise -/// rewrite `machine.json` from the PTY reader thread, and it is documented -/// display-only. It still *rides along* — whenever a load-bearing fact -/// changes, the record published carries the current status too. fn facts_changed( before: &(Option<String>, Option<crate::core::machine::AgentFacts>), after: &(Option<String>, Option<crate::core::machine::AgentFacts>), @@ -2010,10 +1212,6 @@ fn facts_changed( before.0 != after.0 || agent_facts_changed(before.1.as_ref(), after.1.as_ref()) } -/// [`facts_changed`]'s agent half: equality over every field but the status. -/// Compared field by field rather than by cloning-and-blanking, because this -/// runs on the pane's reader thread and the argv it would clone is a `Vec` of -/// `String`s. fn agent_facts_changed( before: Option<&crate::core::machine::AgentFacts>, after: Option<&crate::core::machine::AgentFacts>, @@ -2027,8 +1225,6 @@ fn agent_facts_changed( } } -/// Apply sniffed signals to the shared state and notify the subscriber of any cwd -/// / prompt change. Called with the state lock held. fn apply_signals(st: &mut PaneState, signals: SniffSignals) { if let Some(cwd) = signals.cwd { if st.cwd.as_ref() != Some(&cwd) { @@ -2038,22 +1234,7 @@ fn apply_signals(st: &mut PaneState, signals: SniffSignals) { st.cwd = Some(cwd); } } - // One frame per entry: the client needs every prompt-boundary crossing the - // chunk carried, not just where it ended up (see [`SniffSignals::shell`]). for shell in signals.shell { - // Windows: agent identity rides the C mark's command capture — ConPTY - // has no foreground process group for the Unix 0.5 s poll to read an - // argv from. `C;<cmd>` detects, `D` cleared `command` so it applies - // `None` and clears the chip. Unix keeps the poll (it sees through - // scripts and wrappers) and never consults the mark. Applied before - // the sentinel events below so an event naming the agent can still - // re-brand within the same chunk. - // - // Gated on the capture *changing*: a stray foreign mark mid-command - // (nested/remote shell, issue #26) re-delivers the same unchanged - // capture, and when that capture names no agent (a wrapper script the - // matcher can't see through), re-applying its `None` would wipe an - // identity the sentinel events established. #[cfg(windows)] if shell_mark_capture_changed(&st.shell, &shell) { apply_agent( @@ -2074,19 +1255,6 @@ fn apply_signals(st: &mut PaneState, signals: SniffSignals) { apply_agent_signals(st, signals.agent_events, signals.notification); } -/// The coding agent named by the shell's last `133;C;<command>` capture — the -/// Windows detection input ([`apply_signals`] applies it there whenever the -/// capture changes). `None` both at the prompt (`D` cleared `command`) and for -/// an unrecognized command, so applying the answer verbatim also clears -/// the chip when the command ends. Compiled on every platform so the unit -/// tests cover it from Unix dev machines; only the Windows build calls it. -/// Whether a shell-mark change should (re-)run mark-derived agent detection -/// on Windows: only when the capture itself changed. `C` (new capture) and `D` -/// (capture cleared) qualify; a stray foreign A/B mark re-delivers the same -/// capture, and when that capture names no agent (a wrapper script the matcher -/// can't see through) re-applying its `None` would wipe an identity the -/// sentinel events established. Compiled on every platform for the unit tests; -/// only the Windows build calls it. #[cfg_attr(not(windows), allow(dead_code))] fn shell_mark_capture_changed(prev: &ShellState, next: &ShellState) -> bool { prev.command != next.command @@ -2097,26 +1265,12 @@ fn agent_from_shell_mark( shell: &ShellState, custom: &std::collections::HashMap<String, String>, ) -> Option<crate::core::cli_agent::CLIAgent> { - // Identity only — deliberately NOT a launch-argv source for resume flag - // carry-over. The `133;C` capture is terminal *output* (any program can - // print the OSC and forge it), and forged flags would ride an - // auto-executed resume command on the next restore; the Unix argv comes - // from the process table and has no such problem. Windows sessions - // therefore resume bare. shell .command .as_deref() .and_then(|cmd| crate::core::cli_agent::CLIAgent::detect_from_command_with(cmd, custom)) } -/// Fold the chunk's agent signals into the pane's session state and push any -/// resulting change. Called with the state lock held. -/// -/// Two tiers: sentinel events (hooks installed) drive the full -/// state machine and may even *identify* the agent where argv detection can't -/// see through a wrapper; a plain OSC 9/777 notification is the no-hooks -/// fallback — it only means "the agent pinged you", so it marks the session -/// `Waiting` (non-rich) and never overrides live rich state. fn apply_agent_signals( st: &mut PaneState, events: Vec<crate::core::cli_agent::AgentEvent>, @@ -2130,8 +1284,6 @@ fn apply_agent_signals( let before = st.agent_session.clone(); for event in &events { - // An event naming an agent brands the pane even when the process-table - // poll can't (an unrecognized wrapper binary): identity via protocol. if st.agent.is_none() && event.agent.is_some() { st.agent = event.agent; if let Some(sub) = &st.subscriber { @@ -2143,8 +1295,6 @@ fn apply_agent_signals( .apply_event(event); } - // Opaque fallback: only meaningful when we know an agent runs here, and - // never on top of rich state (the hooks channel owns it then). if let Some(body) = notification && st.agent.is_some() && !st.agent_session.as_ref().is_some_and(|s| s.rich) @@ -2156,10 +1306,6 @@ fn apply_agent_signals( sess.message = Some(body); } - // A session the events just created starts without the launch argv the - // identity poll captured — seed it so resume gets the flags no matter - // which side observed the pane first. Updates keep flowing through - // [`stamp_launch_argv`]. if let (Some(sess), Some(argv)) = (&mut st.agent_session, &st.agent_argv) && sess.launch_argv.is_none() { @@ -2173,35 +1319,10 @@ fn apply_agent_signals( } } -/// Reconcile a process-table cwd reading with what the pane already reports. -/// Called with the state lock held, off the 0.5 s poll. -/// -/// **Why this exists.** OSC 7 is the precise channel, but it only speaks for -/// shells tty7 managed to instrument. A shell that `exec`s into another one from -/// its rc file (`exec fish` in `.zshrc`), a nested shell started by hand, or any -/// shell tty7 has no integration for emits nothing — and since a pane's cwd is -/// seeded with its spawn directory, such a pane doesn't report *no* cwd, it -/// reports a permanently stale one. Every consumer then quietly misbehaves: new -/// tabs and splits open in the wrong place (issue #187), and so do the git probe -/// and path completion. The process table knows the truth in all those cases, so -/// consult it — twice a second, off the hot path, alongside the SSH and agent -/// probes that already run there. -/// -/// **Why the shell's spelling still wins when both name the same directory.** -/// A shell's `$PWD` keeps the *logical* path the user walked in through; the -/// kernel only knows the physical one. With `~/dev` symlinked onto a volume, -/// OSC 7 says `/Users/me/dev` and the process table says `/Volumes/x/dev` — -/// both correct, but the logical one is what the user typed and expects a new -/// tab to land in. So a reading that resolves to the directory we already report -/// changes nothing; only a genuine disagreement does. fn apply_probed_cwd(st: &mut PaneState, probed: Option<PathBuf>) { let Some(probed) = probed else { return; }; - // A remote pane's directory lives in the remote's namespace. The local - // process table can only see the `ssh` client's own cwd, which would be - // attributed to the remote shell — the very confusion `apply_remote_context` - // clears the cwd to avoid. if st.remote.is_some() { return; } @@ -2214,10 +1335,6 @@ fn apply_probed_cwd(st: &mut PaneState, probed: Option<PathBuf>) { st.cwd = Some(probed); } -/// Whether two paths name the same directory — equal as written, or resolving to -/// the same place through symlinks (see [`apply_probed_cwd`]). A path that can't -/// be resolved at all (deleted since, or a namespace this machine can't see) -/// answers `false`: it is not the directory we just read from the kernel. fn same_dir(a: &Path, b: &Path) -> bool { a == b || match (a.canonicalize(), b.canonicalize()) { @@ -2230,15 +1347,6 @@ fn apply_remote_context(st: &mut PaneState, remote: Option<RemoteContext>) { if st.remote == remote { return; } - // The cwd belonged to whichever side we are leaving, and it does not - // survive the crossing: a remote path is meaningless locally, and a local - // one is meaningless on the remote. Drop it so the pane reports no cwd - // until the new shell's OSC 7 lands, rather than attributing the old - // namespace's directory to the new one — otherwise a local shell without - // shell integration keeps serving the remote's last path to the local - // `git` probe for the rest of its life. - // `DaemonMsg::Cwd` carries a bare path with no "cleared" form, so the - // client mirrors this on its own when it sees the `RemoteContext` below. st.cwd = None; if let Some(sub) = &st.subscriber { let _ = sub.send(DaemonMsg::RemoteContext(remote.clone())); @@ -2255,16 +1363,9 @@ fn apply_agent( None => (None, None), }; if st.agent == agent { - // Same chip, but the observed argv can still be news: the sentinel - // events may have branded the pane before the first argv poll, or the - // user relaunched the agent with different flags. stamp_launch_argv(st, argv); return; } - // The agent leaving the foreground ends its session: clear the rich state - // (and tell the client) so a stale "waiting" dot can't outlive the process. - // The poll can blip momentarily (an agent-spawned subcommand takes the - // foreground group), but events re-establish state on the next signal. if agent.is_none() && st.agent_session.is_some() { st.agent_session = None; if let Some(sub) = &st.subscriber { @@ -2281,11 +1382,6 @@ fn apply_agent( stamp_launch_argv(st, argv); } -/// Record the detected agent's launch argv and mirror it into the rich session -/// state (pushing the change to the client) when one exists — resume-after- -/// restart reads it from there. `None` (a poll that saw no argv) and an empty -/// argv (Windows mark detection, identity without a trustworthy argv) never -/// wipe a captured value; the chip clearing in [`apply_agent`] does that. fn stamp_launch_argv(st: &mut PaneState, argv: Option<Vec<String>>) { let Some(argv) = argv else { return }; if argv.is_empty() { @@ -2305,16 +1401,6 @@ fn stamp_launch_argv(st: &mut PaneState, argv: Option<Vec<String>>) { } } -/// Whether a foreground command — not the shell itself — currently owns the -/// PTY. True while e.g. `ssh`, `vim`, or a nested shell runs; false when the -/// shell sits idle at its own prompt (it is then the terminal's foreground -/// process group). Unknown/missing data answers false, so a bad reading never -/// suppresses a real local prompt. -/// -/// This is the signal that keeps a foreground program's OSC 133 marks — a fish -/// session over ssh emitting its own prompt marks, most visibly — from engaging -/// tty7's local line editor, whose completion and history are local-only and -/// wrong for whatever is really reading the keyboard. See issue #26. fn foreground_command_running( master: &Mutex<Box<dyn MasterPty + Send>>, shell_pid: Option<u32>, @@ -2322,25 +1408,16 @@ fn foreground_command_running( is_foreground_command(pty_foreground_pgid(master), shell_pid) } -/// The PTY's foreground process-group id (`pid_t`, i.e. `i32`), read from the -/// terminal via `tcgetpgrp`, or `None` when it can't be read. #[cfg(unix)] fn pty_foreground_pgid(master: &Mutex<Box<dyn MasterPty + Send>>) -> Option<i32> { master.lock().ok().and_then(|m| m.process_group_leader()) } -/// Windows conpty has no foreground-process-group concept — portable-pty doesn't -/// implement `process_group_leader` there — so there is nothing to gate on: we -/// answer `None`, leaving prompt marks handled exactly as before. (ssh from a -/// Windows tty7 is rare and uses a different model anyway.) #[cfg(not(unix))] fn pty_foreground_pgid(_master: &Mutex<Box<dyn MasterPty + Send>>) -> Option<i32> { None } -/// Pure core of [`foreground_command_running`]: given the PTY's foreground -/// process group and the shell's pid, is the foreground group some *other* -/// process (a running command) rather than the shell idling at its prompt? fn is_foreground_command(fg_pgid: Option<i32>, shell_pid: Option<u32>) -> bool { match (fg_pgid, shell_pid) { (Some(pg), Some(shell)) if pg > 0 => pg as u32 != shell, @@ -2348,10 +1425,6 @@ fn is_foreground_command(fg_pgid: Option<i32>, shell_pid: Option<u32>) -> bool { } } -/// The cwd of whatever currently owns the PTY, via `proc_pidinfo` (macOS). -/// Prefers the foreground process group over the shell pid: when the user has -/// started a *nested* shell (`fish` typed into a zsh pane) the outer shell's own -/// cwd stops tracking their `cd`s, and the group leader is the one that moves. #[cfg(target_os = "macos")] fn foreground_cwd( master: &Mutex<Box<dyn MasterPty + Send>>, @@ -2365,8 +1438,6 @@ fn foreground_cwd( } let mut vinfo: libc::proc_vnodepathinfo = unsafe { std::mem::zeroed() }; let size = std::mem::size_of::<libc::proc_vnodepathinfo>() as libc::c_int; - // SAFETY: zeroed buffer of the expected type; real size passed; read - // back only on success. let ret = unsafe { libc::proc_pidinfo( pid, @@ -2379,7 +1450,6 @@ fn foreground_cwd( if ret != size { return None; } - // SAFETY: on success the kernel NUL-terminates `vip_path`. let s = unsafe { CStr::from_ptr(vinfo.pvi_cdir.vip_path.as_ptr() as *const libc::c_char) } .to_str() .ok()?; @@ -2395,8 +1465,6 @@ fn foreground_cwd( .or_else(|| read_cwd(shell_pid.map(|p| p as i32).unwrap_or(0))) } -/// The cwd of whatever currently owns the PTY, via `/proc/<pid>/cwd` (Linux). -/// Same foreground-group-first preference as the macOS reader. #[cfg(target_os = "linux")] fn foreground_cwd( master: &Mutex<Box<dyn MasterPty + Send>>, @@ -2407,12 +1475,6 @@ fn foreground_cwd( return None; } let cwd = std::fs::read_link(format!("/proc/{pid}/cwd")).ok()?; - // A process whose directory was removed under it (`rm -rf` from another - // pane, `git clean`) reads back as `<path> (deleted)` — the kernel's own - // annotation, not a path. Harmless while this was only a `List` fallback; - // now that the reading is broadcast, it would send every new tab and - // split to a directory that cannot be opened. An unstat-able reading is - // therefore no reading, and the shell pid gets its turn. cwd.is_dir().then_some(cwd) }; pty_foreground_pgid(master) @@ -2420,12 +1482,6 @@ fn foreground_cwd( .or_else(|| read_cwd(shell_pid.map(|p| p as i32).unwrap_or(0))) } -/// No cwd fallback on Windows (or other non-mac/Linux targets): reading another -/// process's working directory needs PEB traversal via `ReadProcessMemory`, -/// which is undocumented and brittle across bitness/elevation. cwd there comes -/// from OSC 7 (the PowerShell shell integration emits it); `None` here just -/// means "no out-of-band fallback", so a shell without integration reports no -/// cwd rather than a wrong one. #[cfg(not(any(target_os = "macos", target_os = "linux")))] fn foreground_cwd( _master: &Mutex<Box<dyn MasterPty + Send>>, @@ -2446,12 +1502,6 @@ fn foreground_remote_context(_master: &Mutex<Box<dyn MasterPty + Send>>) -> Opti None } -/// Identify the third-party CLI coding agent (Claude Code, Codex, …) owning the -/// PTY foreground, from its `argv`. Same process-table read as -/// [`foreground_remote_context`]; runs off the hot path on the 0.5 s poll. -/// Always `Some(answer)` — this platform *has* the process-table view, so even -/// "no agent" is a real answer that must apply (it clears the chip when the -/// agent exits). See [`ForegroundProbes::agent`] for the outer option's contract. #[cfg(any(target_os = "macos", target_os = "linux"))] fn foreground_agent( master: &Mutex<Box<dyn MasterPty + Send>>, @@ -2463,16 +1513,11 @@ fn foreground_agent( &argv, crate::core::config::agent_commands_cached(), )?; - // The argv rides along: resume-after-restart replays its flags. Some((agent, argv)) }; Some(detect()) } -/// Windows: ConPTY has no foreground process group, so there is no process -/// table to poll — "no opinion" (`None`), never applied. Agent identity comes -/// from the shell integration's `133;C;<command>` capture instead, applied in -/// [`apply_signals`] via [`agent_from_shell_mark`]. #[cfg(not(any(target_os = "macos", target_os = "linux")))] fn foreground_agent( _master: &Mutex<Box<dyn MasterPty + Send>>, @@ -2480,75 +1525,35 @@ fn foreground_agent( None } -// --------------------------------------------------------------------------- -// OSC sniffer (cwd + prompt). The byte-level OSC framing lives in -// `core::osc::OscTokenizer` (shared with the client's notification scanner); -// this layer only routes completed OSC 7 / OSC 133 payloads. Instead of -// mutating shared `Arc<Mutex<..>>` it returns the changes from each `feed`, so -// the pane decides when to notify (it already holds the state lock). -// --------------------------------------------------------------------------- - -/// Shell-reported prompt/command state (OSC 133). #[derive(Default, Clone, PartialEq, Eq)] struct ShellState { active: bool, at_prompt: bool, last_exit_code: Option<i32>, - /// The command line the shell reported on its last `133;C;<cmd>` mark - /// (percent-decoded), cleared when the command finishes (`D`). All of - /// tty7's shell integrations carry the payload; it is the Windows - /// coding-agent detection input (see [`agent_from_shell_mark`]). command: Option<String>, } -/// Changes a `feed` call produced, if any. #[derive(Default)] struct SniffSignals { cwd: Option<PathBuf>, - /// Shell states completed in this chunk, in stream order — one entry per - /// `at_prompt` *transition*, not one per marker: a run of marks on the same - /// side of the prompt boundary folds into its latest state, so the ordinary - /// `D`/`A`/`B` chunk still yields the single entry it always did. - /// - /// Only the last state describes "what the shell is doing now", but the - /// client keys its prompt *cycle* off the false→true edge - /// (`terminal::remote::ShellState::cycle` — what releases a Tab handoff, see - /// `TerminalView::editor_handoff`). Collapsing to the last state alone hides - /// that edge whenever a whole command cycle (`C` … `D`) lands in one read — - /// routine over SSH, where a fast command's output arrives in a single - /// packet — and the handed-off prompt would then never come back to tty7's - /// line editor. shell: Vec<ShellState>, - /// Sentinel agent events completed in this chunk, in stream order — each - /// one is a state-machine step, so unlike cwd/shell they must *all* apply - /// (a `stop` directly after a `notification` still means "done"). agent_events: Vec<crate::core::cli_agent::AgentEvent>, - /// A plain (non-sentinel) OSC 9/777 desktop notification completed in this - /// chunk — the opaque "the agent pinged you" fallback signal for panes - /// whose agent has no hooks installed. Last body wins. notification: Option<String>, } struct OscSniffer { tok: OscTokenizer, - /// Running shell state, updated in place as 133 markers arrive. shell: ShellState, } impl OscSniffer { fn new() -> Self { Self { - // 9 / 777 are the notification channels the agent-status layer - // rides (sentinel events + opaque fallback); the client sniffs the - // same two independently for its desktop toasts. tok: OscTokenizer::new(&[b"7", b"133", b"9", b"777"]), shell: ShellState::default(), } } - /// Feed a chunk; return any cwd / shell-state change completed within it. (If - /// a chunk completes several cwd markers the last one wins; shell states keep - /// every prompt-boundary crossing — see [`SniffSignals::shell`].) fn feed(&mut self, bytes: &[u8]) -> SniffSignals { let mut signals = SniffSignals::default(); let shell = &mut self.shell; @@ -2558,9 +1563,6 @@ impl OscSniffer { } else if let Some(rest) = payload.strip_prefix(b"133;") { if handle_osc133(shell, rest) { match signals.shell.last_mut() { - // Still on the same side of the prompt boundary: fold in, - // latest wins (it carries the freshest exit code / command - // capture). Only a crossing earns its own entry. Some(last) if last.at_prompt == shell.at_prompt => { *last = shell.clone(); } @@ -2570,8 +1572,6 @@ impl OscSniffer { } else if let Some(event) = crate::core::cli_agent::parse_agent_event(payload) { signals.agent_events.push(event); } else if let Some((title, body)) = crate::core::osc::parse_notification(payload) { - // A sentinel-titled payload whose JSON failed to parse is - // protocol traffic, not a user notification — drop it. if title.as_deref() != Some(crate::core::cli_agent::AGENT_EVENT_SENTINEL) { signals.notification = Some(body); } @@ -2581,39 +1581,12 @@ impl OscSniffer { } } -/// Fold one OSC 133 marker into the running shell state. fn handle_osc133(shell: &mut ShellState, rest: &[u8]) -> bool { shell.active = true; - // `at_prompt` means "no foreground command is running" — i.e. the shell is - // drawing or sitting at its prompt, so tty7's local line editor should own - // the keyboard. Only `C` (command started) clears it; `A` (prompt start), - // `B` (input begins) and `D` (command finished) all set it. - // - // Crucially `D` and `A` set it *before* the prompt text is printed (the - // byte stream is always `…[D][A][prompt text][B]`), whereas `B` sits at the - // very end of PS1. Keying `at_prompt` off `B` alone left a window: when the - // visible prompt text arrived in an earlier PTY chunk than the trailing - // `B`, `at_prompt` was still false while the prompt was on screen, so keys - // typed in that gap were routed to the PTY (echoed by the shell into the - // grid) instead of the editor — the "un-deletable char / doubled prompt" - // glitch. Setting it as early as `D`/`A` closes that window. match rest.first() { - // A/B deliberately leave `command` alone: every tty7 integration emits - // D *before* A at a real prompt (so it's already cleared there), while - // a stray A/B from a foreign integration mid-command (a nested or - // remote shell drawing its own prompt — Windows has no pgid gate to - // reject it with, cf. issue #26) must not wipe the agent chip. Some(b'A') | Some(b'B') => shell.at_prompt = true, Some(b'C') => { shell.at_prompt = false; - // tty7 extension: our shell integrations append the submitted - // command line, percent-encoded — the Windows agent-detection - // input (see [`agent_from_shell_mark`]). A bare `C` overwrites the - // capture with `None` — deliberately, even though a *foreign* bare - // `C` mid-command then clears the chip: our own PowerShell body - // falls back to a bare `C` when escaping throws (lone surrogate on - // PS 5.1), and there a new command *has* started, so keeping the - // previous capture would misattribute it. shell.command = rest .strip_prefix(b"C;") .map(|c| String::from_utf8_lossy(&percent_decode(c)).into_owned()) @@ -2632,10 +1605,6 @@ fn handle_osc133(shell: &mut ShellState, rest: &[u8]) -> bool { true } -/// Build a `PathBuf` from raw OSC-7 path bytes. On Unix paths are arbitrary bytes, -/// so we go through `OsStr` losslessly; elsewhere (Windows) we interpret them as -/// UTF-8 (OSC 7 paths are UTF-8 in practice) and drop the URI's leading slash -/// ahead of a drive letter (see [`strip_uri_drive_slash`]). #[cfg(unix)] fn path_from_bytes(bytes: &[u8]) -> PathBuf { use std::os::unix::ffi::OsStrExt; @@ -2648,12 +1617,6 @@ fn path_from_bytes(bytes: &[u8]) -> PathBuf { PathBuf::from(strip_uri_drive_slash(s.as_ref())) } -/// A `file://` URI carries an absolute path with a leading `/`, but a Windows -/// drive path must drop it to be valid: `parse_osc7` hands us `/C:/Users/foo`, -/// which has to become `C:/Users/foo`. Only strips when a drive letter (`X:`) -/// follows, leaving POSIX paths (`/home/x`) and UNC shares untouched. Compiled -/// on all platforms so it's testable off Windows; only used by the non-unix -/// `path_from_bytes` above. #[cfg_attr(unix, allow(dead_code))] fn strip_uri_drive_slash(path: &str) -> &str { let b = path.as_bytes(); @@ -2664,9 +1627,6 @@ fn strip_uri_drive_slash(path: &str) -> &str { } } -/// Parse an OSC 7 `file://HOST/PATH` (or bare absolute path) payload. -/// `pub(crate)` so the shell-integration tests can round-trip what the snippets -/// actually emit through the parser that consumes it. pub(crate) fn parse_osc7(payload: &[u8]) -> Option<PathBuf> { let rest = payload.strip_prefix(b"7;")?; let path_bytes: &[u8] = if let Some(after) = rest.strip_prefix(b"file://") { @@ -2684,7 +1644,6 @@ pub(crate) fn parse_osc7(payload: &[u8]) -> Option<PathBuf> { Some(path_from_bytes(&decoded)) } -/// Decode `%XX` percent-escapes. fn percent_decode(input: &[u8]) -> Vec<u8> { let mut out = Vec::with_capacity(input.len()); let mut i = 0; @@ -2711,15 +1670,12 @@ fn hex_val(b: u8) -> Option<u8> { } } -/// Executable basename of `pid` via `proc_pidpath` (macOS). #[cfg(target_os = "macos")] fn proc_name(pid: i32) -> Option<String> { if pid <= 0 { return None; } let mut buf = [0u8; libc::PROC_PIDPATHINFO_MAXSIZE as usize]; - // SAFETY: valid, correctly-sized buffer; `proc_pidpath` writes at most - // `buf.len()` bytes and returns the count (<=0 on failure). let ret = unsafe { libc::proc_pidpath(pid, buf.as_mut_ptr() as *mut libc::c_void, buf.len() as u32) }; if ret <= 0 { @@ -2729,15 +1685,11 @@ fn proc_name(pid: i32) -> Option<String> { Some(path.rsplit('/').next().unwrap_or(path).to_string()) } -/// Executable basename of `pid` via `/proc/<pid>/exe`, falling back to -/// `/proc/<pid>/comm` (Linux). #[cfg(target_os = "linux")] fn proc_name(pid: i32) -> Option<String> { if pid <= 0 { return None; } - // `exe` is a symlink to the full binary path. If the binary was deleted the - // link target reads "<path> (deleted)" — strip that so the name stays clean. if let Ok(path) = std::fs::read_link(format!("/proc/{pid}/exe")) { if let Some(name) = path.file_name().and_then(|n| n.to_str()) { let name = name.strip_suffix(" (deleted)").unwrap_or(name); @@ -2746,8 +1698,6 @@ fn proc_name(pid: i32) -> Option<String> { } } } - // `exe` can be unreadable (e.g. a setuid foreground process); `comm` is - // world-readable but kernel-truncated to 15 chars — good enough for a title. let comm = std::fs::read_to_string(format!("/proc/{pid}/comm")).ok()?; let comm = comm.trim(); (!comm.is_empty()).then(|| comm.to_string()) @@ -2758,23 +1708,16 @@ mod tests { use super::*; use std::path::Path; - /// A cwd the client reports is only as trustworthy as the OSC 7 behind it. - /// Passing one this machine cannot resolve straight to `cmd.cwd()` turns a - /// new tab or split into a hard spawn failure, so anything that isn't a - /// real directory here must fall through to the next candidate instead. #[test] fn initial_working_directory_skips_paths_that_are_not_directories() { let real = std::env::temp_dir(); assert!(real.is_dir(), "temp dir should exist"); - // A usable client cwd still wins outright. assert_eq!( initial_working_directory(Some(real.clone())), Some(real.clone()) ); - // A remote-namespace path, and the msys shape Windows reads as - // drive-relative: neither resolves here, so neither may be used. for bogus in [ "/home/someone/definitely-not-here", "/c/Users/definitely-not-here", @@ -2785,13 +1728,11 @@ mod tests { Some(Path::new(bogus)), "{bogus} is not a directory here and must not be handed to spawn" ); - // Whatever we fall back to must itself be usable. if let Some(d) = got { assert!(d.is_dir(), "fallback {d:?} must be a real directory"); } } - // A file is not a directory either. let file = real.join("tty7-iwd-probe"); std::fs::write(&file, b"x").expect("write probe file"); let got = initial_working_directory(Some(file.clone())); @@ -2799,36 +1740,17 @@ mod tests { let _ = std::fs::remove_file(&file); } - /// Issue #187, end-to-end on a *live* PTY: the process-table cwd reading - /// must follow a child that changed directory without saying so. This is the - /// shape of an uninstrumented shell — `.zshrc` ending in `exec fish`, a - /// nested shell, a shell tty7 has no integration for — where OSC 7 never - /// arrives and the pane would otherwise report its spawn directory forever. - /// - /// Deliberately runs the whole platform chain (`process_group_leader` → - /// `proc_pidinfo` / `/proc/<pid>/cwd`) rather than the pure reconciliation - /// in [`apply_probed_cwd`]: spawn → PTY → reader poll → process-table read → - /// `Cwd` to the client is exactly the plumbing the unit tests can't see, so - /// a break anywhere in it fails here rather than in a bug report. #[cfg(any(target_os = "macos", target_os = "linux"))] #[test] fn live_pane_reports_an_uninstrumented_shells_cwd() { - // `/usr` is unambiguous, is not the test runner's cwd, and is not a - // symlink on either platform (`/tmp` is one on macOS), so the reading - // can be compared verbatim. let target = std::path::Path::new("/usr"); let (tx, rx) = mpsc::channel(); let pane = DaemonPane::spawn( 1, - Some(PathBuf::from("/")), // the spawn directory, and the seeded cwd + Some(PathBuf::from("/")), ws(80, 24), Some(ShellSpec { - // `sh` is the point: tty7 has no integration for it, so nothing - // in this pane will ever emit an OSC 7. `cd` then `exec cat` - // leaves the child parked in a directory it never announced, - // holding the PTY foreground group — the `exec fish` case - // reduced to its essentials. program: "sh".into(), args: vec!["-c".into(), "cd /usr && exec cat".into()], args_are_tty7_defaults: false, @@ -2839,9 +1761,6 @@ mod tests { .expect("spawn pane"); pane.attach(tx); - // The poll rides the reader's chunks, so the pane has to say *something* - // first; the PTY echoes whatever we send. Bounded, and re-poked each - // round so a slow `exec` doesn't need the whole budget in one shot. let mut reported = None; for _ in 0..200 { pane.write_input(b"\n"); @@ -2865,13 +1784,6 @@ mod tests { ); } - /// End-to-end check of the *live* agent-detection chain this feature rides - /// on macOS/Linux: spawn a real PTY child whose `argv[0]` names a coding - /// agent (`exec -a codex …`), then follow the exact path `foreground_agent` - /// uses — read the PTY's foreground process-group leader, read its `argv` - /// from the process table, and run `detect_from_argv`. Guards against a - /// regression in the platform `process_group_leader` / `foreground_argv` - /// plumbing that the pure `detect_from_argv` unit tests can't see. #[cfg(any(target_os = "macos", target_os = "linux"))] #[test] fn live_pty_child_argv_detects_the_agent() { @@ -2885,23 +1797,13 @@ mod tests { pixel_height: 0, }) .expect("openpty"); - // `exec -a codex` replaces the shell with `cat`, giving it argv[0]=codex - // while it blocks on stdin — so it stays the PTY's foreground group long - // enough to observe. `cat` (not `sleep`) keeps it alive until the master - // is dropped and its stdin EOFs. Must be bash: `exec -a` is a bashism - // that dash (Ubuntu's /bin/sh) rejects. let mut cmd = CommandBuilder::new("bash"); cmd.args(["-c", "exec -a codex cat"]); let mut child = pty.slave.spawn_command(cmd).expect("spawn child"); let master = Mutex::new(pty.master); - // Poll for the foreground group to become the child (not the transient - // `sh`), then detect. Bounded so a stuck spawn fails the test rather than - // hanging CI. let mut detected = None; for _ in 0..200 { - // Flatten: the outer Some is just "this platform has a process - // table"; the poll keeps going until detection actually answers. if let Some(agent) = foreground_agent(&master).flatten() { detected = Some(agent); break; @@ -2924,8 +1826,6 @@ mod tests { ); } - /// 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 { @@ -2935,8 +1835,6 @@ mod tests { }; let cfg = ("zsh".to_string(), vec!["-i".to_string()]); - // Override wins even when a shell is configured, carrying its - // arg-ownership flag through. assert_eq!( choose_shell(Some(over.clone()), Some(cfg.clone())), Some(ChosenShell { @@ -2945,8 +1843,6 @@ mod tests { args_are_tty7_defaults: true, }) ); - // No override → the configured shell, whose args are the user's and so - // are never tty7 defaults. assert_eq!( choose_shell(None, Some(cfg.clone())), Some(ChosenShell { @@ -2955,13 +1851,9 @@ mod tests { args_are_tty7_defaults: false, }) ); - // Neither → platform default. assert_eq!(choose_shell(None, None), None); } - /// Only *user*-authored args block integration. Locks the contract stated - /// on [`has_custom_args`] — in particular that the Git Bash dropdown row's - /// `-i -l` does not, which is what lets it get shell integration at all. #[test] fn only_user_authored_args_block_shell_integration() { let chosen = |args: Vec<&str>, tty7: bool| ChosenShell { @@ -2970,23 +1862,13 @@ mod tests { args_are_tty7_defaults: tty7, }; - // The Git Bash dropdown row: tty7 wrote `-i -l`, so `setup_bash` may - // replace them. assert!(!has_custom_args(Some(&chosen(vec!["-i", "-l"], true)))); - // The same args from the user's config.json are theirs to keep. assert!(has_custom_args(Some(&chosen(vec!["-i", "-l"], false)))); - // No args at all: nothing to preserve either way. assert!(!has_custom_args(Some(&chosen(vec![], false)))); assert!(!has_custom_args(Some(&chosen(vec![], true)))); - // Platform default — no configured shell at all. assert!(!has_custom_args(None)); } - /// A WSL pane must be tagged as living in another filesystem namespace, so - /// `TerminalView::local_cwd` declines the distro's cwd and the local git - /// probe / completion / link resolution / cwd inheritance never see a path - /// that means nothing here — and that Windows would read as drive-relative - /// (`/home/me` -> `C:\home\me`) rather than reject. #[cfg(windows)] #[test] fn wsl_panes_are_tagged_as_a_foreign_filesystem() { @@ -2996,20 +1878,15 @@ mod tests { args_are_tty7_defaults: true, }; - // The dropdown's WSL row. let ctx = wsl_remote_context(Some(&spec( "wsl.exe", vec!["--distribution", "Ubuntu-24.04", "--cd", "~"], ))) .expect("wsl.exe must be tagged"); assert_eq!(ctx.kind, RemoteKind::Wsl); - // The distro rides along as the target so the UI has a name for it. assert_eq!(ctx.target, "Ubuntu-24.04"); - // Nothing reads `argv` for this kind; it is not an ssh invocation. assert!(ctx.argv.is_empty()); - // Short flag, and no flag at all (wsl.exe then picks the default - // distro — still a WSL pane, just one we have no name for). assert_eq!( wsl_remote_context(Some(&spec("wsl.exe", vec!["-d", "Debian"]))) .expect("short flag") @@ -3022,24 +1899,14 @@ mod tests { .target, "" ); - // The `--distribution=NAME` spelling too — the tag reads the distro with - // the integration's own parser, so the two cannot disagree about an argv - // they are both handed. assert_eq!( wsl_remote_context(Some(&spec("wsl.exe", vec!["--distribution=Arch"]))) .expect("joined flag") .target, "Arch" ); - // Case- and suffix-insensitive, like every other Windows program name. assert!(wsl_remote_context(Some(&spec(r"C:\Windows\System32\WSL.EXE", vec![]))).is_some()); - // Regression: the tag is read off the *resolved* shell, not the - // per-spawn override. A `wsl.exe` written into `config.json` reaches - // `setup_wsl` with no override in play (empty args, so nothing custom to - // preserve), so the distro starts reporting its own cwd — and an - // untagged pane would hand `/home/me/proj` straight to the local git - // probe, which Windows resolves drive-relative to `C:\home\me\proj`. let from_config = choose_shell(None, Some(("wsl.exe".to_string(), Vec::new()))); assert_eq!( wsl_remote_context(from_config.as_ref()).map(|c| c.kind), @@ -3047,8 +1914,6 @@ mod tests { "a configured wsl.exe is as much a WSL pane as a dropdown one" ); - // Everything else is a local pane and must not be tagged — tagging it - // would silently disable its git status, completion and cwd inheritance. assert!(wsl_remote_context(Some(&spec("powershell.exe", vec![]))).is_none()); assert!( wsl_remote_context(Some(&spec(r"C:\Program Files\Git\bin\bash.exe", vec![]))).is_none() @@ -3126,31 +1991,22 @@ mod tests { assert!(!default_shell_name(&cmd).is_empty()); } - /// A reader that finishes is joined and reported done — the common teardown - /// path (group-kill closed the slave, the reader EOFed) returns cleanly. #[test] fn join_bounded_returns_true_when_the_thread_finishes() { let handle = std::thread::spawn(|| {}); assert!(join_bounded(handle, Duration::from_secs(5))); } - /// A reader stuck forever (models one blocked on a `read()` that never EOFs - /// because a detached grandchild still holds the slave PTY) does *not* wedge the - /// caller: `join_bounded` gives up after the timeout and returns `false`. This - /// is the P0 guarantee — `DaemonPane::drop` can never block indefinitely. #[test] fn join_bounded_times_out_on_a_stuck_thread() { let (unblock, blocked) = mpsc::channel::<()>(); - // Blocks until `unblock` is dropped — i.e. "forever" for the test's purposes. let handle = std::thread::spawn(move || { let _ = blocked.recv(); }); assert!(!join_bounded(handle, Duration::from_millis(50))); - // Let the stuck thread finish so it doesn't linger past the test. drop(unblock); } - /// Below the high-water mark the gate never blocks the reader. #[test] fn gate_passes_below_high_water() { let gate = OutputGate::new(); @@ -3163,8 +2019,6 @@ mod tests { ); } - /// At the high-water mark the reader parks until the writer credits bytes - /// back — the backpressure that keeps a flood's backlog bounded. #[test] fn gate_parks_at_high_water_until_drained() { let gate = Arc::new(OutputGate::new()); @@ -3188,8 +2042,6 @@ mod tests { ); } - /// `reset` (attach/detach) unparks a reader throttled against frames that - /// died with a replaced subscriber channel. #[test] fn gate_reset_unparks_a_throttled_reader() { let gate = Arc::new(OutputGate::new()); @@ -3208,8 +2060,6 @@ mod tests { assert!(t0.elapsed() < OutputGate::MAX_WAIT); } - /// A late `sub` racing a `reset` (old writer thread draining after a - /// re-attach) drives the counter negative and must not panic or wedge. #[test] fn gate_tolerates_negative_drift() { let gate = OutputGate::new(); @@ -3229,7 +2079,6 @@ mod tests { } } - /// The ring keeps appending verbatim while under the cap. #[test] fn ring_under_cap_keeps_all() { let mut ring = ReplayRing::new(ws(80, 24)); @@ -3238,8 +2087,6 @@ mod tests { assert_eq!(ring.flatten(), b"hello world"); } - /// Once total exceeds the cap, the oldest bytes are dropped from the front and - /// the ring holds exactly the most recent `RING_CAP` bytes. #[test] fn ring_over_cap_drops_oldest() { let mut ring = ReplayRing::new(ws(80, 24)); @@ -3252,8 +2099,6 @@ mod tests { assert_eq!(&flat[RING_CAP - 100..], &vec![b'b'; 100][..]); } - /// A single chunk larger than the cap keeps only its trailing `RING_CAP` - /// bytes, and collapses any older geometry segments with it. #[test] fn ring_giant_chunk_keeps_tail() { let mut ring = ReplayRing::new(ws(100, 24)); @@ -3267,11 +2112,6 @@ mod tests { assert_eq!(&ring.flatten()[RING_CAP - 4..], b"TAIL"); } - /// Regression for the "Claude Code scrollback duplicated after reattach" - /// bug: bytes recorded before and after a resize must replay as separate - /// `Size` + `Snapshot` pairs, each at its recorded geometry — replaying - /// everything at the final width re-wraps the older stretch, and a TUI's - /// cursor-up redraws then leak stale frames into scrollback. #[test] fn ring_resize_splits_replay_into_geometry_segments() { let mut ring = ReplayRing::new(ws(100, 24)); @@ -3288,9 +2128,6 @@ mod tests { assert!(rx.try_recv().is_err()); } - /// Same-size resizes are no-ops and an idle (empty-tail) pane's resizes - /// retag the tail in place — a window drag must not pile up segments. The - /// replay still ends at the current geometry, empty tail included. #[test] fn ring_idle_resizes_collapse_and_replay_ends_at_current_size() { let mut ring = ReplayRing::new(ws(100, 24)); @@ -3310,8 +2147,6 @@ mod tests { assert!(rx.try_recv().is_err()); } - /// Cap eviction that empties a leading segment drops the segment itself, - /// so its geometry no longer appears in the replay. #[test] fn ring_eviction_drops_emptied_segments() { let mut ring = ReplayRing::new(ws(100, 24)); @@ -3328,9 +2163,6 @@ mod tests { assert!(matches!(rx.try_recv(), Ok(DaemonMsg::Size(s)) if s == ws(80, 24))); } - /// Tiny segments (a drag-resize over a redrawing TUI) must not accumulate - /// without bound: past `MAX_RING_SEGMENTS` the oldest two merge — no bytes - /// lost, and the merged head carries the *newer* of the two geometries. #[test] fn ring_caps_segment_count_by_merging_oldest() { let mut ring = ReplayRing::new(ws(100, 24)); @@ -3341,14 +2173,10 @@ mod tests { } assert_eq!(ring.segments.len(), MAX_RING_SEGMENTS); - // Every byte survives the merges, in order. let flat = String::from_utf8(ring.flatten()).unwrap(); let expect: String = (0..rounds).map(|i| format!("seg{i:02} ")).collect(); assert_eq!(flat, expect); - // 74 recorded segments squeezed into 64: the head absorbed the 11 - // oldest, and replays them at the geometry of the newest one merged - // (seg 11 was recorded at 111 cols). let head = ring.segments.front().unwrap(); assert_eq!(head.size, ws(111, 24)); assert!( @@ -3358,7 +2186,6 @@ mod tests { ); } - /// OSC 7 cwd is sniffed and surfaced as a `cwd` signal. #[test] fn sniff_osc7_cwd() { let mut s = OscSniffer::new(); @@ -3366,7 +2193,6 @@ mod tests { assert_eq!(sig.cwd, Some(PathBuf::from("/Users/me/dev"))); } - /// OSC 133 B/C/D drive the shell prompt state. #[test] fn sniff_osc133_prompt() { let mut s = OscSniffer::new(); @@ -3377,26 +2203,16 @@ mod tests { let c = s.feed(b"\x1b]133;C\x07"); assert!(!c.shell.last().unwrap().at_prompt); - // D (command finished) means no command is running, so we're back at the - // prompt: at_prompt is true again (it also carries the exit code). let d = s.feed(b"\x1b]133;D;130\x07"); assert!(d.shell.last().unwrap().at_prompt); assert_eq!(d.shell.last().unwrap().last_exit_code, Some(130)); } - /// A whole command cycle inside ONE chunk still reports the prompt-boundary - /// crossing. The client counts `at_prompt` false→true edges to tell a fresh - /// prompt from a same-prompt redraw, and a Tab handoff only returns the line - /// to tty7's editor on that edge (`TerminalView::editor_handoff`). Reporting - /// just the chunk's final state hid the edge whenever `C` … `D` arrived - /// together — the norm over SSH, where a fast command's whole output lands in - /// one read — so one Tab in an ssh pane disabled the local editor for good. #[test] fn a_full_command_cycle_in_one_chunk_still_reports_leaving_the_prompt() { let mut s = OscSniffer::new(); - s.feed(b"\x1b]133;A\x07\x1b]133;B\x07"); // sitting at the prompt + s.feed(b"\x1b]133;A\x07\x1b]133;B\x07"); - // Enter → command → output → done → next prompt, all in one read. let sig = s.feed(b"\x1b]133;C;echo%20hi\x07hi\r\n\x1b]133;D;0\x07\x1b]133;A\x07\x1b]133;B\x07"); let states: Vec<bool> = sig.shell.iter().map(|s| s.at_prompt).collect(); @@ -3409,8 +2225,6 @@ mod tests { assert_eq!(sig.shell.last().unwrap().command, None); } - /// The flip side: marks that stay on one side of the boundary fold into a - /// single state, so the ordinary prompt draw still costs exactly one frame. #[test] fn marks_on_the_same_side_of_the_prompt_boundary_fold_into_one_state() { let mut s = OscSniffer::new(); @@ -3420,17 +2234,11 @@ mod tests { assert_eq!(sig.shell[0].last_exit_code, Some(3)); } - /// The C mark's command capture (tty7 extension, PowerShell integration) — - /// the Windows agent-detection input: `C;<cmd>` records the submitted line - /// percent-decoded, every prompt mark clears it, and - /// [`agent_from_shell_mark`] turns it into the chip's agent. #[test] fn sniff_osc133_command_capture_drives_agent_detection() { let custom = std::collections::HashMap::new(); let mut s = OscSniffer::new(); - // A submitted `claude --help` (space percent-encoded, as the - // PowerShell body emits it). let c = s.feed(b"\x1b]133;C;claude%20--help\x07"); let shell = c.shell.last().unwrap(); assert!(!shell.at_prompt); @@ -3440,55 +2248,39 @@ mod tests { Some(crate::core::cli_agent::CLIAgent::Claude) ); - // The command finishing (D) clears the capture → the agent clears. let d = s.feed(b"\x1b]133;D;0\x07"); let shell = d.shell.last().unwrap(); assert_eq!(shell.command, None); assert_eq!(agent_from_shell_mark(shell, &custom), None); - // A non-agent command sets the capture but detects nothing. let c = s.feed(b"\x1b]133;C;git%20status\x07"); let shell = c.shell.last().unwrap(); assert_eq!(shell.command.as_deref(), Some("git status")); assert_eq!(agent_from_shell_mark(shell, &custom), None); - // A bare `C` (a foreign shell integration) leaves no capture. let c = s.feed(b"\x1b]133;C\x07"); assert_eq!(c.shell.last().unwrap().command, None); - // A stray A/B mid-command (a nested/remote shell drawing its own - // prompt) must NOT wipe the capture — only D (command finished) does. - // Windows has no pgid gate to reject foreign marks with, so this is - // what keeps the agent chip alive while the agent runs. let _ = s.feed(b"\x1b]133;C;codex\x07"); let a = s.feed(b"\x1b]133;A\x1b]133;B\x07"); assert_eq!(a.shell.last().unwrap().command.as_deref(), Some("codex")); let d = s.feed(b"\x1b]133;D;0\x07"); assert_eq!(d.shell.last().unwrap().command, None); - // A multi-line command arrives %0A-joined (fish re-joins the split - // list with it) and decodes back to real newlines. let c = s.feed(b"\x1b]133;C;echo%20a%0Aecho%20b\x07"); assert_eq!( c.shell.last().unwrap().command.as_deref(), Some("echo a\necho b") ); - // An all-whitespace payload is no capture, like a bare `C`. let c = s.feed(b"\x1b]133;C;%20%20\x07"); assert_eq!(c.shell.last().unwrap().command, None); } - /// The Windows apply gate ([`shell_mark_capture_changed`]): detection - /// re-runs only when the capture changes, so a stray foreign A/B mark - /// mid-command — which re-delivers the same capture — can't wipe a - /// sentinel-established agent identity by re-applying the capture's `None` - /// (a wrapper script the matcher can't see through detects nothing). #[test] fn sniff_osc133_stray_marks_do_not_reapply_mark_detection() { let mut s = OscSniffer::new(); - // A wrapper launch: capture set, but detection has no answer. let mut prev = ShellState::default(); let c = s.feed(b"\x1b]133;C;.%5Cdev.ps1\x07").shell.pop().unwrap(); assert!(shell_mark_capture_changed(&prev, &c)); @@ -3498,14 +2290,10 @@ mod tests { ); prev = c; - // Stray foreign prompt marks mid-command: same capture, no re-apply — - // an agent branded by sentinel events keeps its chip. let ab = s.feed(b"\x1b]133;A\x1b]133;B\x07").shell.pop().unwrap(); assert!(!shell_mark_capture_changed(&prev, &ab)); prev = ab; - // The command finishing clears the capture: that change applies (its - // `None` is what clears the chip at the prompt). let d = s.feed(b"\x1b]133;D;0\x07").shell.pop().unwrap(); assert!(shell_mark_capture_changed(&prev, &d)); } @@ -3524,38 +2312,24 @@ mod tests { assert!(b.shell.last().unwrap().at_prompt); } - /// The foreground-command predicate: only a process group *other* than the - /// shell counts as a running command; matching pids, or missing data, mean - /// the shell is idle at its own prompt (so we never suppress a real prompt). #[test] fn foreground_command_distinguishes_the_shell_from_a_command() { - // Shell idle at its prompt: the shell is the PTY's foreground group. assert!(!is_foreground_command(Some(1000), Some(1000))); - // A command (ssh, vim, a nested shell) owns the PTY: a different group. assert!(is_foreground_command(Some(2000), Some(1000))); - // Unknown foreground group, unknown shell pid, or a non-positive pgid all - // answer "shell is foreground" — a bad reading must not disengage editing. assert!(!is_foreground_command(None, Some(1000))); assert!(!is_foreground_command(Some(2000), None)); assert!(!is_foreground_command(Some(0), Some(1000))); } - /// The reader's gate for issue #26: a remote shell over ssh emits its own - /// OSC 133 marks, which the sniffer reads as "at prompt" — but because a - /// foreground command (ssh) owns the PTY, the reader drops that flag so - /// tty7's local line editor stays disengaged and Tab reaches the remote shell. #[test] fn foreground_program_prompt_marks_do_not_claim_the_prompt() { let mut s = OscSniffer::new(); - // The remote fish draws its prompt: A (start) then B (input begins). let mut signals = s.feed(b"\x1b]133;A\x1b]133;B\x07"); assert!( signals.shell.last().unwrap().at_prompt, "the raw marks read as at-prompt" ); - // The reader consults the foreground gate before reporting. With ssh (a - // different process group) on the PTY, the prompt flag is cleared. let ssh_running = is_foreground_command(Some(2000), Some(1000)); if signals.shell.iter().any(|st| st.at_prompt) && ssh_running { for st in signals.shell.iter_mut() { @@ -3567,8 +2341,6 @@ mod tests { "a foreground program's prompt marks must not engage the local editor" ); - // Sanity: the very same marks with the shell itself foreground (idle at a - // local prompt) keep at_prompt true — the local editor still engages. let mut local = s.feed(b"\x1b]133;A\x1b]133;B\x07"); let shell_idle = is_foreground_command(Some(1000), Some(1000)); if local.shell.iter().any(|st| st.at_prompt) && shell_idle { @@ -3579,19 +2351,8 @@ mod tests { assert!(local.shell.last().unwrap().at_prompt); } - /// Regression: a well-formed OSC marker directly following an *unterminated* - /// one must not be dropped. A bare ESC inside an OSC aborts the current - /// sequence (VT semantics) and — when the next byte is `]` — introduces a new - /// OSC. The scanner has to resync on that `]` rather than dropping it into - /// Ground, or the following marker is silently lost. (The resync itself now - /// lives in `core::osc::OscTokenizer`; this stays as a routing-level guard - /// that cwd/prompt markers survive it end to end.) #[test] fn sniff_resyncs_on_new_osc_after_an_unterminated_one() { - // OSC 133: an unterminated `133;A` (aborted by the bare ESC that opens the - // next OSC) immediately followed by a well-formed `133;B`. The B marker - // drives at_prompt and must survive — dropping it re-opens the "prompt - // visible but keys mis-routed to the PTY" window this sniffer exists to close. let mut s = OscSniffer::new(); let sig = s.feed(b"\x1b]133;A\x1b]133;B\x07"); assert!( @@ -3599,46 +2360,23 @@ mod tests { "OSC 133;B after an unterminated 133;A was dropped (no resync on `]`)" ); - // OSC 7: an unterminated cwd report followed by a well-formed one — the - // second path must win (the first is discarded, not the second). let mut s = OscSniffer::new(); let sig = s.feed(b"\x1b]7;file://host/dropped\x1b]7;file://host/kept\x07"); assert_eq!(sig.cwd, Some(PathBuf::from("/kept"))); } - /// Regression guard for the "un-deletable char / doubled prompt" glitch. - /// - /// A new prompt is emitted as `…[D][A][visible PS1 text][B]`, and only the - /// trailing `B` used to flip `at_prompt` true. When the visible text and the - /// trailing `B` landed in *different* PTY read chunks (long prompts with git - /// status + color escapes make this likely), there was a window where the - /// client had already rendered the prompt — the user sees it and starts typing - /// — yet `at_prompt` was still false, so those keys were routed to the PTY - /// (echoed by ZLE into the grid) instead of the local editor. - /// - /// The fix keys `at_prompt` off "no command running", so `D`/`A` (which precede - /// the prompt text in the stream) already set it true. This test feeds the - /// prompt as separate chunks and asserts `at_prompt` is true from the moment - /// the prompt text is visible — i.e. the window is closed. #[test] fn at_prompt_covers_prompt_draw_gap_across_chunks() { let mut s = OscSniffer::new(); - // A command was running… assert!(!s.feed(b"\x1b]133;C\x07").shell.last().unwrap().at_prompt); - // …then finishes: D (in its own chunk, before any prompt text) already - // marks us back at the prompt. let d = s.feed(b"\x1b]133;D;0\x07"); assert!( d.shell.last().unwrap().at_prompt, "D should mark us back at the prompt before the prompt text is drawn" ); - // The visible prompt text arrives in a later chunk, still WITHOUT the - // trailing B. Because D already set at_prompt, the state stays true while - // the prompt is on screen — so a key typed here routes to the editor, not - // the PTY. This is the window that used to be open. let chunk = s.feed( b"\x1b]133;A\x07\x1b]7;file://host/repo/tty7\x07\r\ntty7 git:(main) \xe2\x9e\x9c ", ); @@ -3647,12 +2385,9 @@ mod tests { "prompt visible but at_prompt=false — the mis-routing window is still open" ); - // The trailing B finally arrives and keeps it true. assert!(s.feed(b"\x1b]133;B\x07").shell.last().unwrap().at_prompt); } - /// `pty_size` never reports a zero dimension (a 0×0 window would make the - /// child think it has no room) and derives pixel size from the cell metrics. #[test] fn pty_size_clamps_and_computes_pixels() { let ps = pty_size(WinSize { @@ -3666,7 +2401,6 @@ mod tests { assert_eq!(ps.pixel_width, 80 * 8); assert_eq!(ps.pixel_height, 24 * 17); - // A degenerate 0×0 window clamps rows/cols up to 1. let z = pty_size(WinSize { cols: 0, rows: 0, @@ -3678,7 +2412,6 @@ mod tests { assert_eq!(z.pixel_width, 0); assert_eq!(z.pixel_height, 0); - // Pixel dimensions saturate rather than overflow u16. let big = pty_size(WinSize { cols: u16::MAX, rows: u16::MAX, @@ -3689,76 +2422,52 @@ mod tests { assert_eq!(big.pixel_height, u16::MAX); } - /// OSC 7 parsing accepts both `file://HOST/PATH` and a bare absolute path, and - /// rejects anything else. #[test] fn parse_osc7_forms_and_rejections() { - // file://HOST/PATH → the path after the host. assert_eq!( parse_osc7(b"7;file://host/Users/me/dev"), Some(PathBuf::from("/Users/me/dev")) ); - // An empty host (file:///path) still yields the absolute path. assert_eq!(parse_osc7(b"7;file:///etc"), Some(PathBuf::from("/etc"))); - // A bare absolute path (no file:// scheme) is taken verbatim. assert_eq!(parse_osc7(b"7;/var/log"), Some(PathBuf::from("/var/log"))); - // Percent-escapes in the path are decoded. assert_eq!( parse_osc7(b"7;file://host/a%20b"), Some(PathBuf::from("/a b")) ); - // Percent-encoded multibyte UTF-8 (a CJK dir name) decodes losslessly. assert_eq!( parse_osc7(b"7;file://host/%E4%B8%AD%E6%96%87"), Some(PathBuf::from("/中文")) ); - // Round-trip with the shell integration's `%` → `%25` escape: a dir - // whose name contains a literal `%XX` survives the decode intact. assert_eq!( parse_osc7(b"7;file://host/tmp/a%2520b"), Some(PathBuf::from("/tmp/a%20b")) ); - // Missing the `7;` prefix. assert!(parse_osc7(b"8;file://host/x").is_none()); - // `file://` with no path slash after the host. assert!(parse_osc7(b"7;file://host").is_none()); - // Neither file:// nor an absolute path. assert!(parse_osc7(b"7;relative/path").is_none()); - // Decodes to empty → rejected. assert!(parse_osc7(b"7;file://host").is_none()); } - /// A `file://` URI path arrives with a leading slash; a Windows drive path - /// (`/C:/…`, what PowerShell's OSC 7 reporter yields) must drop it, while - /// POSIX and UNC paths keep theirs. This is what makes cwd-inheriting new - /// tabs work on Windows. #[test] fn strip_uri_drive_slash_only_unwraps_drive_paths() { assert_eq!(strip_uri_drive_slash("/C:/Users/foo"), "C:/Users/foo"); assert_eq!(strip_uri_drive_slash("/d:/x"), "d:/x"); - // POSIX paths keep their leading slash (no drive letter follows). assert_eq!(strip_uri_drive_slash("/home/me/dev"), "/home/me/dev"); - // A UNC share (`//host/share`) is left alone — the second byte is a slash. assert_eq!(strip_uri_drive_slash("//host/share"), "//host/share"); - // No leading slash, or too short to be a drive path: untouched. assert_eq!(strip_uri_drive_slash("C:/already"), "C:/already"); assert_eq!(strip_uri_drive_slash("/"), "/"); } - /// `%XX` escapes decode; malformed or truncated escapes are kept literally. #[test] fn percent_decode_handles_escapes_and_garbage() { assert_eq!(percent_decode(b"a%20b"), b"a b"); assert_eq!(percent_decode(b"%2F"), b"/"); - assert_eq!(percent_decode(b"%2f"), b"/"); // lowercase hex - // Non-hex after % is left verbatim. + assert_eq!(percent_decode(b"%2f"), b"/"); assert_eq!(percent_decode(b"%GG"), b"%GG"); - // A truncated escape at the end has no two following digits → literal. assert_eq!(percent_decode(b"x%2"), b"x%2"); assert_eq!(percent_decode(b"plain"), b"plain"); } - /// `hex_val` covers the three hex ranges and rejects everything else. #[test] fn hex_val_ranges() { assert_eq!(hex_val(b'0'), Some(0)); @@ -3772,30 +2481,22 @@ mod tests { assert!(hex_val(b'/').is_none()); } - /// OSC 133 `D` carries an optional exit code; a missing or unparseable code - /// leaves it `None`, and a negative code parses. #[test] fn osc133_exit_code_parsing() { let mut s = OscSniffer::new(); - // D with no code. let d = s.feed(b"\x1b]133;D\x07"); assert!(d.shell.last().unwrap().at_prompt); assert_eq!(d.shell.last().unwrap().last_exit_code, None); - // D with a non-numeric code stays None. let d = s.feed(b"\x1b]133;D;oops\x07"); assert_eq!(d.shell.last().unwrap().last_exit_code, None); - // A negative exit code parses. let d = s.feed(b"\x1b]133;D;-1\x07"); assert_eq!(d.shell.last().unwrap().last_exit_code, Some(-1)); } - /// A fresh `PaneState` for the PTY-less state-machine tests. fn test_state(alive: bool) -> PaneState { PaneState { - // Unit tests publish observations nowhere (no store is installed - // in this process), so the id is never consulted. id: 0, ring: ReplayRing::new(ws(80, 24)), subscriber: None, @@ -3810,11 +2511,6 @@ mod tests { } } - /// What the machine tree is told about a pane is exactly what a successor - /// needs: the cwd as a string, the session's own argv over the poll's - /// capture (the session record survives chip churn), and the coarse - /// status. No agent, no facts — a revival must not resume a session that - /// was never there. #[test] fn observed_facts_prefer_the_sessions_argv_and_carry_its_status() { use crate::core::cli_agent::{AgentSessionState, AgentStatus, CLIAgent}; @@ -3844,7 +2540,6 @@ mod tests { ); assert_eq!(agent.status, Some(AgentStatus::Working)); - // The poll's capture is the fallback until the session stamps its own. st.agent_session = None; let (_, agent) = observed_facts(&st); assert_eq!( @@ -3853,16 +2548,6 @@ mod tests { ); } - /// Ending a workspace's sessions has to leave a record its successor can - /// resume from. The kill hangs up the whole process group, so the coding - /// agent dies before the PTY EOFs — and a poll firing on whatever bytes - /// still come out then sees nothing recognizable in the foreground. - /// Published, that answer clears the record's agent, session id and all, and - /// the reopened workspace comes back to a bare shell instead of the - /// conversation. So a teardown publishes nothing. - /// - /// The second half is the behaviour that must *not* change: the same answer - /// about a pane nobody is tearing down means the agent exited on its own. #[test] fn a_pane_killed_with_its_agent_keeps_the_facts_a_resume_needs() { use crate::core::cli_agent::{AgentSessionState, CLIAgent}; @@ -3897,9 +2582,6 @@ mod tests { .unwrap(); publish_observations(&store); - // One read carrying a prompt mark — which is what opens the publish - // gate — while the poll answers "nothing recognizable in the - // foreground", the reading a hung-up agent produces. let run = |shutting_down: bool| { let mut state = test_state(true); state.id = PANE; @@ -3942,10 +2624,6 @@ mod tests { withdraw_observations(); } - /// The full daemon-side rich-status path: sentinel OSC events sniffed out - /// of the byte stream drive the pane's session state machine, identify the - /// agent when argv detection hasn't, and stream every change to the - /// subscriber — while a plain notification only fires the opaque fallback. #[test] fn sentinel_events_drive_agent_session_state() { use crate::core::cli_agent::{AgentStatus, CLIAgent}; @@ -3965,15 +2643,12 @@ mod tests { ); apply_signals(&mut st, sniffer.feed(stream.as_bytes())); - // The event branded the pane (argv detection never ran here)… assert_eq!(st.agent, Some(CLIAgent::Claude)); - // …and the state machine folded both events: idle → working, id kept. let sess = st.agent_session.clone().expect("session state exists"); assert_eq!(sess.status, AgentStatus::Working); assert_eq!(sess.session_id.as_deref(), Some("sid-9")); assert!(sess.rich); - // The subscriber saw the identity and the (final) status. assert!(matches!( rx.try_recv(), Ok(DaemonMsg::Agent(Some(CLIAgent::Claude))) @@ -3983,7 +2658,6 @@ mod tests { Ok(DaemonMsg::AgentStatus(Some(s))) if s.status == AgentStatus::Working )); - // A waiting event lands with its message. let waiting = concat!( "\x1b]777;notify;tty7://cli-agent;", r#"{"event":"notification","message":"Claude needs your permission to use Bash"}"#, @@ -3999,27 +2673,21 @@ mod tests { Ok(DaemonMsg::AgentStatus(Some(s))) if s.message.as_deref().unwrap().contains("permission") )); - // The agent leaving the foreground clears the session (and says so). apply_agent(&mut st, None); assert!(st.agent_session.is_none()); assert!(matches!(rx.try_recv(), Ok(DaemonMsg::AgentStatus(None)))); assert!(matches!(rx.try_recv(), Ok(DaemonMsg::Agent(None)))); } - /// The opaque fallback: with an agent detected but no hooks, a plain OSC 9 - /// notification marks the session waiting (non-rich); without an agent it - /// does nothing; and it never clobbers live rich state. #[test] fn opaque_notifications_only_fall_back_when_no_rich_state() { use crate::core::cli_agent::{AgentSessionState, AgentStatus, CLIAgent}; - // No agent → the notification is ignored (it's just a toast). let mut st = test_state(true); let mut sniffer = OscSniffer::new(); apply_signals(&mut st, sniffer.feed(b"\x1b]9;Build finished\x07")); assert!(st.agent_session.is_none()); - // Agent detected, no hooks → waiting, non-rich, body kept. st.agent = Some(CLIAgent::Codex); apply_signals( &mut st, @@ -4029,7 +2697,6 @@ mod tests { assert_eq!(sess.status, AgentStatus::Waiting); assert!(!sess.rich); - // Rich state present → the opaque ping is ignored. st.agent_session = Some(AgentSessionState { status: AgentStatus::Working, message: None, @@ -4046,16 +2713,9 @@ mod tests { ); } - /// Issue #187: a pane whose shell tty7 never managed to instrument — the - /// user's `.zshrc` ends in `exec fish`, so no OSC 7 is ever emitted — keeps - /// reporting the directory it was *spawned* in, and every new tab opens - /// there instead of where the user actually is. The process-table reading - /// corrects it, and the client is told. #[test] fn probed_cwd_corrects_a_pane_whose_shell_never_reports_osc7() { let mut st = test_state(true); - // What `spawn` seeds: the pane's initial directory, and — with no OSC 7 - // — for the rest of the pane's life. st.cwd = Some(PathBuf::from("/Users/alice")); let (tx, rx) = mpsc::channel(); st.subscriber = Some(tx); @@ -4068,17 +2728,6 @@ mod tests { ); } - /// The shell's own spelling outranks the kernel's when both name the same - /// directory: `$PWD` keeps the symlinked path the user walked in through, - /// and that is the one a new tab should open in. Uses a real symlink so the - /// canonicalization is the actual one, not a stand-in. - /// - /// Unix only, and not because the reconciliation is: creating a symlink on - /// Windows needs `SeCreateSymbolicLinkPrivilege`, which an unelevated shell - /// without Developer Mode does not hold, so the setup — not the assertion — - /// fails on an ordinary Windows box with `ERROR_PRIVILEGE_NOT_HELD`. Nothing - /// is lost by skipping it there: `foreground_cwd` answers `None` off - /// macOS/Linux, so no Windows pane ever reaches this comparison. #[cfg(unix)] #[test] fn probed_cwd_keeps_the_shells_spelling_for_a_symlinked_path() { @@ -4090,11 +2739,10 @@ mod tests { std::os::unix::fs::symlink(&real, &link).unwrap(); let mut st = test_state(true); - st.cwd = Some(link.clone()); // as OSC 7 reported it + st.cwd = Some(link.clone()); let (tx, rx) = mpsc::channel(); st.subscriber = Some(tx); - // The kernel reports the physical path — same directory, different name. apply_probed_cwd(&mut st, Some(real.canonicalize().unwrap())); assert_eq!(st.cwd.as_deref(), Some(link.as_path())); @@ -4103,11 +2751,6 @@ mod tests { std::fs::remove_dir_all(&tmp).ok(); } - /// The same rule on the path every platform takes — a reading that matches - /// what we already report letter for letter, needing no filesystem at all. - /// The poll fires twice a second for the whole life of a pane, so a re-send - /// here would be a `Cwd` frame (and the git probe behind it) twice a second - /// on an idle shell. #[test] fn probed_cwd_matching_the_reported_one_says_nothing() { let mut st = test_state(true); @@ -4121,9 +2764,6 @@ mod tests { assert!(rx.try_recv().is_err()); } - /// A remote pane's cwd lives in the remote's namespace; the only thing the - /// local process table can see is the `ssh` client's own directory, which - /// must never be attributed to the remote shell. #[test] fn probed_cwd_declines_to_speak_for_a_remote_pane() { let mut st = test_state(true); @@ -4142,8 +2782,6 @@ mod tests { assert!(rx.try_recv().is_err()); } - /// No reading (native SSH, Windows, an unreadable process) is "no opinion", - /// never "no cwd" — it must not clear what the pane already reports. #[test] fn probed_cwd_absent_leaves_the_reported_cwd_alone() { let mut st = test_state(true); @@ -4157,8 +2795,6 @@ mod tests { assert!(rx.try_recv().is_err()); } - /// Attaching replays Size → Snapshot (→ Cwd) in order and installs the - /// subscriber under a fresh epoch. #[test] fn attach_replays_state_in_order_and_installs_subscriber() { let mut st = test_state(true); @@ -4174,7 +2810,6 @@ mod tests { assert!( matches!(rx.try_recv(), Ok(DaemonMsg::Cwd(p)) if p.as_path() == std::path::Path::new("/work")) ); - // A live pane replays no exit; the reader thread reports that live. assert!(rx.try_recv().is_err()); } @@ -4193,9 +2828,6 @@ mod tests { ); } - /// Regression: attaching to a pane whose child already exited must replay - /// the exit too — the reader thread that would have reported it is gone, so - /// without this the client renders the snapshot and then waits forever. #[test] fn attach_to_a_dead_pane_replays_exited() { let mut st = test_state(false); @@ -4203,7 +2835,6 @@ mod tests { let (tx, rx) = mpsc::channel(); attach_subscriber(&mut st, tx); - // Skip the geometry + snapshot replay, then the exit must follow. assert!(matches!(rx.try_recv(), Ok(DaemonMsg::Size(_)))); assert!(matches!(rx.try_recv(), Ok(DaemonMsg::Snapshot(_)))); assert!(matches!( @@ -4212,8 +2843,6 @@ mod tests { )); } - /// EOF with a subscriber attached: `Exited` goes to the subscriber and the - /// pane is NOT handed to `on_dead` — that connection's detach reclaims it. #[test] fn reader_eof_with_subscriber_sends_exited_not_on_dead() { let state = Arc::new(Mutex::new(test_state(true))); @@ -4227,7 +2856,7 @@ mod tests { Arc::new(AtomicBool::new(false)), Arc::new(OutputGate::new()), Box::new(std::io::Cursor::new(b"tail".to_vec())), - || false, // no PTY here → treat the shell as foreground + || false, ForegroundProbes { remote: Box::new(|| None), agent: Box::new(|| None), @@ -4237,7 +2866,7 @@ mod tests { dead_flag.store(true, Ordering::SeqCst) })), ); - handle.join().unwrap(); // the Cursor EOFs immediately after "tail" + handle.join().unwrap(); assert!(!state.lock().unwrap().alive); assert_eq!(state.lock().unwrap().ring.flatten(), b"tail"); @@ -4252,17 +2881,13 @@ mod tests { ); } - /// Wiring for issue #187: the reader's foreground poll applies the cwd - /// reading, so an uninstrumented shell's directory reaches the client with - /// no OSC 7 anywhere in the byte stream. The poll gate opens on the first - /// chunk, so one read is enough. #[test] fn reader_poll_applies_the_probed_cwd() { let state = Arc::new(Mutex::new(test_state(true))); let (sub_tx, sub_rx) = mpsc::channel(); { let mut st = state.lock().unwrap(); - st.cwd = Some(PathBuf::from("/Users/alice")); // the spawn directory + st.cwd = Some(PathBuf::from("/Users/alice")); st.subscriber = Some(sub_tx); } @@ -4291,9 +2916,6 @@ mod tests { ); } - /// Regression: EOF with *nobody* attached must fire `on_dead` so the server - /// can drop the pane — otherwise a detached pane whose shell exits leaks its - /// zombie child and replay ring in the registry for the daemon's lifetime. #[test] fn reader_eof_without_subscriber_fires_on_dead() { let state = Arc::new(Mutex::new(test_state(true))); @@ -4318,8 +2940,6 @@ mod tests { assert!(dead_rx.try_recv().is_ok(), "unattached death → on_dead"); } - /// During owner-initiated teardown (`shutting_down`), EOF neither notifies - /// nor fires `on_dead` — the killer owns the registry cleanup. #[test] fn reader_eof_during_shutdown_is_silent() { let state = Arc::new(Mutex::new(test_state(true))); @@ -4328,7 +2948,7 @@ mod tests { let handle = DaemonPane::spawn_reader( state.clone(), - Arc::new(AtomicBool::new(true)), // teardown already initiated + Arc::new(AtomicBool::new(true)), Arc::new(OutputGate::new()), Box::new(std::io::Cursor::new(Vec::new())), || false, @@ -4347,9 +2967,6 @@ mod tests { assert!(!dead.load(Ordering::SeqCst)); } - /// The latch that lets the reader's EOF and (on Windows) the child-exit - /// monitor both report the same death without the subscriber seeing two - /// `Exited`s: the first `report` notifies, the second is a silent no-op. #[test] fn death_reporter_notifies_once_across_racing_callers() { let state = Arc::new(Mutex::new(test_state(true))); @@ -4360,12 +2977,10 @@ mod tests { let calls_flag = calls.clone(); let death = DeathReporter::new(move || calls_flag.store(true, Ordering::SeqCst)); - // Two reporters (stand-ins for the reader and the monitor) both fire. death.report(&state, &shutting_down); death.report(&state, &shutting_down); assert!(!state.lock().unwrap().alive); - // Exactly one `Exited`, then nothing more. assert!(matches!( sub_rx.try_recv(), Ok(DaemonMsg::Exited { code: None }) @@ -4376,8 +2991,6 @@ mod tests { ); } - /// With nobody attached, the *first* report hands the pane to `on_dead` and a - /// racing second report neither re-fires it nor panics on the taken `FnOnce`. #[test] fn death_reporter_fires_on_dead_at_most_once() { let state = Arc::new(Mutex::new(test_state(true))); @@ -4392,10 +3005,6 @@ mod tests { assert!(dead_rx.try_recv().is_err(), "on_dead must fire only once"); } - /// Every pane is told which terminal it is running in, under the names the - /// rest of the world reads (`TERM_PROGRAM`/`TERM_PROGRAM_VERSION`) as well - /// as our own `TTY7` marker. Nothing third-party looks for the marker, so - /// dropping the standard pair would leave capability probes guessing. #[test] fn pane_environment_advertises_the_terminal_under_the_standard_names() { let env: std::collections::HashMap<_, _> = @@ -4421,10 +3030,6 @@ mod tests { ); } - /// The user's `env` map may rename the terminal — posing as another program - /// is how you get a tool that only recognises a fixed list to light up — - /// but it may not contradict what our emulator can decode. Later entries - /// win, so the ordering is the precedence. #[test] fn pane_environment_lets_configured_env_override_identity_but_not_capability() { let configured = [ @@ -4460,12 +3065,6 @@ mod tests { ); } - /// Windows environment blocks are case-insensitive — `portable-pty` keeps - /// one slot per lowercased key — so a configured `Term` would replace - /// `TERM` just as surely as the exact spelling. The capability filter must - /// therefore drop any casing of a capability key, not just the canonical - /// one. (On Unix a differently-cased key is a distinct variable and passes - /// through untouched.) #[cfg(windows)] #[test] fn pane_environment_capability_keys_cannot_be_overridden_by_recasing() { @@ -4489,13 +3088,9 @@ mod tests { }; assert_eq!(get("TERM"), Some("xterm-256color")); assert_eq!(get("COLORTERM"), Some("truecolor")); - // Identity keys stay overridable in any casing the user spells. assert_eq!(get("term_program"), Some("x")); } - /// The macOS UTF-8 fallback applies only when the inherited environment has - /// no locale and the user has not taken control through the generic `env` - /// map. Key presence is authoritative there, including an empty value. #[test] fn locale_fallback_respects_inherited_and_configured_environments() { let environment = |pairs: &[(&str, &str)]| { @@ -4522,9 +3117,6 @@ mod tests { )); } - /// A CFLocale identifier is not a POSIX locale name: it can carry a script - /// subtag, keywords, and hyphens, and it may name a region combination the - /// machine has no locale for (`en_CN` is a perfectly ordinary macOS setting). #[test] fn posix_locale_stem_drops_script_and_keyword_subtags() { let stem = |id: &str| posix_locale_stem(id); @@ -4539,47 +3131,35 @@ mod tests { assert_eq!(stem("es_419").as_deref(), Some("es_419")); assert_eq!(stem("EN_us").as_deref(), Some("en_US")); - // No region to name a locale with, so there is nothing to derive. assert_eq!(stem("zh"), None); assert_eq!(stem("zh_Hans"), None); assert_eq!(stem(""), None); assert_eq!(stem("@calendar=gregorian"), None); } - /// The derived locale must exist on the machine before it is exported. - /// `LC_CTYPE` outranks the `LANG` a remote host sets for itself and ssh - /// forwards `LC_*`, so an unloadable name would break non-ASCII output on - /// every host we ssh into — the exact failure this seeding exists to fix. #[test] fn character_locale_only_returns_installed_locales() { let installed = |names: &'static [&'static str]| move |n: &str| names.contains(&n); - // The system locale is used when the machine actually has it. assert_eq!( character_locale(Some("zh_Hans_CN"), installed(&["zh_CN.UTF-8", "C.UTF-8"])), Some("zh_CN.UTF-8".to_string()) ); - // `en_CN` resolves to no installed locale, so it falls through. assert_eq!( character_locale(Some("en_CN"), installed(&["C.UTF-8", "en_US.UTF-8"])), Some("C.UTF-8".to_string()) ); - // Older macOS predates `C.UTF-8`; `en_US.UTF-8` is there on every macOS. assert_eq!( character_locale(Some("en_CN"), installed(&["en_US.UTF-8"])), Some("en_US.UTF-8".to_string()) ); - // No identifier at all still yields a usable fallback. assert_eq!( character_locale(None, installed(&["C.UTF-8", "en_US.UTF-8"])), Some("C.UTF-8".to_string()) ); - // Nothing installed means nothing exported — never a name that fails to load. assert_eq!(character_locale(Some("zh_Hans_CN"), installed(&[])), None); } - /// The locale actually derived on this machine must be one the C library can - /// load, since an unloadable `LC_CTYPE` is worse than none at all. #[cfg(target_os = "macos")] #[test] fn derived_character_locale_is_installed_on_this_machine() { @@ -4597,10 +3177,6 @@ mod tests { ); } - /// Every spawned shell carries the `TTY7` marker, so the `tty7 agent-hook` - /// emitter fires (it stays silent without it). This is the env side of the - /// rich-status channel — a regression here silently breaks all hook-based - /// agent status, which no other test would catch. #[test] fn spawned_shell_carries_the_tty7_marker() { let cmd = build_shell_command(None, &Some(PathBuf::from("/tmp"))) @@ -4616,12 +3192,10 @@ mod tests { ); } - /// `apply_signals` writes sniffed cwd/shell state into the pane state. #[test] fn apply_signals_updates_state() { let mut st = test_state(true); - // A cwd signal lands in the state. apply_signals( &mut st, SniffSignals { @@ -4631,7 +3205,6 @@ mod tests { ); assert_eq!(st.cwd, Some(PathBuf::from("/tmp/x"))); - // A shell signal updates the prompt state. apply_signals( &mut st, SniffSignals { @@ -4647,7 +3220,6 @@ mod tests { assert!(st.shell.active && st.shell.at_prompt); assert_eq!(st.shell.last_exit_code, Some(0)); - // An empty signal set changes nothing. apply_signals(&mut st, SniffSignals::default()); assert_eq!(st.cwd, Some(PathBuf::from("/tmp/x"))); } diff --git a/crates/tty7-core/src/daemon/pidfile.rs b/crates/tty7-core/src/daemon/pidfile.rs index a6e3a7fe..3d56c645 100644 --- a/crates/tty7-core/src/daemon/pidfile.rs +++ b/crates/tty7-core/src/daemon/pidfile.rs @@ -1,32 +1,11 @@ -//! The daemon's pid marker: `<config>/daemon.pid`, written after a successful -//! `bind` and removed on shutdown. -//! -//! The endpoint marker (socket / port file) answers "is something listening -//! *here*?", but says nothing about *which process* — and that gap is exactly -//! how daemons got stranded (see the takeover paths in `spawn`): a client that -//! couldn't talk to the old daemon would unlink its endpoint and start a fresh -//! one, leaving the old process alive, unreachable, and still holding every -//! pane's PTY + children. The pidfile closes the gap: takeover paths read it -//! and reap the recorded process before claiming the endpoint. -//! -//! A pidfile can outlive its daemon (crash, SIGKILL), and pids get recycled — -//! so readers must never trust it blindly. `spawn::reap_recorded_daemon` -//! verifies the pid's executable basename matches our own before signalling. - use std::path::PathBuf; use crate::core::config; -/// Path of the pidfile for this process's config dir. `None` only when the -/// config dir can't be resolved (no `$HOME`). pub fn path() -> Option<PathBuf> { config::config_path("daemon.pid") } -/// Record the current process as the daemon serving this config dir. Best -/// effort: the pidfile is a rescue marker, not a correctness requirement, so a -/// failed write must not take the daemon down — it just means a future -/// takeover can't reap us and falls back to today's behavior. pub fn write_current() { let Some(path) = path() else { return }; if let Some(parent) = path.parent() { @@ -37,14 +16,11 @@ pub fn write_current() { } } -/// The recorded daemon pid, if the pidfile exists and parses. Says nothing -/// about whether that process is still alive or still a tty7 daemon. pub fn read() -> Option<u32> { let contents = std::fs::read_to_string(path()?).ok()?; contents.trim().parse::<u32>().ok() } -/// Remove the pidfile. Best effort: a missing file is fine. pub fn remove() { if let Some(path) = path() { let _ = std::fs::remove_file(path); @@ -55,18 +31,12 @@ pub fn remove() { mod tests { use super::*; - /// Pin the process config dir so the pidfile lives under a temp dir, never - /// the real `~/.config`. First-call-wins across the whole test binary, so - /// use the same directory the other IO tests pin. fn pin_config_dir() { let dir = std::env::temp_dir().join(format!("tty7-covtest-{}", std::process::id())); std::fs::create_dir_all(&dir).ok(); config::set_config_dir(dir); } - /// One test drives the whole lifecycle — write → read → remove → reject - /// garbage — so the shared `daemon.pid` file isn't raced by parallel tests - /// (same reason transport's endpoint test is a single lifecycle). #[test] fn pidfile_lifecycle_round_trips_clears_and_rejects_garbage() { pin_config_dir(); @@ -74,11 +44,8 @@ mod tests { assert_eq!(read(), Some(std::process::id())); remove(); assert_eq!(read(), None, "no pid after removal"); - // Removing again is harmless. remove(); - // A corrupt file (partial write, hand-edited) must read as "no pid", - // never panic or misparse. std::fs::write(path().unwrap(), "not-a-pid\n").unwrap(); assert_eq!(read(), None); remove(); diff --git a/crates/tty7-core/src/daemon/procinfo.rs b/crates/tty7-core/src/daemon/procinfo.rs index 71635eff..9e3356c7 100644 --- a/crates/tty7-core/src/daemon/procinfo.rs +++ b/crates/tty7-core/src/daemon/procinfo.rs @@ -1,32 +1,11 @@ -//! What a pane is *running*: the process tree under its shell, and the TCP ports -//! that tree is listening on. Feeds the GUI's details panel (`QueryProcs`). -//! -//! Everything here is best-effort and read-only. A pid can exit between the -//! table walk and the name lookup, `lsof` may be missing, `/proc` may be -//! unreadable — each of those degrades to a shorter list, never an error. The -//! panel showing one fewer row is a non-event; a details query that can fail is -//! a support burden. -//! -//! Called on demand from the details panel, not on a timer — see the doc on -//! [`ClientMsg::QueryProcs`](crate::daemon::protocol::ClientMsg::QueryProcs) for -//! why this is pull-based when `Cwd` and `Agent` are pushed. - use std::collections::HashMap; use crate::daemon::protocol::{PaneProcs, PortEntry, ProcEntry}; -/// Depth cap on the process walk. Deep trees are real (a shell running `make` -/// running a compiler driver running the compiler), but past a handful of hops -/// the rows stop being information and start being noise in a 260px column. const MAX_DEPTH: u8 = 6; -/// Hard cap on rows, so a pane that spawned a thousand workers can't turn a -/// details query into a wire-format stress test. const MAX_PROCS: usize = 64; -/// The process tree under `shell_pid` plus its listening ports. `fg_pgid` is the -/// PTY's foreground process group, used to mark the row the user is looking at; -/// pass `None` when it isn't known. pub fn snapshot(shell_pid: u32, fg_pgid: Option<i32>) -> PaneProcs { let table = process_table(); let procs = walk(&table, shell_pid, fg_pgid); @@ -34,19 +13,13 @@ pub fn snapshot(shell_pid: u32, fg_pgid: Option<i32>) -> PaneProcs { PaneProcs { procs, ports } } -/// One row of the system process table, reduced to what the walk needs. struct Row { ppid: u32, pgid: u32, name: String, } -/// Depth-first from the shell, so the caller can render in order and indent by -/// `depth` without rebuilding a hierarchy. fn walk(table: &HashMap<u32, Row>, shell_pid: u32, fg_pgid: Option<i32>) -> Vec<ProcEntry> { - // Children by parent, so the descent is a lookup rather than a table scan - // per node. Sorted by pid: the process table's own order is unspecified, and - // a list that reshuffles between two refreshes reads as churn. let mut children: HashMap<u32, Vec<u32>> = HashMap::new(); for (pid, row) in table { children.entry(row.ppid).or_default().push(*pid); @@ -72,7 +45,6 @@ fn walk(table: &HashMap<u32, Row>, shell_pid: u32, fg_pgid: Option<i32>) -> Vec< continue; } if let Some(kids) = children.get(&pid) { - // Pushed in reverse so the pop order stays ascending by pid. for kid in kids.iter().rev() { stack.push((*kid, depth + 1)); } @@ -81,26 +53,15 @@ fn walk(table: &HashMap<u32, Row>, shell_pid: u32, fg_pgid: Option<i32>) -> Vec< out } -// ── Platform: the process table ───────────────────────────────────────────── - -/// macOS: one `proc_listallpids` sweep, then `PROC_PIDTBSDINFO` per pid for -/// parent/group. Cheaper than shelling out to `ps`, and it can't be defeated by -/// a user's `ps` alias or a locale-dependent column layout. #[cfg(target_os = "macos")] fn process_table() -> HashMap<u32, Row> { let mut table = HashMap::new(); - // Ask for the count first, then read into a buffer sized from it (plus slack, - // since processes can appear between the two calls). - // SAFETY: the documented "how big a buffer do I need" form — null buffer, - // zero size — which only returns a byte count. let bytes = unsafe { libc::proc_listallpids(std::ptr::null_mut(), 0) }; if bytes <= 0 { return table; } let cap = (bytes as usize / std::mem::size_of::<libc::c_int>()) + 64; let mut pids = vec![0 as libc::c_int; cap]; - // SAFETY: buffer and its true byte length; the call writes at most that many - // bytes and returns how many it wrote. let written = unsafe { libc::proc_listallpids( pids.as_mut_ptr() as *mut libc::c_void, @@ -117,9 +78,6 @@ fn process_table() -> HashMap<u32, Row> { } let mut info: libc::proc_bsdinfo = unsafe { std::mem::zeroed() }; let size = std::mem::size_of::<libc::proc_bsdinfo>() as libc::c_int; - // SAFETY: zeroed buffer of the expected type, real size passed; the - // result is read back only when the kernel filled exactly that many - // bytes (a short return means the pid died mid-walk). let ret = unsafe { libc::proc_pidinfo( pid, @@ -132,8 +90,6 @@ fn process_table() -> HashMap<u32, Row> { if ret != size { continue; } - // `pbi_comm` is the kernel's truncated name (16 bytes). Prefer the full - // executable basename, which is what the user typed. let name = proc_name(pid).unwrap_or_else(|| cstr_field(&info.pbi_comm)); table.insert( pid as u32, @@ -147,7 +103,6 @@ fn process_table() -> HashMap<u32, Row> { table } -/// Read a fixed-size, NUL-padded C char array into a `String`. #[cfg(target_os = "macos")] fn cstr_field(buf: &[libc::c_char]) -> String { let bytes: Vec<u8> = buf @@ -158,9 +113,6 @@ fn cstr_field(buf: &[libc::c_char]) -> String { String::from_utf8_lossy(&bytes).into_owned() } -/// Linux: `/proc/<pid>/stat` carries ppid and pgid in fixed positions. The -/// comm field is parenthesized and may itself contain spaces and parens, so the -/// fields after it are located from the *last* `)`, not by splitting the line. #[cfg(target_os = "linux")] fn process_table() -> HashMap<u32, Row> { let mut table = HashMap::new(); @@ -182,7 +134,6 @@ fn process_table() -> HashMap<u32, Row> { continue; }; let mut fields = stat[close + 1..].split_whitespace(); - // After `)`: state, ppid, pgrp, … let (Some(_state), Some(ppid), Some(pgid)) = (fields.next(), fields.next(), fields.next()) else { continue; @@ -191,7 +142,6 @@ fn process_table() -> HashMap<u32, Row> { continue; }; let name = proc_name(pid as i32).unwrap_or_else(|| { - // Fall back to the parenthesized comm already in hand. stat[..close] .rfind('(') .map_or_else(|| String::new(), |open| stat[open + 1..close].to_string()) @@ -201,9 +151,6 @@ fn process_table() -> HashMap<u32, Row> { table } -/// Windows: reuse the existing toolhelp snapshot. It carries no process-group -/// concept, so nothing is ever marked foreground — matching how `foreground_title` -/// already treats the platform. #[cfg(windows)] fn process_table() -> HashMap<u32, Row> { crate::daemon::winproc::snapshot() @@ -212,7 +159,6 @@ fn process_table() -> HashMap<u32, Row> { ( p.pid, Row { - // `winproc::Proc` names the parent link `parent`. ppid: p.parent, pgid: 0, name: p.name, @@ -227,12 +173,9 @@ fn process_table() -> HashMap<u32, Row> { HashMap::new() } -/// Executable basename of `pid` (macOS). #[cfg(target_os = "macos")] fn proc_name(pid: i32) -> Option<String> { let mut buf = [0u8; libc::PROC_PIDPATHINFO_MAXSIZE as usize]; - // SAFETY: valid, correctly-sized buffer; `proc_pidpath` writes at most - // `buf.len()` bytes and returns the count (<=0 on failure). let ret = unsafe { libc::proc_pidpath(pid, buf.as_mut_ptr() as *mut libc::c_void, buf.len() as u32) }; if ret <= 0 { @@ -242,8 +185,6 @@ fn proc_name(pid: i32) -> Option<String> { Some(path.rsplit('/').next().unwrap_or(path).to_string()) } -/// Executable basename of `pid` via `/proc/<pid>/exe` (Linux). Unreadable for -/// processes we don't own, hence the caller's `comm` fallback. #[cfg(target_os = "linux")] fn proc_name(pid: i32) -> Option<String> { let path = std::fs::read_link(format!("/proc/{pid}/exe")).ok()?; @@ -252,15 +193,6 @@ fn proc_name(pid: i32) -> Option<String> { (!name.is_empty()).then(|| name.to_string()) } -// ── Platform: listening ports ─────────────────────────────────────────────── - -/// TCP listeners owned by any pid in `procs`, via `lsof`. -/// -/// Shelling out rather than reading the socket tables directly: on macOS the -/// only supported route is a private `libproc` fd walk, and on Linux matching -/// `/proc/net/tcp` inodes against every pid's fds costs more syscalls than the -/// subprocess. `lsof` ships with macOS; where it's missing this returns empty, -/// which just hides the row. #[cfg(unix)] fn listening_ports(procs: &[ProcEntry]) -> Vec<PortEntry> { use std::process::{Command, Stdio}; @@ -273,8 +205,6 @@ fn listening_ports(procs: &[ProcEntry]) -> Vec<PortEntry> { .map(|p| p.pid.to_string()) .collect::<Vec<_>>() .join(","); - // `-Fpn`: machine-readable output, pid (`p…`) and name (`n…`) fields only, - // one per line. `-nP` skips DNS and /etc/services lookups — both can block. let out = Command::new("lsof") .args([ "-nP", @@ -304,8 +234,6 @@ fn listening_ports(procs: &[ProcEntry]) -> Vec<PortEntry> { let Some(port) = parse_listen_port(rest) else { continue; }; - // One listener commonly binds both v4 and v6, or several - // addresses on the same port; the panel wants the port once. if ports.iter().any(|e| e.port == port && e.pid == current) { continue; } @@ -331,11 +259,8 @@ fn listening_ports(_procs: &[ProcEntry]) -> Vec<PortEntry> { Vec::new() } -/// The port out of an `lsof -Fn` name field: `*:3000`, `127.0.0.1:8080`, -/// `[::1]:5173`, sometimes with a trailing ` (LISTEN)` despite `-F`. fn parse_listen_port(name: &str) -> Option<u16> { let name = name.split_whitespace().next()?; - // Split on the *last* colon: an IPv6 literal is full of them. let (_, port) = name.rsplit_once(':')?; port.parse().ok() } @@ -359,7 +284,6 @@ mod tests { (200, row(100, "make")), (300, row(200, "cc")), (400, row(100, "vim")), - // A sibling process outside the shell's tree must not appear. (500, row(1, "Finder")), ] .into_iter() @@ -392,9 +316,6 @@ mod tests { #[test] fn walk_survives_a_cycle_in_the_table() { - // Two processes claiming each other as parent — impossible on a live - // kernel, but the table is a non-atomic sweep of pids that can be reused - // mid-walk, so the descent must terminate regardless. let table: HashMap<u32, Row> = [(100, row(200, "a")), (200, row(100, "b"))] .into_iter() .collect(); diff --git a/crates/tty7-core/src/daemon/protocol.rs b/crates/tty7-core/src/daemon/protocol.rs index 0dfe6ead..1d55c557 100644 --- a/crates/tty7-core/src/daemon/protocol.rs +++ b/crates/tty7-core/src/daemon/protocol.rs @@ -1,161 +1,45 @@ -//! Wire protocol between the GUI **client** and the persistent **daemon**. -//! -//! One Unix-domain-socket connection carries exactly one *pane* (a single PTY + -//! child). The GUI opens one connection per terminal view; session listing uses -//! a short-lived control connection. This mirrors the in-process model where one -//! `TerminalView` owns one terminal, so nothing higher up needs multiplexing. -//! -//! ## Framing -//! -//! Every message is a length-prefixed frame: -//! -//! ```text -//! [u32 LE payload_len][u8 kind][payload (payload_len bytes)] -//! ``` -//! -//! The `kind` byte selects the variant. Hot-path variants (`Input`, `Output`, -//! `Snapshot`) carry the raw PTY bytes *verbatim* as the payload — no -//! serialization, no copy beyond the frame. Cold control variants serialize -//! their small structs as JSON, which keeps the wire format easy to evolve and -//! debug without pulling in a binary-codec dependency. -//! -//! Decoding never trusts the length blindly: frames larger than [`MAX_FRAME`] -//! are rejected so a desynced/hostile peer can't make us allocate unboundedly. - use std::io::{self, Read, Write}; use std::path::PathBuf; use serde::{Deserialize, Serialize}; -/// Upper bound on a single frame's payload. A `Snapshot` replays the daemon's -/// byte ring (a few MB by default), so this is generous; anything past it is a -/// protocol desync and we error rather than allocate. pub const MAX_FRAME: usize = 64 * 1024 * 1024; -/// Version of this wire protocol. The daemon outlives the GUI binary, so after -/// an app upgrade the two can be different builds; the GUI asks a running -/// daemon for its version (`ClientMsg::Version`) before reusing it and, on a -/// mismatch, keeps it alive but asks the user whether to keep their sessions -/// on the old dialect or restart the service clean (see -/// `spawn::ensure_running`). -/// -/// Bump this on any change an old peer would *misread*: a repurposed kind -/// byte, a changed payload shape, altered framing. Purely additive changes — -/// a brand-new kind, a new `#[serde(default)]` field — don't need a bump; -/// the existing unknown-kind / missing-field behavior already covers them. -/// -/// A **new variant of an existing enum** is not additive, despite looking it: -/// the enums here carry no `#[serde(other)]` fallback, so an old peer fails -/// the whole `from_json` and its reader treats that as a desync — it drops the -/// connection rather than ignoring the field. That is what earned v2. -/// -/// ## History -/// -/// - **v4** — the daemon serves the machine tree. `tty7 --daemon` now runs -/// the shared `run_daemon`: a control listener (carrying the daemon-owned -/// workspace tree) beside the pane listener. No pane frame changed, so by -/// the letter of the rule above this is additive — but the *service* is -/// not: a v3 daemon has no control socket at all, and a GUI from this -/// build that silently adopted one would connect its control link into the -/// void forever — every window hydrating from a tree that never answers, -/// which renders as empty windows with no error anywhere. The bump routes -/// that meeting into `ensure_running`'s existing keep-or-restart prompt, -/// where "restart the background service" is the fix. -/// - **v3** — the [`control`](super::control) dialect (kinds 60-63) and -/// [`DaemonVersion::features`]. By the rule above this is *additive* and -/// would not earn a bump on its own: a v2 daemon meeting a control frame -/// already reports an unknown kind. The bump buys something else — it makes -/// "does this peer speak control?" a question that can be asked **forwards**. -/// Without it the only probe is to open a control connection and see whether -/// the peer drops it, which costs a round trip, logs a misleading desync -/// error, and is indistinguishable from a genuine desync. `features` then -/// makes this the *last* bump of its kind: further capabilities are announced -/// as strings, not as a higher number. -/// - **v2** — [`RemoteKind::Wsl`]. A v1 client decoding a WSL pane's -/// `RemoteContext` errors out and loses the pane, which only bites on a -/// downgrade (a v2 GUI spawns the pane, a v1 GUI later attaches to it), but -/// loses it silently. The handshake now catches that skew and asks. -/// - **v1** — the dialect at the time versioning landed. pub const PROTOCOL_VERSION: u32 = 4; -/// Capability string for [`DaemonVersion::features`]: this daemon records -/// which workspace each pane was spawned for and reports it in `List`'s -/// [`PaneInfo::owner`], and it understands the [`kind::SPAWN_OWNED`] frame. A -/// client must check for this before sending an owned spawn — the frame kind -/// is unknown to older daemons, which drop the connection over it. pub const FEATURE_PANE_OWNER: &str = "pane-owner"; -/// Reply to `ClientMsg::Version`: the protocol dialect the daemon speaks, plus -/// its crate version for logs/diagnostics. Only `protocol`, `features` and -/// `instance` drive decisions. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct DaemonVersion { pub protocol: u32, - /// The daemon binary's `CARGO_PKG_VERSION`. Display only. #[serde(default)] pub build: String, - /// Fine-grained capability bits — see [`super::control::feature`]. - /// - /// `#[serde(default)]`, so a pre-v3 daemon's reply still decodes (as an - /// empty list, which is the truth about it). This exists so that a - /// capability added after v3 does **not** need another version bump, and - /// therefore does not need to provoke the "Restart Daemon?" prompt for - /// every user whose daemon happens to predate it. #[serde(default)] pub features: Vec<String>, - /// Identity of this daemon *process*, minted once at startup — the same - /// identity the control hello announces - /// ([`ControlHelloOk::instance`](crate::daemon::control::ControlHelloOk::instance)), - /// which is what reconnect logic actually consults to tell "the link - /// blinked" from "a different process answers now". PTYs die with the - /// process, so a changed instance means every previously live pane is - /// gone; the machine tree records the same fact per pane (`load_machine` - /// clears every `live` flag on open), and a daemon carrying a tree seeds - /// its pane ids *past* everything the tree names rather than restarting - /// from 1, so a stale id can never alias a new shell. Empty for daemons - /// that predate the field — "unknown", never "restarted". #[serde(default)] pub instance: String, } impl DaemonVersion { - /// What *this* build answers with. - /// - /// A single constructor so the capability list can't drift between the - /// daemon's reply and anything else that claims to describe this build. pub fn current() -> DaemonVersion { DaemonVersion { protocol: PROTOCOL_VERSION, build: env!("CARGO_PKG_VERSION").to_string(), - // This reply describes the *pane* socket only. The control - // dialect lives on the daemon's separate control socket, whose - // own `ControlHelloOk` announces `control` / `host-rpc` / - // `machine-tree` for itself; claiming them here would say the - // pane socket speaks frames it does not. - // - // `pane-owner` *is* a pane-protocol capability, so every process - // serving panes from this build advertises it. features: vec![FEATURE_PANE_OWNER.to_string()], instance: process_instance().to_string(), } } - /// Whether this peer advertises `name`. pub fn has_feature(&self, name: &str) -> bool { self.features.iter().any(|f| f == name) } } -/// This process's pane-daemon identity: a uuid minted on first use and stable -/// for the process lifetime. See [`DaemonVersion::instance`] for why it exists. pub fn process_instance() -> &'static str { static INSTANCE: std::sync::OnceLock<String> = std::sync::OnceLock::new(); INSTANCE.get_or_init(|| uuid::Uuid::new_v4().to_string()) } -/// Terminal geometry shared by spawn/attach/resize. Cell pixel size travels too -/// so the daemon can set an accurate `TIOCSWINSZ` (`ws_xpixel`/`ws_ypixel`), -/// which some full-screen apps read. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub struct WinSize { pub cols: u16, @@ -164,33 +48,15 @@ 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>, - /// True when `args` were authored by tty7's own shell discovery - /// (`core::shells`) rather than by the user, and so may be replaced by - /// shell integration — Git Bash's `-i -l` is tty7's way of saying "an - /// interactive login shell", which `setup_bash`'s `--rcfile … -i` plus its - /// replayed login-file chain expresses differently but equivalently. - /// User-configured args get no such liberty; see - /// `daemon::shell_integration::setup`'s `has_custom_args`. - /// - /// Defaults to `false` on the wire so an older client's frame — which can - /// only carry user-configured args — keeps them untouched. #[serde(default)] pub args_are_tty7_defaults: bool, } -/// Whether a short `ssh` option flag consumes the following argument as its -/// value. Used by the GUI's typed-connect parser to skip an option's value while -/// hunting for the destination token. pub fn ssh_option_takes_value(flag: char) -> bool { matches!( flag, @@ -217,7 +83,6 @@ pub fn ssh_option_takes_value(flag: char) -> bool { ) } -/// Metadata for one live pane, returned by `List` for session restore / pickers. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct PaneInfo { pub pane_id: u64, @@ -225,56 +90,23 @@ pub struct PaneInfo { pub cwd: Option<PathBuf>, #[serde(default)] pub title: String, - /// False once the child has exited but the pane lingers (so a client can - /// still read its final scrollback). pub alive: bool, - /// The workspace this pane was spawned for (a `WorkspaceId` uuid, as a - /// string), when the spawning client said ([`ClientMsg::Spawn`]'s `owner`). - /// `None` for panes spawned by older clients or through the legacy spawn - /// kinds. Restore uses this to refuse re-attaching a pane to a workspace - /// that never owned it — the failure mode where one workspace's saved ids - /// silently pick up another's shells. #[serde(default, skip_serializing_if = "Option::is_none")] pub owner: Option<String>, } -/// A pane whose filesystem is not the host's — either a remote session, or a -/// local one behind a boundary the host's own tools can't follow (WSL). -/// -/// The common consequence, whatever the kind, is that the pane's cwd names a -/// path in *that* namespace: see `TerminalView::local_cwd`, which is what keeps -/// a local `git` / `read_dir` / spawn away from it. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RemoteContext { pub kind: RemoteKind, - /// Original foreground argv. Kept so follow-up operations can preserve ssh - /// config flags such as `-F`, `-p`, and `-J` rather than guessing. Empty - /// for kinds that aren't detected from a foreground process. pub argv: Vec<String>, - /// The destination token: `host`, `user@host`, or ssh config alias for the - /// ssh kinds; the distro name for [`RemoteKind::Wsl`]. pub target: String, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum RemoteKind { - /// A foreground `ssh` process typed into a normal shell, detected from the - /// local process table. Status/label only — it has no tty7-owned connection, - /// so forwarding / SFTP don't apply to it. Ssh, - /// A pane backed by the daemon's native russh session engine - /// (`daemon::ssh`). Forwarding / SFTP reach the connection through the - /// in-memory registry. NativeSsh, - /// A `wsl.exe` pane: not remote in the network sense, but its shell lives - /// inside a distro with its own filesystem namespace, so a cwd it reports - /// (`/home/me/proj`) means nothing to the Windows-side host — and on - /// Windows is *drive-relative* rather than invalid, so it silently resolves - /// to `C:\home\me\proj`. Set at spawn time from the `ShellSpec`, not - /// detected from the process table. Nothing SSH-specific applies to it: - /// callers that mean "an SSH pane" must test the kind, not merely that a - /// `RemoteContext` is present. Wsl, } @@ -285,69 +117,38 @@ pub struct LoopbackForwardRequest { pub remote_port: u16, } -// --------------------------------------------------------------------------- -// Workspace-scoped control requests (M7). -// -// A *remote workspace* has no pane on this daemon: its panes live on the remote -// `tty7-server`, and the only thing this side owns is the `SshConnection` the -// workspace's routed link rides. So every pane-addressed control request above -// (`SftpList { pane_id }`, `AddForward { pane_id }`, …) is unaddressable for it. -// -// Rather than a parallel variant per operation, the workspace form is one -// envelope: the *only* thing that differs is how the connection is found, and -// the daemon resolves that once, up front. Replies reuse the existing -// `DaemonMsg` variants verbatim, so no daemon-space kind is spent. -// --------------------------------------------------------------------------- - -/// A control request that runs on a **remote workspace's** SSH connection -/// rather than a pane's. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct WorkspaceRequest { - /// Which workspace the request is *attributed* to. Two workspaces on the - /// same machine share one `SshConnection` but own their forwards - /// separately, so this is not derivable from `spec`. pub workspace: crate::core::session::WorkspaceId, - /// Names the machine. **Secret-free** ([`NativeSshSpec::without_secrets`]): - /// the daemon only ever *looks up* an already-authenticated connection with - /// this key and never connects, so nothing here needs to authenticate. pub spec: Box<NativeSshSpec>, - /// The pane the caller is rendering the answer under, stamped into - /// `ManagedForward::pane_id` / `SftpJobProgress::pane_id` so the GUI's - /// per-pane panels can filter rows they asked for. Display only — it is - /// *not* what the forward is owned by, and a workspace forward outlives it. pub view_pane: u64, pub op: WorkspaceOp, } -/// The operation half of a [`WorkspaceRequest`]. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum WorkspaceOp { - /// ⌘/Ctrl-clicked `localhost:PORT` — ensure an on-demand local forward to - /// `remote_host:remote_port` and reply `LoopbackForward { local_port }`. EnsureLoopback { remote_host: String, remote_port: u16, }, - /// Establish a managed forward owned by the workspace; replies `ForwardList`. - AddForward { rule: SshForwardRule }, - /// Tear one workspace forward down by id; replies `ForwardList`. - RemoveForward { forward_id: u64 }, - /// The workspace's managed forwards; replies `ForwardList`. + AddForward { + rule: SshForwardRule, + }, + RemoveForward { + forward_id: u64, + }, ListForwards, - /// Drop every forward the workspace owns (the workspace was closed). Replies - /// with the — now empty — `ForwardList`. TeardownForwards, - /// List a remote directory over the workspace's SFTP session; replies - /// `SftpEntries`. - SftpList { path: String }, - /// A one-shot SFTP operation on the workspace's session; replies - /// `SftpOpResult`. - SftpOp { op: SftpOp }, - /// Start an upload/download on the workspace's session; replies - /// `SftpTransferStarted`. `spec.pane_id` is ignored in favour of `view_pane`. - SftpTransferStart { spec: SftpTransferSpec }, - /// Poll the workspace's transfer jobs; replies `SftpTransferProgress`. + SftpList { + path: String, + }, + SftpOp { + op: SftpOp, + }, + SftpTransferStart { + spec: SftpTransferSpec, + }, SftpTransferList, } @@ -372,23 +173,6 @@ pub struct LoopbackForwardInfo { pub idle_secs: u64, } -// --------------------------------------------------------------------------- -// Native SSH (russh) session engine — wire types (Workstream 2). -// -// A `NativeSshSpec` is everything the daemon needs to establish one russh -// connection and open a shell channel on it. The GUI (WS1/WS6) resolves a -// stored profile — including any OS-keychain secrets and any jump-host profile -// references — into this fully self-contained spec before sending it; the daemon -// never reads the keychain or the profile store. Secrets (`password`, -// `key_passphrases`) ride the *local* daemon socket exactly once and are held -// only in memory. `NativeSshSpec` has a hand-written `Debug` that redacts them, -// so it is safe to log a spec for diagnostics. -// --------------------------------------------------------------------------- - -/// Which authentication methods the daemon may attempt. `Auto` tries all in the -/// Tabby-derived order (none → publickey → agent → password → keyboard-interactive); -/// the others restrict attempts to that single family (plus the mandatory leading -/// `none` probe, which only learns the server's advertised methods). #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "kebab-case")] pub enum SshAuthMode { @@ -401,17 +185,11 @@ pub enum SshAuthMode { KeyboardInteractive, } -/// The transport under the SSH connection. Exactly one is used; `Command` and the -/// proxies are mutually exclusive with each other and with a jump host (which is -/// carried separately on `NativeSshSpec::jump`). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "kebab-case")] pub enum SshProxy { #[default] None, - /// A `ProxyCommand`-style program whose stdio is the transport. The daemon - /// substitutes `%h`/`%p` (and `%r`) tokens itself before spawning — the gap - /// Tabby left open (#11058). Command(String), Socks { host: String, @@ -423,8 +201,6 @@ pub enum SshProxy { }, } -/// Per-connection algorithm preference lists. Empty list = russh defaults (with -/// tty7's Tabby-derived preference applied where russh supports the entry). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] pub struct SshAlgorithms { #[serde(default)] @@ -439,8 +215,6 @@ pub struct SshAlgorithms { pub compression: Vec<String>, } -/// A preconfigured port-forward carried on the spec. WS2 only carries the data; -/// establishing forwards is WS4's job (see the seam in `daemon::ssh`). #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum SshForwardKind { @@ -462,21 +236,6 @@ pub struct SshForwardRule { pub description: Option<String>, } -// --------------------------------------------------------------------------- -// SFTP (Workstream 5) — wire types. -// -// SFTP rides a native-SSH pane's already-authenticated russh connection: the -// daemon opens an SFTP-subsystem channel on the pane's connection (reused across -// panes sharing it) and answers directory listings / file operations / transfer -// jobs. All requests carry the `pane_id`; the daemon resolves it to the pane's -// `SshConnection` through the registry. Only native-SSH panes have one — a PTY -// pane (or a foreground `ssh` typed in a shell) replies with an `Error`. -// --------------------------------------------------------------------------- - -/// The classification of one remote directory entry. Symlinks are reported as -/// `Symlink`; the daemon additionally follow-stats the target so the GUI can tell -/// a link-to-directory (navigable) from a link-to-file (downloadable) via -/// [`SftpEntry::target_is_dir`]. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum SftpEntryKind { @@ -485,77 +244,34 @@ pub enum SftpEntryKind { Symlink, } -/// One entry in a remote directory listing (or a single `Stat` result). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct SftpEntry { pub name: String, pub kind: SftpEntryKind, #[serde(default)] pub size: u64, - /// Modification time in whole seconds since the Unix epoch (0 if unknown). #[serde(default)] pub mtime: u64, - /// Unix mode bits (permissions + type), 0 if the server didn't report them. #[serde(default)] pub permissions: u32, - /// For a `Symlink`, whether the (followed) target is a directory — lets the - /// GUI decide navigate-vs-download without another round-trip. Always false - /// for non-symlinks. #[serde(default)] pub target_is_dir: bool, } -/// A metadata / namespace operation on the remote filesystem. Recursive delete -/// (`RemoveDir`) recurses daemon-side. `Stat`/`Readlink`/`Realpath` return data in the -/// [`SftpOpResult`]; the rest just succeed or fail. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum SftpOp { - /// Follow-symlink stat of a single path. - Stat { - path: String, - }, - Mkdir { - path: String, - }, - /// Create a new empty file, failing if one already exists at `path`. - CreateFile { - path: String, - }, - RemoveFile { - path: String, - }, - /// Recursive directory delete (daemon walks + removes children first). - RemoveDir { - path: String, - }, - Rename { - from: String, - to: String, - }, - /// Set the permission (mode) bits of `path`. - Chmod { - path: String, - mode: u32, - }, - /// Read a symlink's target path (returned as [`SftpOpResult::Link`]). - Readlink { - path: String, - }, - /// Resolve `path` against the SFTP session's own working directory and return - /// it absolute (SFTP's REALPATH), as [`SftpOpResult::Link`]. - /// - /// Exists for one job: `Realpath { path: "." }` is how the browser learns the - /// login directory. A remote shell only reports its cwd if tty7's shell - /// integration is installed over there, which on a host you just connected to - /// it usually isn't — and `/` is a poor place to open a file browser. - Realpath { - path: String, - }, + Stat { path: String }, + Mkdir { path: String }, + CreateFile { path: String }, + RemoveFile { path: String }, + RemoveDir { path: String }, + Rename { from: String, to: String }, + Chmod { path: String, mode: u32 }, + Readlink { path: String }, + Realpath { path: String }, } -/// The reply to a [`SftpOp`]. `Done` for side-effecting ops; `Stat`/`Link` carry -/// the queried data; `Error` carries a human-readable failure reason. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum SftpOpResult { @@ -565,30 +281,23 @@ pub enum SftpOpResult { Error(String), } -/// Transfer direction for a background SFTP job. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum SftpTransferKind { - /// local → remote. Upload, - /// remote → local. Download, } -/// The recipe for a background transfer job. `local` is a path in the *daemon -/// process's* filesystem (same user); `remote` is an absolute remote path. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct SftpTransferSpec { pub pane_id: u64, pub kind: SftpTransferKind, pub local: PathBuf, pub remote: String, - /// Recurse into directories (create dirs on the far side). #[serde(default)] pub recursive: bool, } -/// Lifecycle state of a transfer job. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum SftpJobState { @@ -598,47 +307,33 @@ pub enum SftpJobState { Cancelled, } -/// A snapshot of one transfer job's progress, returned by the poll-based -/// `SftpTransferList` request while the tray is visible. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct SftpJobProgress { pub job_id: u64, pub pane_id: u64, pub kind: SftpTransferKind, pub state: SftpJobState, - /// The path currently being transferred (a leaf within a recursive job). #[serde(default)] pub current: String, #[serde(default)] pub bytes_done: u64, #[serde(default)] pub bytes_total: u64, - /// Populated only when `state == Error`. #[serde(default)] pub error: Option<String>, - /// Display labels (the job's endpoints). #[serde(default)] pub local: String, #[serde(default)] pub remote: String, } -/// Runtime status of a live managed forward, surfaced to the GUI per row. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum ForwardStatus { - /// The forward's listener (Local/Dynamic) or remote binding (Remote) is up. Listening, - /// The forward failed to come up (bind conflict, remote request denied, …). - /// The string is a human-readable reason with no secrets. Error(String), } -/// One established managed forward on a native-SSH pane's connection (WS4). This -/// is the runtime counterpart of a [`SshForwardRule`]: it carries a daemon-issued -/// `id` (used to remove it), the pane it is attributed to (for per-pane listing), -/// the *resolved* bind port (a `bind_port` of 0 resolves to the OS-assigned port), -/// and a live `status`. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ManagedForward { pub id: u64, @@ -655,24 +350,15 @@ pub struct ManagedForward { pub status: ForwardStatus, } -/// One process running under a pane's shell, for the details panel's process -/// list. `depth` is hops from the shell (the shell itself is 0), which is all the -/// UI needs to indent the tree — sending the parent pid would make the client -/// rebuild a hierarchy the daemon already walked. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ProcEntry { pub pid: u32, pub name: String, pub depth: u8, - /// Whether this process (or its group) currently owns the terminal — the one - /// the user is actually looking at. #[serde(default)] pub foreground: bool, } -/// A TCP port a pane's process tree is listening on. The pane that started a dev -/// server is exactly the context in which "which port is this on?" gets asked, so -/// the answer belongs next to the process list rather than in a global inspector. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct PortEntry { pub port: u16, @@ -680,12 +366,9 @@ pub struct PortEntry { pub name: String, } -/// Reply to `QueryProcs`: what a pane is running, and what it's listening on. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct PaneProcs { - /// Depth-first from the shell, so rendering in order gives a readable tree. pub procs: Vec<ProcEntry>, - /// Ascending by port; deduped, since one listener can bind several addresses. pub ports: Vec<PortEntry>, } @@ -697,8 +380,6 @@ fn default_true() -> bool { true } -/// The fully-resolved recipe for one native SSH connection + shell. See the -/// module-level comment above for the trust/secret model. #[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct NativeSshSpec { pub host: String, @@ -706,30 +387,21 @@ pub struct NativeSshSpec { pub user: String, pub auth_mode: SshAuthMode, - /// Private-key paths to try, in order. `%h`/`%r` expand to host/user. #[serde(default)] pub identity_files: Vec<String>, #[serde(default)] pub agent_forward: bool, - /// Cleartext password, pre-resolved by the GUI from the keychain. SECRET. #[serde(default)] pub password: Option<String>, - /// Passphrases for encrypted identity files, keyed by identity-file path (as - /// listed in `identity_files`). Pre-resolved by the GUI. SECRET. #[serde(default)] pub key_passphrases: Option<std::collections::HashMap<String, String>>, #[serde(default)] pub proxy: SshProxy, - /// Jump host: the GUI resolves a profile reference into a nested spec, so a - /// multi-level chain is a chain of `jump` boxes. The daemon opens a - /// `direct-tcpip` channel on the (recursively established) jump connection and - /// uses it as this connection's transport. #[serde(default)] pub jump: Option<Box<NativeSshSpec>>, - /// Preconfigured forwards — carried only (WS4 establishes them). #[serde(default)] pub forwards: Vec<SshForwardRule>, @@ -742,7 +414,6 @@ pub struct NativeSshSpec { #[serde(default)] pub algorithms: SshAlgorithms, - /// X11 forwarding — carried only (implementing X11 channels is deferred). #[serde(default)] pub x11: bool, @@ -752,25 +423,11 @@ pub struct NativeSshSpec { pub verify_host_keys: bool, #[serde(default)] pub skip_banner: bool, - /// Bootstrap tty7's shell integration (OSC 133 prompt marks + cwd - /// reporting) into the remote shell — what powers the inline line editor, - /// exit-code marks and cwd tracking for this pane. See - /// [`crate::daemon::shell_integration::remote`]. - /// - /// On by default, and a remote we can't integrate declines itself (the - /// probe answers "unknown shell" and the session starts bare), so this is - /// for the case the probe can't detect: a remote where the bootstrap *would* - /// work but the user would rather it didn't — a bash host where the - /// login-shell → `--rcfile` swap upsets something, or simply a host they - /// want left exactly as stock ssh leaves it. #[serde(default = "default_true")] pub shell_integration: bool, - /// Lines sent verbatim (each + `\n`) to the shell channel after it starts, - /// sequentially, with no expect-logic. #[serde(default)] pub login_script: Vec<String>, - /// UI labeling only — never affects connection behavior. #[serde(default)] pub display_name: Option<String>, #[serde(default)] @@ -778,11 +435,7 @@ pub struct NativeSshSpec { } impl NativeSshSpec { - /// A clone with all secrets stripped (`password`, `key_passphrases`), and the - /// jump chain stripped recursively. This is the form that is safe to persist - /// (e.g. in `core::session` for native-SSH pane respawn) — the daemon - /// re-resolves secrets from the GUI/keychain on the next connect. - #[allow(dead_code)] // consumed by WS6 when persisting native-SSH panes + #[allow(dead_code)] pub fn without_secrets(&self) -> NativeSshSpec { NativeSshSpec { password: None, @@ -794,9 +447,6 @@ impl NativeSshSpec { } impl std::fmt::Debug for NativeSshSpec { - /// Redacts secrets so a spec can be logged. `password` / `key_passphrases` - /// collapse to a presence marker; the nested `jump` spec redacts recursively - /// through this same impl. fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("NativeSshSpec") .field("host", &self.host) @@ -829,29 +479,16 @@ impl std::fmt::Debug for NativeSshSpec { } } -/// One row for the "SSH → Known hosts" management view (WS3): a single trusted -/// (or revoked / CA) `known_hosts` entry. Hashed hosts surface their raw `|1|…` -/// field (the hash can't be reversed to a hostname). Listed daemon-side because -/// the daemon owns file access on the native path. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct KnownHostEntry { - /// Raw host field as stored (`example.com`, `[h]:2222`, a comma list, or a - /// `|1|salt|hash` hashed token). pub host: String, - /// `"@cert-authority"` / `"@revoked"` when the line carries a marker. #[serde(default)] pub marker: Option<String>, - /// Key algorithm string (`ssh-ed25519`, `ecdsa-sha2-nistp256`, …). pub key_type: String, - /// `SHA256:…` fingerprint, or `"?"` for an entry whose blob doesn't parse. pub fingerprint_sha256: String, - /// Stable identity used to delete this exact entry. pub id: KnownHostId, } -/// Content-based identity of one `known_hosts` entry (host field + key type + -/// blob), so a delete survives unrelated edits between list and delete rather -/// than relying on a fragile line index. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct KnownHostId { pub host: String, @@ -859,18 +496,12 @@ pub struct KnownHostId { pub keyblob: String, } -/// One prompt in a keyboard-interactive challenge (RFC 4256). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct KiPrompt { pub text: String, - /// Whether the user's keystrokes should be echoed (false for passwords). pub echo: bool, } -/// An interactive decision the daemon needs from the GUI during a native-SSH -/// spawn. Sent as `DaemonMsg::AuthPrompt` over the pane's own connection, before -/// any `Output`; the daemon blocks that auth/host-key step until the matching -/// `ClientMsg::AuthResponse` arrives (or a timeout fails it cleanly). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum AuthPromptKind { Password { @@ -899,24 +530,16 @@ pub enum AuthPromptKind { fingerprint_sha256: String, old_fingerprint_sha256: String, }, - /// A server auth banner. Fire-and-forget: no response is expected or awaited. Banner { text: String, }, } -/// The GUI's reply to an [`AuthPromptKind`]. `Secret`/`Secrets` carry cleartext -/// (a password, a passphrase, or keyboard-interactive answers); the hand-written -/// `Debug` redacts them. #[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum AuthResponse { Secret(String), Secrets(Vec<String>), - HostKeyDecision { - accept: bool, - remember: bool, - }, - /// The user dismissed the prompt; the daemon fails the auth step cleanly. + HostKeyDecision { accept: bool, remember: bool }, Cancelled, } @@ -935,8 +558,6 @@ impl std::fmt::Debug for AuthResponse { } } -/// Progress of a native-SSH spawn, sent as `DaemonMsg::SshStatus` so the GUI can -/// show a status line while the connection comes up (or explain a failure). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum SshPhase { Connecting, @@ -945,202 +566,117 @@ pub enum SshPhase { Failed { reason: String }, } -/// Messages the GUI client sends to the daemon. #[derive(Debug, Clone, PartialEq, Eq)] 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. - /// `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>, - /// The workspace this pane will belong to (a `WorkspaceId` uuid, as a - /// string). Rides the [`kind::SPAWN_OWNED`] frame, which only a daemon - /// advertising [`FEATURE_PANE_OWNER`] understands — callers leave this - /// `None` for older daemons and the spawn goes out on the legacy kinds, - /// byte-for-byte as before. owner: Option<String>, }, - /// 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 }, - /// Raw bytes typed/pasted into the pane. Hot path — payload is verbatim. + Attach { + pane_id: u64, + size: WinSize, + }, Input(Vec<u8>), - /// The client's view changed size; resize the PTY (`SIGWINCH` to the child). Resize(WinSize), - /// Disconnect from the pane without killing it (it keeps running detached). Detach, - /// Terminate a pane's child and forget it. - Kill { pane_id: u64 }, - /// Ask for the list of live panes (control connection). + Kill { + pane_id: u64, + }, List, - /// Shut the whole daemon down: hang up every pane's child, then exit the - /// process. A control-connection message the GUI sends to force a fresh - /// daemon — e.g. so a newly granted macOS permission (Full Disk Access) takes - /// effect, which a long-lived daemon process can't otherwise see. Ends every - /// running session, so the caller confirms with the user first. Shutdown, - /// Ensure a local SSH port-forward exists for a loopback URL printed by a - /// remote session in `pane_id`. Control-connection message; daemon replies - /// with `LoopbackForward` or `Error`. EnsureLoopbackForward(LoopbackForwardRequest), - /// Ask for the daemon's active SSH loopback port-forwards. ListLoopbackForwards, - /// Close one active SSH loopback port-forward. CloseLoopbackForward(LoopbackForwardId), - /// Create a new pane backed by the daemon's native russh session engine. - /// Like `Spawn`, but the pane's byte source is an SSH shell channel rather - /// than a local PTY. `spec` is fully self-contained (see [`NativeSshSpec`]). - /// This connection then becomes that pane's stream, and also carries the - /// interactive auth/host-key exchange (`AuthPrompt`/`AuthResponse`). SpawnNativeSsh { cwd: Option<PathBuf>, size: WinSize, spec: Box<NativeSshSpec>, }, - /// The GUI's reply to a `DaemonMsg::AuthPrompt` with a matching `request_id`. - /// Delivered on the pane's own connection while its native-SSH spawn is still - /// authenticating. AuthResponse { request_id: u64, response: AuthResponse, }, - /// List the OpenSSH `known_hosts` entries for the "SSH → Known hosts" settings - /// section (control connection; daemon replies with `KnownHostsList`). ListKnownHosts, - /// Delete one `known_hosts` entry, then reply with the refreshed list. DeleteKnownHost(KnownHostId), - /// List a remote directory over the pane's SFTP session (control connection). - /// Daemon replies `SftpEntries` or `Error`. - SftpList { pane_id: u64, path: String }, - /// A one-shot SFTP filesystem operation (mkdir/remove/rename/chmod/stat/…) on - /// the pane's SFTP session. Daemon replies `SftpOpResult`. - SftpOp { pane_id: u64, op: SftpOp }, - /// Start a background upload/download job on the pane's SFTP session. Daemon - /// replies `SftpTransferStarted { job_id }` (or `Error`). + SftpList { + pane_id: u64, + path: String, + }, + SftpOp { + pane_id: u64, + op: SftpOp, + }, SftpTransferStart(SftpTransferSpec), - /// Cancel a running transfer job. Daemon replies with the current - /// `SftpTransferProgress` list. - SftpTransferCancel { job_id: u64 }, - /// Poll the transfer jobs for a pane (the GUI polls while its tray is - /// visible). Daemon replies with a `SftpTransferProgress` list. - SftpTransferList { pane_id: u64 }, - /// Establish a new managed port-forward (Local/Remote/Dynamic) on the native-SSH - /// pane `pane_id`'s connection (WS4). Control-connection message; the daemon - /// replies with a `ForwardList` reflecting the pane's forwards after the add. - AddForward { pane_id: u64, rule: SshForwardRule }, - /// Tear down one managed forward by its daemon-issued id. Control-connection - /// message; the daemon replies with the pane's remaining `ForwardList`. - RemoveForward { pane_id: u64, forward_id: u64 }, - /// Ask for the managed forwards attributed to `pane_id`. Control-connection - /// message; the daemon replies with a `ForwardList`. - ListForwards { pane_id: u64 }, - /// One-shot query for a pane's process tree and listening ports, over a - /// short-lived control connection; the daemon replies with `PaneProcs`. - /// - /// Deliberately pull-based, unlike `Cwd`/`Agent` which the daemon pushes: - /// walking the process table and probing sockets costs far more than sniffing - /// an OSC sequence, and the answer is only ever looked at while the details - /// panel's Info tab is open. Pushing it on a timer would burn that cost for - /// every pane, forever, to feed a view that's usually closed. - QueryProcs { pane_id: u64 }, - /// A control request scoped to a **remote workspace's** SSH connection - /// instead of a pane's. See [`WorkspaceRequest`]. + SftpTransferCancel { + job_id: u64, + }, + SftpTransferList { + pane_id: u64, + }, + AddForward { + pane_id: u64, + rule: SshForwardRule, + }, + RemoveForward { + pane_id: u64, + forward_id: u64, + }, + ListForwards { + pane_id: u64, + }, + QueryProcs { + pane_id: u64, + }, OnWorkspace(Box<WorkspaceRequest>), - /// Ask which protocol version the daemon speaks (control connection); the - /// daemon replies `Version`. A daemon that predates versioning doesn't know - /// this kind and drops the connection instead of replying — the client - /// reads that hangup as "older than every versioned daemon" and treats it - /// like any other mismatch: keep it, ask the user (see - /// `spawn::ensure_running`). Version, } -/// Messages the daemon sends back to the GUI client. #[derive(Debug, Clone, PartialEq, Eq)] pub enum DaemonMsg { - /// Result of `Spawn`: the id of the freshly created pane. - Spawned { pane_id: u64 }, - /// The geometry the next `Snapshot`'s bytes were recorded under, sent - /// immediately before it so the client can size its local grid to match - /// before replaying. Replaying at any other width mis-wraps history and - /// lands relative cursor motion on the wrong rows. The attach replay is a - /// `Size` → `Snapshot` pair per geometry segment of the pane's ring - /// (oldest first); the last pair carries the PTY's current size. + Spawned { + pane_id: u64, + }, Size(WinSize), - /// One segment of the pane's byte-ring replay, sent right after - /// `Attach`/`Spawn` (paired with its `Size`) so the client's local - /// emulator rebuilds the current screen + scrollback. Snapshot(Vec<u8>), - /// Live PTY output tail. Hot path — payload is verbatim. Output(Vec<u8>), - /// The foreground cwd, sniffed daemon-side from OSC 7 / proc lookup. Cwd(PathBuf), - /// Shell prompt/command state, sniffed daemon-side from OSC 133. Prompt { active: bool, at_prompt: bool, last_exit: Option<i32>, }, - /// The pane's child exited; `code` is its status when known. - Exited { code: Option<i32> }, - /// Reply to `List`. + Exited { + code: Option<i32>, + }, PaneList(Vec<PaneInfo>), - /// The foreground remote context, or `None` when the pane is local / unknown. RemoteContext(Option<RemoteContext>), - /// The third-party CLI coding agent currently running in the foreground - /// (Claude Code, Codex, Gemini, …), or `None` when no known agent is running. - /// Detected daemon-side from the foreground `argv` — see - /// [`crate::core::cli_agent`]. Agent(Option<crate::core::cli_agent::CLIAgent>), - /// The rich per-session agent status (idle / working / waiting / done + - /// native session id), sniffed daemon-side from the pane's OSC stream - /// (tty7's sentinel events, with an opaque OSC 9/777 fallback) — see - /// [`crate::core::cli_agent::AgentSessionState`]. `None` clears it (the - /// agent exited). AgentStatus(Option<crate::core::cli_agent::AgentSessionState>), - /// Reply to `EnsureLoopbackForward`. LoopbackForward(LoopbackForward), - /// Reply to `ListLoopbackForwards` and `CloseLoopbackForward`. LoopbackForwardList(Vec<LoopbackForwardInfo>), - /// A native-SSH spawn needs an interactive decision from the GUI (password, - /// passphrase, keyboard-interactive answers, or a host-key confirmation). - /// Sent before `Output` starts flowing; the daemon blocks the auth step until - /// a `ClientMsg::AuthResponse` with the same `request_id` arrives. A `Banner` - /// prompt is fire-and-forget (no response awaited). AuthPrompt { request_id: u64, prompt: AuthPromptKind, }, - /// Progress of a native-SSH spawn (connect/auth/connected/failed). - SshStatus { phase: SshPhase }, - /// Reply to `ListKnownHosts` and `DeleteKnownHost`. + SshStatus { + phase: SshPhase, + }, KnownHostsList(Vec<KnownHostEntry>), - /// Reply to `SftpList`: the directory's entries (unsorted; the GUI sorts). SftpEntries(Vec<SftpEntry>), - /// Reply to `SftpOp`. SftpOpResult(SftpOpResult), - /// Reply to `SftpTransferStart`: the id of the freshly created job. - SftpTransferStarted { job_id: u64 }, - /// Reply to `SftpTransferList` / `SftpTransferCancel`: progress snapshots. + SftpTransferStarted { + job_id: u64, + }, SftpTransferProgress(Vec<SftpJobProgress>), - /// Reply to `AddForward` / `RemoveForward` / `ListForwards`: the managed - /// forwards currently attributed to the requested pane (WS4). ForwardList(Vec<ManagedForward>), - /// Reply to `QueryProcs`. Procs(PaneProcs), - /// Reply to `Version`. Version(DaemonVersion), - /// A request failed (e.g. `Attach` to an unknown/dead pane id). Error(String), } -// Kind bytes. Client and daemon have independent spaces (a connection always -// knows which direction it is reading), so the small overlaps are intentional. mod kind { - // Client -> daemon pub const SPAWN: u8 = 1; pub const ATTACH: u8 = 2; pub const INPUT: u8 = 3; @@ -1149,66 +685,27 @@ mod kind { pub const KILL: u8 = 6; pub const LIST: u8 = 7; pub const SHUTDOWN: u8 = 8; - /// `Spawn` with an explicit, non-managed 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. pub const SPAWN_SHELL: u8 = 9; pub const ENSURE_LOOPBACK_FORWARD: u8 = 10; pub const LIST_LOOPBACK_FORWARDS: u8 = 11; pub const CLOSE_LOOPBACK_FORWARD: u8 = 12; - // 13 (was `SPAWN_MANAGED_SSH`, the system-ssh compat funnel) is retired: all - // SSH goes through the native russh engine (`SPAWN_NATIVE_SSH`). - /// `SpawnNativeSsh` — the native russh session engine. A brand-new kind, so a - /// daemon that predates WS2 rejects it (unknown kind → error) rather than - /// mis-spawning; a native-SSH pane must never silently fall back to anything. pub const SPAWN_NATIVE_SSH: u8 = 14; - /// `AuthResponse` — the GUI's reply to an `AUTH_PROMPT`. pub const AUTH_RESPONSE: u8 = 15; - /// `ListKnownHosts` — control request for the known_hosts management view. pub const LIST_KNOWN_HOSTS: u8 = 16; - /// `DeleteKnownHost` — remove one known_hosts entry. pub const DELETE_KNOWN_HOST: u8 = 17; - // (WS3 reserves 15-17, WS4 reserves 20-24.) SFTP (WS5) owns 30-36. pub const SFTP_LIST: u8 = 30; pub const SFTP_OP: u8 = 31; pub const SFTP_TRANSFER_START: u8 = 32; pub const SFTP_TRANSFER_CANCEL: u8 = 33; pub const SFTP_TRANSFER_LIST: u8 = 34; - // (16–19 reserved: WS3 auth extensions.) - /// `AddForward` — establish a managed port-forward (WS4). pub const ADD_FORWARD: u8 = 20; - /// `RemoveForward` — tear down one managed forward by id (WS4). pub const REMOVE_FORWARD: u8 = 21; - /// `ListForwards` — list a pane's managed forwards (WS4). pub const LIST_FORWARDS: u8 = 22; - /// `Version` — protocol-version handshake. 40 sits clear of every reserved - /// range above (WS3 16–19, WS4 20–24, SFTP 30–36). pub const VERSION: u8 = 40; - /// `QueryProcs` — a pane's process tree + listening ports, for the details - /// panel. 50 sits clear of every range above and of `VERSION`. pub const QUERY_PROCS: u8 = 50; - // 51 is taken: `daemon::router::ROUTE_KIND`, the route header that hands a - // connection to a remote `tty7-server`. It is defined there rather than - // here because this module is private and the router must not become a - // reason to open it — but the number is spent either way. - /// `OnWorkspace` — a control request on a remote workspace's SSH connection - ///. 52 is the next number clear of every range above, of the - /// router's 51, and of the retired 13; the contract's control connection - /// reserves 60-63, which this stays below. pub const ON_WORKSPACE: u8 = 52; - /// `Spawn` carrying a [`super::OwnedSpawn`] **struct** payload — the spawn - /// that also names the workspace owning the pane. A brand-new kind for the - /// same reason `SPAWN_SHELL` was one: the legacy spawn payloads are - /// positional tuples an old daemon cannot grow, so a client only sends this - /// to a daemon advertising [`super::FEATURE_PANE_OWNER`] and falls back to - /// the legacy kinds otherwise. The struct payload is the lesson learned — - /// any further spawn field rides this kind with `#[serde(default)]`, no new - /// number needed. 53 stays below the control connection's 60-63 reserve. pub const SPAWN_OWNED: u8 = 53; - // Daemon -> client pub const SPAWNED: u8 = 1; pub const SNAPSHOT: u8 = 2; pub const OUTPUT: u8 = 3; @@ -1221,32 +718,20 @@ mod kind { pub const REMOTE_CONTEXT: u8 = 10; pub const LOOPBACK_FORWARD: u8 = 11; pub const LOOPBACK_FORWARD_LIST: u8 = 12; - /// `AuthPrompt` — an interactive auth/host-key request during a native-SSH spawn. pub const AUTH_PROMPT: u8 = 13; - /// `SshStatus` — native-SSH spawn progress. pub const SSH_STATUS: u8 = 14; - /// `KnownHostsList` — reply to `LIST_KNOWN_HOSTS` / `DELETE_KNOWN_HOST`. pub const KNOWN_HOSTS_LIST: u8 = 15; - // SFTP (WS5) replies own 30-36 in the daemon space too. pub const SFTP_ENTRIES: u8 = 30; pub const SFTP_OP_RESULT: u8 = 31; pub const SFTP_TRANSFER_STARTED: u8 = 32; pub const SFTP_TRANSFER_PROGRESS: u8 = 33; - // (15–19 reserved: WS3 auth extensions.) - /// `ForwardList` — reply to the WS4 managed-forward messages. pub const FORWARD_LIST: u8 = 20; - /// `Agent` — the foreground CLI coding agent detected on a pane (or its clear). pub const AGENT: u8 = 21; - /// `AgentStatus` — the pane's rich agent-session status (or its clear). pub const AGENT_STATUS: u8 = 22; - /// `Version` — reply to the client-space `VERSION` request (same value by - /// design; the spaces are independent). pub const VERSION_REPLY: u8 = 40; - /// `Procs` — reply to the client-space `QUERY_PROCS` request. pub const PROCS: u8 = 50; } -/// Write one framed message: `[u32 LE len][u8 kind][payload]`. pub fn write_frame<W: Write>(w: &mut W, kind: u8, payload: &[u8]) -> io::Result<()> { let len = payload.len(); if len > MAX_FRAME { @@ -1261,9 +746,6 @@ pub fn write_frame<W: Write>(w: &mut W, kind: u8, payload: &[u8]) -> io::Result< Ok(()) } -/// Read one framed message, returning `(kind, payload)`. Returns an `UnexpectedEof` -/// error when the peer closes cleanly between frames (callers treat that as a -/// normal disconnect). pub fn read_frame<R: Read>(r: &mut R) -> io::Result<(u8, Vec<u8>)> { let mut len_buf = [0u8; 4]; r.read_exact(&mut len_buf)?; @@ -1281,34 +763,16 @@ pub fn read_frame<R: Read>(r: &mut R) -> io::Result<(u8, Vec<u8>)> { Ok((kind[0], payload)) } -/// The kind byte of the frame at the front of `buf`, once its 5-byte header has -/// arrived — the payload need not have. -/// -/// For the one caller that has to classify a reply *before* paying for it: the -/// client's `Attach` is answered either by a tiny `Error` or by a `Size` + -/// `Snapshot` replay that can run to megabytes, and waiting for the whole first -/// frame to tell them apart would stall every successful attach behind its own -/// scrollback. pub fn peek_frame_kind(buf: &[u8]) -> Option<u8> { (buf.len() >= 5).then(|| buf[4]) } -/// Whether `kind` is the [`DaemonMsg::Error`] frame. The kind bytes themselves -/// stay private — this is the one classification a client makes without -/// decoding, and naming it keeps the numbering in one file. pub fn is_error_kind(kind: u8) -> bool { kind == kind::ERROR } -/// Extract one complete frame from the front of `buf`, if fully buffered — the -/// resumable counterpart of [`read_frame`] for callers that read the stream -/// with timeouts (the client reader enforces the DEC 2026 synchronized-update -/// deadline this way). A partial frame stays in `buf` untouched until more -/// bytes arrive, so a read that times out mid-frame loses nothing. Returns -/// `Ok(None)` while the frame is incomplete; an oversize length is a protocol -/// desync and errors, mirroring `read_frame`. pub fn take_frame(buf: &mut Vec<u8>) -> io::Result<Option<(u8, Vec<u8>)>> { - const HEADER: usize = 5; // u32 LE payload length + u8 kind + const HEADER: usize = 5; if buf.len() < HEADER { return Ok(None); } @@ -1328,8 +792,6 @@ pub fn take_frame(buf: &mut Vec<u8>) -> io::Result<Option<(u8, Vec<u8>)>> { Ok(Some((kind, payload))) } -/// Serialize a control struct to JSON, mapping serde errors to `io::Error` so -/// the encode/decode surface is a single error type. fn to_json<T: Serialize>(value: &T) -> io::Result<Vec<u8>> { serde_json::to_vec(value).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e)) } @@ -1338,8 +800,6 @@ fn from_json<T: for<'de> Deserialize<'de>>(bytes: &[u8]) -> io::Result<T> { serde_json::from_slice(bytes).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e)) } -/// The [`kind::SPAWN_OWNED`] payload — a struct, not a tuple, so the *next* -/// spawn field is a `#[serde(default)]` line here instead of a new frame kind. #[derive(Debug, Clone, Serialize, Deserialize)] struct OwnedSpawn { #[serde(default)] @@ -1352,12 +812,8 @@ struct OwnedSpawn { } impl ClientMsg { - /// Encode and write this message as one frame. pub fn encode<W: Write>(&self, w: &mut W) -> io::Result<()> { match self { - // 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, @@ -1370,8 +826,6 @@ impl ClientMsg { shell: shell @ Some(_), owner: None, } => write_frame(w, kind::SPAWN_SHELL, &to_json(&(cwd, size, shell))?), - // An owner present means the caller checked FEATURE_PANE_OWNER — - // this frame kind is unknown to daemons without it. ClientMsg::Spawn { cwd, size, @@ -1447,7 +901,6 @@ impl ClientMsg { } } - /// Reconstruct a message from a decoded frame. pub fn from_frame(k: u8, payload: Vec<u8>) -> io::Result<Self> { Ok(match k { kind::SPAWN => { @@ -1553,7 +1006,6 @@ impl ClientMsg { }) } - /// Read and decode the next client message from `r`. pub fn read<R: Read>(r: &mut R) -> io::Result<Self> { let (k, payload) = read_frame(r)?; Self::from_frame(k, payload) @@ -1561,7 +1013,6 @@ impl ClientMsg { } impl DaemonMsg { - /// Encode and write this message as one frame. pub fn encode<W: Write>(&self, w: &mut W) -> io::Result<()> { match self { DaemonMsg::Spawned { pane_id } => write_frame(w, kind::SPAWNED, &to_json(pane_id)?), @@ -1613,7 +1064,6 @@ impl DaemonMsg { } } - /// Reconstruct a message from a decoded frame. pub fn from_frame(k: u8, payload: Vec<u8>) -> io::Result<Self> { Ok(match k { kind::SPAWNED => DaemonMsg::Spawned { @@ -1667,7 +1117,6 @@ impl DaemonMsg { }) } - /// Read and decode the next daemon message from `r`. pub fn read<R: Read>(r: &mut R) -> io::Result<Self> { let (k, payload) = read_frame(r)?; Self::from_frame(k, payload) @@ -1685,15 +1134,6 @@ mod tests { cell_h: 17, }; - /// End-to-end: a full attach session's worth of `ClientMsg`s and `DaemonMsg`s - /// crossing a *real* duplex stream (loopback TCP — the same transport shape the - /// daemon uses on Windows, and close enough to the Unix socket to exercise the - /// framing). Unlike the single-`Cursor` round-trips above, this drives both - /// directions across a thread boundary with mixed, back-to-back frames, so it - /// catches framing bugs that only surface when `read_frame` must reassemble a - /// message split across TCP segments or sitting behind an unrelated one. This is - /// the client↔daemon IPC seam the rest of the suite otherwise only tests in - /// halves. #[test] fn full_session_round_trips_over_a_real_duplex_stream() { use std::io::Write; @@ -1703,9 +1143,6 @@ mod tests { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let addr = listener.local_addr().unwrap(); - // A realistic exchange: the client spawns a pane, resizes, types a command - // and detaches; the daemon acknowledges, replays a snapshot, streams output, - // reports prompt state, then exit. let client_msgs = vec![ ClientMsg::Spawn { cwd: Some(PathBuf::from("/work")), @@ -1729,7 +1166,6 @@ mod tests { DaemonMsg::Exited { code: Some(0) }, ]; - // Daemon end: accept, decode every client message, then stream the replies. let expect_from_client = client_msgs.clone(); let reply_with = daemon_msgs.clone(); let daemon = thread::spawn(move || { @@ -1744,7 +1180,6 @@ mod tests { got }); - // Client end: send every request, then decode every reply. let mut sock = TcpStream::connect(addr).unwrap(); for m in &client_msgs { m.encode(&mut sock).unwrap(); @@ -1759,7 +1194,6 @@ mod tests { assert_eq!(got_from_daemon, daemon_msgs, "client decoded daemon stream"); } - /// Round-trip every `ClientMsg` variant through encode → read. #[test] fn client_roundtrip() { let msgs = vec![ @@ -1901,7 +1335,6 @@ mod tests { } } - /// Round-trip every `DaemonMsg` variant through encode → read. #[test] fn daemon_roundtrip() { let msgs = vec![ @@ -1938,8 +1371,6 @@ mod tests { argv: vec!["ssh".into(), "-p".into(), "2222".into(), "dev".into()], target: "dev".into(), })), - // A WSL pane's context rides the same wire; `kind` is serialized - // kebab-case, so this pins the encoding of the new variant. DaemonMsg::RemoteContext(Some(RemoteContext { kind: RemoteKind::Wsl, argv: Vec::new(), @@ -2075,14 +1506,8 @@ 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, @@ -2093,13 +1518,10 @@ mod tests { 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!( @@ -2113,14 +1535,11 @@ mod tests { ); } - /// An explicit-shell spawn rides the `SPAWN_SHELL` frame (not the legacy - /// `SPAWN` kind), and round-trips through encode → decode. #[test] fn explicit_shell_spawn_uses_shell_kind() { let shell = ShellSpec { program: "fish".to_string(), args: vec!["-l".to_string()], - // Set so the round-trip covers the flag, not just program + args. args_are_tty7_defaults: true, }; let msg = ClientMsg::Spawn { @@ -2145,12 +1564,6 @@ mod tests { ); } - /// An owned spawn rides the `SPAWN_OWNED` frame — never a legacy kind, - /// whose tuple payloads cannot carry the field — and round-trips with the - /// shell pick intact. The compat direction is the caller's contract: - /// `owner` is only ever set for a daemon advertising `pane-owner`, so the - /// legacy kinds stay byte-for-byte what old daemons expect (locked by - /// `default_spawn_stays_wire_compatible_with_old_daemons` above). #[test] fn owned_spawn_uses_the_owned_kind_and_round_trips() { let msg = ClientMsg::Spawn { @@ -2170,9 +1583,6 @@ mod tests { assert_eq!(ClientMsg::from_frame(k, payload).unwrap(), msg); } - /// The `SPAWN_OWNED` payload is a struct with defaults, so a frame from a - /// *newer* client — more fields, or fewer — still decodes. This is the - /// property that makes it the last spawn kind ever needed. #[test] fn owned_spawn_payload_tolerates_unknown_and_missing_fields() { let payload = serde_json::to_vec(&serde_json::json!({ @@ -2197,9 +1607,6 @@ mod tests { ); } - /// A `PaneInfo` from an old daemon has no `owner` key and decodes to - /// `None` — the "attachable by anyone" reading every pane had before the - /// field existed. #[test] fn pane_info_owner_defaults_for_old_daemons() { let old = serde_json::json!({"pane_id": 3, "title": "zsh", "alive": true}); @@ -2208,8 +1615,6 @@ mod tests { assert!(info.alive); } - /// An empty-payload binary frame (e.g. an `Input([])`) still round-trips and - /// an oversize length is rejected. #[test] fn frame_edges() { let mut buf = Vec::new(); @@ -2217,7 +1622,6 @@ mod tests { let mut cursor = std::io::Cursor::new(&buf); assert_eq!(read_frame(&mut cursor).unwrap(), (3, vec![])); - // A hand-rolled frame claiming a huge length must be rejected. let mut bad = Vec::new(); bad.extend_from_slice(&(u32::MAX).to_le_bytes()); bad.push(3); @@ -2225,32 +1629,22 @@ mod tests { assert!(read_frame(&mut cursor).is_err()); } - /// `write_frame` refuses to emit a payload larger than `MAX_FRAME` rather than - /// putting a frame on the wire the peer would reject. #[test] fn write_frame_rejects_oversize_payload() { let oversize = vec![0u8; MAX_FRAME + 1]; let mut buf = Vec::new(); assert!(write_frame(&mut buf, 3, &oversize).is_err()); - // Nothing partial should have been emitted before the size check. assert!(buf.is_empty()); } - /// An unknown kind byte is a protocol desync, surfaced as an error (not a panic) - /// for both directions. #[test] fn from_frame_rejects_unknown_kind() { assert!(ClientMsg::from_frame(99, vec![]).is_err()); assert!(DaemonMsg::from_frame(99, vec![]).is_err()); } - /// `take_frame` decodes exactly `write_frame`'s output, leaves partial - /// frames buffered (byte-at-a-time arrival included), preserves trailing - /// bytes of the next frame, and rejects an oversize length. #[test] fn take_frame_is_resumable_and_mirrors_read_frame() { - // Two frames, delivered one byte at a time: nothing decodes until each - // frame completes, and the buffer is never corrupted by partial reads. let mut wire = Vec::new(); write_frame(&mut wire, 3, b"hello").unwrap(); write_frame(&mut wire, 9, &[]).unwrap(); @@ -2266,34 +1660,26 @@ mod tests { assert_eq!(got, vec![(3, b"hello".to_vec()), (9, vec![])]); assert!(buf.is_empty(), "nothing left over after both frames"); - // A complete frame followed by a partial one: the first pops, the - // partial tail stays intact for the next read. let mut buf = Vec::new(); write_frame(&mut buf, 3, b"done").unwrap(); - buf.extend_from_slice(&10u32.to_le_bytes()); // next frame's header only + buf.extend_from_slice(&10u32.to_le_bytes()); assert_eq!(take_frame(&mut buf).unwrap(), Some((3, b"done".to_vec()))); assert_eq!(take_frame(&mut buf).unwrap(), None); assert_eq!(buf, 10u32.to_le_bytes()); - // An oversize length is a desync, same as read_frame. let mut bad = (u32::MAX).to_le_bytes().to_vec(); bad.push(3); assert!(take_frame(&mut bad).is_err()); } - /// A frame truncated mid-stream — after the length prefix, or mid-payload — - /// surfaces as an error (the reader treats it as a dropped peer), never a - /// short/garbage frame. #[test] fn read_frame_on_truncated_frame_is_an_error() { - // Length prefix only, no kind byte. let mut cut = std::io::Cursor::new(5u32.to_le_bytes().to_vec()); assert_eq!( read_frame(&mut cut).unwrap_err().kind(), std::io::ErrorKind::UnexpectedEof ); - // Kind present but the payload is shorter than the length promised. let mut buf = Vec::new(); buf.extend_from_slice(&10u32.to_le_bytes()); buf.push(3); @@ -2305,28 +1691,21 @@ mod tests { ); } - /// A control frame whose JSON payload is garbage decodes to an error rather - /// than panicking — a desynced peer can't crash the reader. #[test] fn from_frame_rejects_malformed_json_payloads() { assert!(ClientMsg::from_frame(kind::SPAWN, b"not json".to_vec()).is_err()); assert!(DaemonMsg::from_frame(kind::PANE_LIST, b"{oops".to_vec()).is_err()); } - /// A clean close between frames (empty input) reads as `UnexpectedEof`, which - /// callers treat as a normal disconnect. #[test] fn read_frame_on_empty_input_is_eof() { let mut empty = std::io::Cursor::new(Vec::<u8>::new()); let err = read_frame(&mut empty).unwrap_err(); assert_eq!(err.kind(), std::io::ErrorKind::UnexpectedEof); - // The typed readers surface the same EOF. let mut empty2 = std::io::Cursor::new(Vec::<u8>::new()); assert!(ClientMsg::read(&mut empty2).is_err()); } - /// `PaneInfo`'s `#[serde(default)]` fields tolerate an older/leaner JSON that - /// omits `cwd` and `title`. #[test] fn pane_info_deserializes_with_defaults() { let info: PaneInfo = serde_json::from_str(r#"{"pane_id": 5, "alive": true}"#).unwrap(); @@ -2403,7 +1782,6 @@ mod tests { } } - /// The wire spec round-trips through serde, secrets and jump chain included. #[test] fn native_ssh_spec_serde_round_trips() { let spec = sample_native_spec(); @@ -2412,24 +1790,18 @@ mod tests { assert_eq!(spec, back); } - /// Missing optional fields decode via `#[serde(default)]` (forward compat). #[test] fn native_ssh_spec_tolerates_minimal_json() { let spec: NativeSshSpec = serde_json::from_str(r#"{"host":"h","port":22,"user":"u","auth_mode":"auto"}"#) .unwrap(); - assert_eq!(spec.term, "xterm-256color"); // defaulted - assert!(spec.verify_host_keys); // defaulted true - // A spec persisted before shell integration existed must come back - // opted *in* — `#[serde(default)]` on a bool would silently turn it off - // for every reconnect to a pane saved by an older build. + assert_eq!(spec.term, "xterm-256color"); + assert!(spec.verify_host_keys); assert!(spec.shell_integration); assert_eq!(spec.password, None); assert!(spec.jump.is_none()); } - /// The hand-written `Debug` must never leak secrets — for the spec *or* its - /// nested jump spec — and `AuthResponse::Secret(s)` redact too. #[test] fn secrets_are_redacted_in_debug_output() { let spec = sample_native_spec(); @@ -2449,22 +1821,16 @@ mod tests { ); } - /// `without_secrets` clears passwords/passphrases recursively but keeps - /// everything else, so the sanitized spec is safe to persist. #[test] fn without_secrets_strips_password_and_passphrases_recursively() { let clean = sample_native_spec().without_secrets(); assert_eq!(clean.password, None); assert!(clean.key_passphrases.is_none()); assert_eq!(clean.jump.as_ref().unwrap().password, None); - // Non-secret fields survive. assert_eq!(clean.host, "example.com"); assert_eq!(clean.login_script, vec!["tmux attach".to_string()]); } - /// Every `WorkspaceOp` round-trips inside the `OnWorkspace` envelope. Kept - /// as its own test rather than folded into `client_roundtrip` so the - /// pane-addressed corpus there stays byte-for-byte what it was. #[test] fn on_workspace_roundtrip() { let ws = crate::core::session::WorkspaceId::new(); @@ -2525,8 +1891,6 @@ mod tests { } } - /// The new native-SSH client/daemon message variants round-trip through the - /// frame codec (new kind bytes included). #[test] fn native_ssh_messages_round_trip() { let client_msgs = vec![ @@ -2594,8 +1958,6 @@ mod tests { } } - /// The native-SSH spawn uses a brand-new kind byte, so a pre-WS2 daemon - /// rejects it (unknown kind) rather than mis-spawning. #[test] fn native_ssh_spawn_uses_new_kind_byte() { let msg = ClientMsg::SpawnNativeSsh { @@ -2609,11 +1971,6 @@ mod tests { assert_eq!(k, kind::SPAWN_NATIVE_SSH); } - /// A daemon that predates `features` answers without the field, and that - /// reply must still decode — as an empty capability list, which is exactly - /// the truth about it. If it didn't, upgrading the app while an old daemon - /// held live sessions would turn the version handshake into a hard failure - /// instead of the keep-or-restart question it is meant to be. #[test] fn a_version_reply_without_features_still_decodes() { let legacy = br#"{"protocol":2,"build":"26.7.4"}"#; @@ -2624,10 +1981,6 @@ mod tests { assert!(!v.has_feature(crate::daemon::control::feature::CONTROL)); } - /// And the reverse skew: a *newer* daemon's extra field must not break an - /// older client's decode. serde ignores unknown fields by default and this - /// struct must never opt out of that, or every future capability becomes a - /// breaking change for clients that don't care about it. #[test] fn a_version_reply_with_unknown_fields_still_decodes() { let future = br#"{"protocol":4,"build":"99.0.0","features":["control"], @@ -2637,11 +1990,6 @@ mod tests { assert!(v.has_feature(crate::daemon::control::feature::CONTROL)); } - /// This build's own answer: the bumped version, and — deliberately — no - /// control capability, because the *local session daemon* does not serve - /// the control dialect. `tty7-server` does, and advertises it itself. - /// Claiming it here would make the GUI open a connection this process - /// cannot answer. #[test] fn the_local_daemon_does_not_claim_the_control_dialect() { let v = DaemonVersion::current(); diff --git a/crates/tty7-core/src/daemon/remote.rs b/crates/tty7-core/src/daemon/remote.rs index da58f1f3..3eaa2404 100644 --- a/crates/tty7-core/src/daemon/remote.rs +++ b/crates/tty7-core/src/daemon/remote.rs @@ -82,7 +82,6 @@ pub(crate) fn parse_ssh_invocation(argv: &[String]) -> Option<SshInvocation> { let target = target?; if i < argv.len() { - // Remote command present. Do not try to reuse this invocation for `-N`. return None; } @@ -147,7 +146,6 @@ fn platform_foreground_argv(pid: i32) -> Option<Vec<String>> { } let mut mib = [libc::CTL_KERN, libc::KERN_PROCARGS2, pid as libc::c_int]; let mut len = 0usize; - // SAFETY: first sysctl call requests the required buffer length. if unsafe { libc::sysctl( mib.as_mut_ptr(), @@ -163,7 +161,6 @@ fn platform_foreground_argv(pid: i32) -> Option<Vec<String>> { return None; } let mut buf = vec![0u8; len]; - // SAFETY: buffer is allocated to the size returned by sysctl above. if unsafe { libc::sysctl( mib.as_mut_ptr(), diff --git a/crates/tty7-core/src/daemon/remote_link.rs b/crates/tty7-core/src/daemon/remote_link.rs index 5ba6cff7..abb67d80 100644 --- a/crates/tty7-core/src/daemon/remote_link.rs +++ b/crates/tty7-core/src/daemon/remote_link.rs @@ -1,51 +1,3 @@ -//! [`RemoteLink`] — one logical byte stream from the local daemon to a remote -//! `tty7-server`. -//! -//! ## Where this sits, and why it is not in the GUI -//! -//! The design's "one more transport shape doesn't disturb the layers above" is -//! true, but not for the reason it looks like. It is *not* that -//! [`crate::daemon::transport::Stream`] grew a variant — that type is a plain -//! alias (`UnixStream` on Unix, loopback `TcpStream` on Windows) and it does not -//! change by a byte here. It is that **a remote stream never reaches it**: -//! -//! ```text -//! GUI ──transport::Stream (unchanged)──▶ local daemon -//! │ -//! RemoteLink ──▶ SSH channel / WSL stdio -//! ``` -//! -//! The GUI still talks to a socket on this machine. The local daemon forwards -//! those bytes onto a `RemoteLink` without parsing them — which is what keeps -//! the router a router, and keeps the remote version handshake genuinely -//! end-to-end between the GUI and the remote server rather than something the -//! daemon in the middle has to understand. -//! -//! Every existing `transport::Stream` call site — `try_clone`, `set_read_timeout`, -//! `shutdown(Shutdown::Write)` — is therefore untouched, because every one of -//! them is on a stream that is still local. -//! -//! ## Why an enum, and why four variants over two types -//! -//! An enum rather than `Box<dyn AsyncRead + AsyncWrite>`, matching -//! [`super::ssh::connect::Transport`]: each variant's poll methods are a direct -//! delegate with no vtable, on a path that carries every byte of every remote -//! pane's output. -//! -//! Four variants over two underlying types, because the pairs are -//! distinguishable only by **how they were obtained**, and that distinction is -//! exactly what diagnostics need: -//! -//! | Variant | Underlying | Distinct because | -//! |---|---|---| -//! | [`RemoteLink::StreamLocal`] | SSH channel | The preferred path. A failure here is what triggers the one-time fallback probe | -//! | [`RemoteLink::SessionExec`] | SSH channel | Already the fallback. A failure here means the remote is genuinely unreachable, not that forwarding is disabled | -//! | [`RemoteLink::Wsl`] | child stdio | No SSH involved; auth and host-key problems are impossible by construction | -//! | [`RemoteLink::LocalStdio`] | child stdio | A test harness. Must never be mistaken for a real remote in a log | -//! -//! Collapsing each pair would turn "`AllowStreamLocalForwarding` is off, fall -//! back" into an indistinguishable "the connection dropped". - use std::io; use std::pin::Pin; use std::process::Stdio; @@ -58,76 +10,29 @@ use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; use super::router::RouteChannel; use super::ssh::ProcessStream; -/// One logical stream between the local daemon and a remote `tty7-server`. pub enum RemoteLink { - /// Preferred: `direct-streamlocal@openssh.com` straight to the remote's - /// `daemon.sock`, opened with russh's - /// `client::Handle::channel_open_direct_streamlocal`. No extra process on - /// the remote, and the remote server's own accept loop handles it exactly - /// as it would a local connection. StreamLocal(russh::ChannelStream<russh::client::Msg>), - /// Fallback for `AllowStreamLocalForwarding no`: a session channel running - /// `tty7-server --stdio`, which bridges its own stdin/stdout to that same - /// socket. Same type as [`RemoteLink::StreamLocal`], different meaning. SessionExec(russh::ChannelStream<russh::client::Msg>), - /// WSL, which has no SSH at all: `wsl.exe -d <distro> -- tty7-server --stdio`. Wsl(ProcessStream), - /// A `tty7-server --stdio` child on *this* machine. The end-to-end test - /// path — the one that lets the whole remote stack be exercised in CI with - /// no second machine, no SSH daemon, and no credentials. LocalStdio(ProcessStream), } impl RemoteLink { - /// Adopt a `direct-streamlocal@openssh.com` channel as the preferred link. - /// - /// Taking the [`Channel`] rather than its stream keeps the "which SSH - /// primitive opened this" decision at the call site that made it, which is - /// the only place that still knows. pub fn stream_local(channel: Channel<Msg>) -> RemoteLink { RemoteLink::StreamLocal(channel.into_stream()) } - /// Adopt a session channel already running `tty7-server --stdio` as the - /// fallback link. pub fn session_exec(channel: Channel<Msg>) -> RemoteLink { RemoteLink::SessionExec(channel.into_stream()) } - /// Spawn `program args…` and take its stdio as a [`RemoteLink::LocalStdio`]. - /// - /// `kill_on_drop`, so dropping the link reaps the child rather than leaving - /// a `tty7-server` parented to a test that has already finished. pub fn local_stdio(program: &str, args: &[&str]) -> io::Result<RemoteLink> { Ok(RemoteLink::LocalStdio(spawn_stdio(program, args)?)) } - /// Spawn `wsl.exe -d <distro> -- <server> --stdio` and take its stdio. - /// - /// `server` is an **absolute path inside the distribution**, not a bare - /// name: `wsl.exe` runs the command without a login shell, so the `PATH` - /// that would find `~/.local/share/tty7/bin` is not in effect. - /// [`install::wsl::ensure_wsl_server`](crate::daemon::install::wsl::ensure_wsl_server) - /// is what resolves it. - /// - /// No shell is involved, so `server` needs no quoting; the distro name is - /// validated because it is an *option's* argument and a leading `-` would be - /// read as another option. - /// - /// # `channel` - /// - /// **A pane bridge and a control bridge are different commands**, and this - /// is the only place that can tell them apart for WSL. The remote listens - /// twice and the dialects are not interchangeable — a pane landing on the - /// control socket writes its `Spawn` and is answered with nothing, which is - /// what "the workspace connects but the pane says it can't reach the - /// machine" was. The SSH path makes the same choice in - /// [`ssh::open_remote_link`](crate::daemon::ssh), and `LocalStdio` makes it - /// in the client's `PaneWorkspace::route_header` because its argv is run - /// verbatim; WSL builds its argv here, so here is where it belongs. pub fn wsl(distro: &str, server: &str, channel: RouteChannel) -> io::Result<RemoteLink> { super::install::wsl::validate_distro(distro)?; let args = super::install::wsl::wsl_args(distro, &wsl_link_argv(server, channel)); @@ -137,18 +42,6 @@ impl RemoteLink { )?)) } - /// [`RemoteLink::wsl`] with the command given as a shell string rather than - /// a resolved path — the WSL reading of - /// [`RouteHeader::server_command`](super::router::RouteHeader::server_command), - /// which over SSH is likewise handed to a shell. - /// - /// The escape hatch for a distribution where the normal install path cannot - /// be used; the resolved-path form above is what ships. - /// - /// `channel` reaches the command through - /// [`RouteChannel::bridge_command`], which is the same rewrite the SSH path - /// applies to *its* shell command — an override must not silently lose the - /// pane dialect that [`RemoteLink::wsl`] gets right. pub fn wsl_shell(distro: &str, command: &str, channel: RouteChannel) -> io::Result<RemoteLink> { super::install::wsl::validate_distro(distro)?; let command = channel.bridge_command(command); @@ -159,11 +52,6 @@ impl RemoteLink { )?)) } - /// The label this link goes into logs and the status line under. - /// - /// The whole reason the variants are not collapsed: an operator reading - /// "streamlocal" versus "session-exec" in a log knows immediately whether - /// the remote refused socket forwarding or whether the box is simply gone. pub fn kind_label(&self) -> &'static str { match self { RemoteLink::StreamLocal(_) => "streamlocal", @@ -173,11 +61,6 @@ impl RemoteLink { } } - /// Whether this link is a `--stdio` bridge rather than a direct socket. - /// - /// The bridge costs one extra process on the remote and cannot report a - /// connection refusal as precisely, so a caller deciding whether to retry - /// the preferred path wants to know. pub fn is_stdio_bridge(&self) -> bool { matches!( self, @@ -185,7 +68,6 @@ impl RemoteLink { ) } - /// Whether the link rides an SSH channel (as opposed to a child process). pub fn is_ssh(&self) -> bool { matches!( self, @@ -194,11 +76,6 @@ impl RemoteLink { } } -/// The command `wsl.exe` runs for a link on `channel`, as an argv. -/// -/// Pure, because the difference between the two is one flag that decides which -/// of the remote's two sockets the stream lands on, and it cannot be checked -/// anywhere a distribution is required. See [`RemoteLink::wsl`]. fn wsl_link_argv<'a>(server: &'a str, channel: RouteChannel) -> Vec<&'a str> { let mut argv = vec![server, "--stdio"]; if channel == RouteChannel::Pane { @@ -214,16 +91,11 @@ fn spawn_stdio(program: &str, args: &[&str]) -> io::Result<ProcessStream> { fn spawn_stdio_owned(program: &str, args: &[String]) -> io::Result<ProcessStream> { let mut command = tokio::process::Command::new(program); - // A GUI process spawning `wsl.exe` would otherwise flash a console window - // per pane. No-op off Windows. crate::core::proc::hide_console_tokio(&mut command); let mut child = command .args(args) .stdin(Stdio::piped()) .stdout(Stdio::piped()) - // stderr stays inherited: the remote server's diagnostics belong in the - // daemon's log, and capturing them into a pipe nobody drains would - // eventually block the child on a full buffer. .stderr(Stdio::inherit()) .kill_on_drop(true) .spawn()?; @@ -238,50 +110,17 @@ fn spawn_stdio_owned(program: &str, args: &[String]) -> io::Result<ProcessStream Ok(ProcessStream::from_parts(child, stdin, stdout)) } -// --------------------------------------------------------------------------- -// How a host is entered: the one-time decision behind `StreamLocal` vs `SessionExec` -// --------------------------------------------------------------------------- - -/// The command run on a session channel when socket forwarding is unavailable. -/// -/// `--stdio` with neither `--serve` nor `--bridge` lets the *remote* decide: -/// it bridges to a running daemon if there is one and serves in-process if -/// there is not, which is the right answer in both cases and one this side has -/// no way to know. -/// -/// **Only a fallback for links that skip the install pass.** SSH links do not: -/// `SshManager::open_remote_link` runs `install::ensure_remote_server` first and -/// uses the absolute, dialect-qualified path it returns. That matters because -/// nothing puts `~/.local/share/tty7/bin` on a non-interactive `PATH`, and the -/// file there is `tty7-server-c<control>p<protocol>` — this bare name would be a -/// `command not found` on a machine the install had just succeeded on. -/// [`super::router::RouteHeader::server_command`] overrides either. pub const DEFAULT_REMOTE_SERVER_CMD: &str = "tty7-server --stdio"; -/// `sockaddr_un.sun_path` is 104 bytes on macOS and 108 on Linux, NUL included. -/// The remote server stays under the smaller figure -/// (`host::server`'s `MAX_SOCKET_PATH_BYTES`), so the path derived here must use -/// the same bound or the two sides would disagree about when the fallback name -/// kicks in. const MAX_SOCKET_PATH_BYTES: usize = 100; -/// How this connection reaches the remote `tty7-server` — decided once per SSH -/// connection and cached there, never re-decided per channel. -/// -/// Probing per channel would put a failed `direct-streamlocal` open in front of -/// every pane on a host whose admin turned `AllowStreamLocalForwarding` off, -/// and each of those is a full round trip. #[derive(Clone, Debug, PartialEq, Eq)] pub enum RemoteEntry { - /// `direct-streamlocal@openssh.com` straight to this absolute remote path. StreamLocal { socket: String }, - /// A session channel running `command`, which bridges its own stdio to that - /// same socket. SessionExec { command: String }, } impl RemoteEntry { - /// The label this entry's links appear under in logs. pub fn kind_label(&self) -> &'static str { match self { RemoteEntry::StreamLocal { .. } => "streamlocal", @@ -290,22 +129,6 @@ impl RemoteEntry { } } -/// Pick the way in, from what a probe of the remote learned. -/// -/// Split out as a pure function because the real decision is impossible to -/// unit-test end to end — it needs an sshd with `AllowStreamLocalForwarding` -/// flipped both ways — while the *policy* is exactly the part worth pinning: -/// -/// | remote socket path | forwarding allowed | entry | -/// |---|---|---| -/// | resolved | yes | [`RemoteEntry::StreamLocal`] | -/// | resolved | no | [`RemoteEntry::SessionExec`] | -/// | unresolved | either | [`RemoteEntry::SessionExec`] | -/// -/// An unresolved path forces the bridge even where forwarding is allowed: -/// `direct-streamlocal` carries an absolute path and nothing else, so without -/// one there is no request to make — whereas `tty7-server --stdio` resolves the -/// path in the process that will actually bind it. pub fn choose_entry( socket: Option<&str>, forwarding_allowed: bool, @@ -321,10 +144,6 @@ pub fn choose_entry( } } -/// The four remote environment variables the control socket path is derived -/// from. Read off the remote in one `exec`, never guessed from this machine's -/// own environment — a macOS client has no `$XDG_RUNTIME_DIR` and a Linux -/// server usually does. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct RemoteEnv { pub control_sock: Option<String>, @@ -333,13 +152,8 @@ pub struct RemoteEnv { pub tmpdir: Option<String>, } -/// Marker every probe line carries, so a remote whose startup files print a -/// banner (or a `fish` that greets) doesn't corrupt the answer. Same tactic as -/// [`crate::daemon::shell_integration::remote`]'s shell probe. const ENV_MARKER: &str = "__tty7_env__"; -/// The probe itself, wrapped in `sh -c` because the login shell it is handed to -/// may be `fish`, which does not speak `${VAR-}`. pub const REMOTE_ENV_PROBE: &str = concat!( "sh -c 'printf \"__tty7_env__ %s\\n\" ", "\"sock=${TTY7_CONTROL_SOCK-}\" \"xdg=${XDG_RUNTIME_DIR-}\" ", @@ -347,7 +161,6 @@ pub const REMOTE_ENV_PROBE: &str = concat!( ); impl RemoteEnv { - /// Parse [`REMOTE_ENV_PROBE`]'s output, ignoring everything unmarked. pub fn parse_probe(out: &str) -> RemoteEnv { let mut env = RemoteEnv::default(); for line in out.lines() { @@ -357,8 +170,6 @@ impl RemoteEnv { let Some((key, value)) = rest.trim_start().split_once('=') else { continue; }; - // An unset variable prints empty; keep it `None` so the fallbacks - // below treat it as absent rather than as the empty path. let value = (!value.is_empty()).then(|| value.to_string()); match key { "sock" => env.control_sock = value, @@ -372,29 +183,6 @@ impl RemoteEnv { } } -/// Where the remote's `tty7-server` listens for control connections, derived -/// from *its* environment. -/// -/// This mirrors `host::server`'s `control_socket_path` step for step, because -/// the two have to agree byte for byte: this side asks `direct-streamlocal` for -/// a path, and the far side binds one, and nothing in between reconciles them. -/// -/// | Order | Path | -/// |---|---| -/// | 1 | `$TTY7_CONTROL_SOCK` | -/// | 2 | `$XDG_RUNTIME_DIR/tty7/daemon.sock` | -/// | 3 | `$HOME/.local/share/tty7/daemon.sock` | -/// | 4 | `<runtime-or-tmp>/tty7-<hash>.sock`, when any of the above overruns `sun_path` | -/// -/// The hashed name is *not* automatically shorter: a deep `$XDG_RUNTIME_DIR` -/// overruns `sun_path` on its own, which is the hole -/// [`crate::daemon::transport`] was fixed for. Every candidate base is -/// length-checked, and `None` — rather than a path the server will not be on — -/// is the answer when none fits, which puts the session down the `--stdio` -/// bridge that resolves the path in the process that binds it. -/// -/// Paths are joined as POSIX strings, never `PathBuf`: on a Windows client -/// `PathBuf::join("/home/me", "tty7")` yields `/home/me\tty7`. pub fn remote_control_socket(env: &RemoteEnv) -> Option<String> { if let Some(explicit) = env.control_sock.as_deref().filter(|s| !s.is_empty()) { return Some(explicit.to_string()); @@ -427,8 +215,6 @@ pub fn remote_control_socket(env: &RemoteEnv) -> Option<String> { .find(|candidate| fits(candidate)) } -/// `Path::join`'s behaviour, spelled out for POSIX strings: one separator, no -/// doubling when the base already ends in one. fn posix_join(base: &str, name: &str) -> String { format!("{}/{name}", base.trim_end_matches('/')) } @@ -473,10 +259,6 @@ impl AsyncWrite for RemoteLink { } } - /// Every variant implements shutdown for real. A half-close is how the - /// remote learns the client is finished rather than merely quiet, and a - /// `poll_shutdown` that returned `Ready(Ok(()))` without acting would strand - /// the remote waiting on a stream that will never carry another byte. fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> { match self.get_mut() { RemoteLink::StreamLocal(s) | RemoteLink::SessionExec(s) => { @@ -500,13 +282,6 @@ mod tests { use super::*; use tokio::io::{AsyncReadExt, AsyncWriteExt}; - /// **A WSL pane asks for the pane socket.** The remote listens twice and - /// answers only the dialect it was asked for, so a pane that reaches the - /// control socket writes its `Spawn` and is answered with nothing at all — - /// the workspace connects, the window opens, and the pane inside it says it - /// cannot reach the machine. Nothing in the transport reports an error, - /// which is why this is pinned here rather than left to the one integration - /// path that would catch it. #[test] fn only_a_pane_link_asks_for_the_pane_socket() { let server = "/home/me/.local/share/tty7/bin/tty7-server-26.7.6"; @@ -520,11 +295,6 @@ mod tests { ); } - /// A child process's stdio really is a duplex stream: bytes written reach - /// the child's stdin and its stdout comes back, through the same - /// `AsyncRead`/`AsyncWrite` the SSH variants use. This is the path the - /// end-to-end test rides, so it has to work before there is a server to - /// point it at. #[tokio::test] async fn a_local_stdio_child_round_trips_bytes() { let mut link = RemoteLink::local_stdio("cat", &[]).unwrap(); @@ -540,9 +310,6 @@ mod tests { assert_eq!(&got, b"hello remote\n"); } - /// Shutting the write half down is what tells the peer "no more input" — - /// `cat` answers by closing its stdout, which surfaces here as EOF. A - /// no-op `poll_shutdown` would hang this test forever. #[tokio::test] async fn shutdown_closes_the_write_half_and_the_peer_sees_eof() { let mut link = RemoteLink::local_stdio("cat", &[]).unwrap(); @@ -554,25 +321,15 @@ mod tests { assert_eq!(rest, b"bye"); } - /// Dropping the link reaps the child. Without `kill_on_drop` a failed test - /// would leave a `tty7-server` running against a socket nobody holds. #[tokio::test] async fn dropping_the_link_kills_the_child() { let link = RemoteLink::local_stdio("sleep", &["300"]).unwrap(); drop(link); - // The child is reaped asynchronously by tokio; what matters is that the - // handle is gone and nothing here leaks. A surviving process would show - // up as a hung test run rather than an assertion, which is why this is - // mostly a statement of intent — `kill_on_drop(true)` above is the - // mechanism. tokio::time::sleep(std::time::Duration::from_millis(50)).await; } - /// The labels are the diagnostic value the four variants exist for, so they - /// are pinned: a log line reading "streamlocal" has to keep meaning that. #[test] fn every_variant_has_a_distinct_label() { - // Constructed without processes: only the discriminant is exercised. let labels = ["streamlocal", "session-exec", "wsl-stdio", "local-stdio"]; let mut sorted = labels.to_vec(); sorted.sort_unstable(); @@ -580,10 +337,6 @@ mod tests { assert_eq!(sorted.len(), labels.len(), "labels must be distinguishable"); } - // -- the way in --------------------------------------------------------- - - /// The whole fallback policy, which a live sshd cannot be asked to - /// demonstrate both halves of in one test run. #[test] fn the_entry_falls_back_exactly_when_streamlocal_cannot_be_used() { let cmd = "tty7-server --stdio"; @@ -593,15 +346,12 @@ mod tests { socket: "/run/user/1000/tty7/daemon.sock".into() } ); - // `AllowStreamLocalForwarding no`: the path is known and useless. assert_eq!( choose_entry(Some("/run/user/1000/tty7/daemon.sock"), false, cmd), RemoteEntry::SessionExec { command: cmd.into() } ); - // No path to ask for. The bridge resolves it in the process that binds - // it, so this is a fallback with *more* information, not less. assert_eq!( choose_entry(None, true, cmd), RemoteEntry::SessionExec { @@ -616,9 +366,6 @@ mod tests { ); } - /// The probe's output is read by marker, so a remote that greets, warns, or - /// prints a MOTD before the answer is still parsed correctly — and an unset - /// variable stays absent rather than becoming the empty path. #[test] fn the_env_probe_survives_a_chatty_remote() { let out = "Welcome to Ubuntu!\n\ @@ -634,11 +381,6 @@ mod tests { assert_eq!(env.tmpdir, None); } - /// The remote path, in the order `host::server::control_socket_path` - /// resolves it. Pinned as literals: these strings are compared — over a - /// wire, with no error message — against what a *different binary* on a - /// *different machine* computed, so an "equivalent" refactor of either side - /// is a silent connection failure. #[test] fn the_remote_socket_path_matches_the_servers_own_order() { let explicit = RemoteEnv { @@ -663,8 +405,6 @@ mod tests { Some("/run/user/1000/tty7/daemon.sock") ); - // A trailing separator must not double up: the server derives its path - // through `Path::join`, which collapses it. let trailing = RemoteEnv { xdg_runtime_dir: Some("/run/user/1000/".into()), ..RemoteEnv::default() @@ -683,15 +423,9 @@ mod tests { Some("/home/me/.local/share/tty7/daemon.sock") ); - // Nothing to derive from at all. assert_eq!(remote_control_socket(&RemoteEnv::default()), None); } - /// The hole `daemon::transport` was fixed for, on the remote side: the - /// "short" hashed name is only short relative to the *config* dir, and a - /// deep `$XDG_RUNTIME_DIR` overruns `sun_path` just as readily. Returning - /// an overlong path here would send `direct-streamlocal` at an address the - /// server could never have bound. #[test] fn a_deep_runtime_dir_never_yields_an_unbindable_path() { let deep = format!("/run/user/1000/{}", "nested/".repeat(12)); @@ -707,15 +441,11 @@ mod tests { "{path} ({} bytes) would be rejected by bind()", path.len() ); - // …and it lands in the temp dir, because the runtime dir itself is what - // was too long. assert!( path.starts_with("/tmp/tty7-"), "unexpected fallback: {path}" ); - // When *no* base is short enough, the honest answer is "no path" — the - // session then takes the stdio bridge, which resolves it remotely. let hopeless = RemoteEnv { control_sock: None, xdg_runtime_dir: Some(deep.clone()), diff --git a/crates/tty7-core/src/daemon/router.rs b/crates/tty7-core/src/daemon/router.rs index c69519c4..58ac80fc 100644 --- a/crates/tty7-core/src/daemon/router.rs +++ b/crates/tty7-core/src/daemon/router.rs @@ -1,86 +1,3 @@ -//! [`RemoteRouter`] — the local daemon as a forwarding hub for remote -//! workspaces. -//! -//! ## The shape -//! -//! ```text -//! GUI ──transport::Stream (unchanged)──▶ local daemon ──RemoteLink──▶ remote tty7-server -//! [route header][opaque bytes…] [the same opaque bytes…] -//! ``` -//! -//! The GUI still opens the same local socket it always did, with the same -//! `try_clone` / `set_read_timeout` / `shutdown` calls on it. The only addition -//! is one [`RouteHeader`] frame in front, naming the machine the rest of the -//! connection is for. Everything after that frame is bytes this daemon does not -//! read. -//! -//! ## Why "does not read" is a requirement and not an optimisation -//! -//! A router that parsed the stream would become a third opinion about the -//! protocol version. The remote's dialect is negotiated between the GUI and the -//! remote server — the end-to-end handshake, and the reason the -//! contract resolves erratum #15 the way it does: a local daemon that had to -//! understand remote frames would need to be upgraded in lockstep with both -//! ends, and every version skew would land in the middle where neither user nor -//! developer would look for it. A remote workspace has *two independent* version -//! checks (GUI↔local daemon, GUI↔remote server) and this hop deliberately has -//! none. -//! -//! So the routed portion of the connection is a byte pipe: one -//! `copy_bidirectional`, no framing, no buffering beyond the copy buffer, no -//! knowledge of `kind` bytes. The `--stdio` bridge on the far side is dumb for -//! exactly the same reason (see `tty7-server`'s `bridge`), and the two together -//! mean a remote workspace's dialect can change without either of them noticing. -//! -//! ## Where the parsing stops -//! -//! | Phase | Who reads the bytes | -//! |---|---| -//! | [`RouteHeader`] frame, client → daemon | This module | -//! | Setup prompts and replies ([`RoutePrompt`] / [`RouteReply`]) | This module | -//! | [`RouteAck`] frame, daemon → client | This module | -//! | Everything after | Nobody, until the remote server | -//! -//! The ack exists so a failure to reach the remote arrives as a *reason* rather -//! than as a closed socket. It is the last frame this side ever writes: once it -//! says `ok`, the connection belongs to the two ends. -//! -//! ## The setup window, and why the prompts live in it -//! -//! Between the header and the ack the connection is still *this* module's, and -//! nothing has been forwarded yet. That window is the only place on a routed -//! connection where the daemon can ask the client a question, and it is exactly -//! where the questions belong — every one of them ("may I write a binary onto -//! this machine?", "what is the password?", "the remote daemon is a different -//! build, keep it or restart it?") is a precondition for opening the link at -//! all. -//! -//! This is what moves three APIs back to the side of the process boundary that -//! can serve them. [`crate::daemon::install::set_install_confirm`] and -//! [`crate::daemon::install::take_mismatched_remote_daemons`] are both backed by -//! statics in whichever process calls them, and the process that *installs* is -//! the daemon while the process with a user in front of it is the GUI. Relaying -//! the question rather than the registry is what closes that gap: -//! [`with_install_confirm`](crate::daemon::install::with_install_confirm) and -//! [`with_mismatch_sink`](crate::daemon::install::with_mismatch_sink) scope both -//! to the connection being set up, and the answers come from the client that -//! asked for it. -//! -//! Once the ack is written the window shuts for good. A password the *remote -//! server* wants (say, a `sudo` prompt inside a pane) is not this layer's -//! business and never was. -//! -//! ## The one thing that goes the other way -//! -//! [`RouteAction::RestartServer`] uses the same window in the opposite -//! direction: the *client* tells the daemon to replace the `tty7-server` on the -//! target machine ("Restart Server"). It is here for the same -//! reason the prompts are — the decision needs a user and the act needs an -//! `Arc<SshConnection>`, and those live in different processes — and it fits the -//! window's own rule, being a precondition for a usable link rather than -//! something that happens over one. Such a connection is *only* a setup window: -//! it acks and closes, and not one byte is forwarded. - use std::collections::HashMap; use std::io::{self, Read, Write}; use std::sync::atomic::{AtomicU64, Ordering}; @@ -98,69 +15,23 @@ use crate::daemon::remote_link::RemoteLink; use crate::daemon::ssh::{ConnectionKey, PromptBroker, SshConnection, SshManager}; use crate::daemon::transport::Stream; -/// Kind byte of the route header, in `protocol`'s **client → daemon** space. -/// -/// Defined here rather than in `protocol::kind`, which is private (contract -/// erratum #16), and additive by that module's own rule: a daemon that predates -/// remote workspaces answers an unknown kind with `InvalidData` and drops the -/// connection, which is the correct outcome — it could not have routed it. -/// 51 sits clear of every allocated and *retired* number there (1-17, 20-24, -/// 30-36, 40, 50; 13 is retired and must never be reused). pub const ROUTE_KIND: u8 = 51; -/// Kind byte of a setup question, in the **daemon → client** space. -/// -/// 52 is the next free number there (1-15, 20-22, 30-33, 40, 50 allocated, 51 -/// spent by the ack above). Additive, so no `PROTOCOL_VERSION` bump: a client -/// that predates it is one that never sends a [`RouteHeader`] either. pub const ROUTE_PROMPT_KIND: u8 = 52; -/// Kind byte of a setup answer, in the **client → daemon** space. -/// -/// 53 is the next number clear of every allocation there (1-17, 20-24, 30-36, -/// 40, 50, 51 = the header, 52 = `OnWorkspace`) and of the retired 13. pub const ROUTE_REPLY_KIND: u8 = 53; -/// How long the daemon waits for a client's answer to a setup question before -/// treating it as a refusal. -/// -/// Longer than the GUI's own consent timeout (`ui::remote_connect`'s 180s) on -/// purpose: whichever side gives up first decides what the user sees, and -/// "tty7 stopped waiting for you" is a better message than "the daemon hung -/// up". const REPLY_TIMEOUT: Duration = Duration::from_secs(240); -/// Which dialect a routed connection carries — and therefore which of the -/// remote's two sockets it has to land on. -/// -/// `tty7-server --daemon` listens twice: the pane protocol on -/// `<config-dir>/daemon.sock` and the control dialect on -/// `$XDG_RUNTIME_DIR/tty7/daemon.sock`. One process, two roles, and a routed -/// connection is for exactly one of them. Before this existed every route went -/// to the control socket, which is why a remote workspace could list files and -/// could not open a pane. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum RouteChannel { - /// Host RPC, the machine tree, event pushes — `daemon::control`. #[default] Control, - /// One pane: `Spawn`/`Attach`/`Input`/`Output` — `daemon::protocol`. Pane, } impl RouteChannel { - /// The command that bridges *this* channel's socket to a session channel's - /// stdio, derived from the base `tty7-server --stdio` form. - /// - /// The pane socket lives under the remote's **config** dir, which the - /// connection-wide environment probe does not read (it resolves the control - /// socket only) — and guessing it wrong means connecting to nothing. So the - /// pane channel always takes the bridge, on the same reasoning - /// [`crate::daemon::remote_link::choose_entry`] already applies to an - /// unresolved socket: the process that binds the path is the one that should - /// resolve it. `direct-streamlocal` for panes is a later optimisation and - /// needs a second probe, not a guess. pub(crate) fn bridge_command(self, base: &str) -> String { match self { RouteChannel::Control => base.to_string(), @@ -169,121 +40,39 @@ impl RouteChannel { } } -/// The machine a routed connection is for. -/// -/// SSH targets carry a whole [`NativeSshSpec`] rather than a host id: it is the -/// type the daemon's connection registry already keys on, so a workspace and a -/// pane naming the same host land on the same `ConnectionKey` — and therefore -/// the same authenticated connection — with nothing to keep in sync. #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum RouteTarget { - /// A host reached over SSH: `direct-streamlocal` when the server allows it, - /// `tty7-server --stdio` on a session channel when it does not. Ssh(Box<NativeSshSpec>), - /// A WSL distribution — no SSH, no auth, no network (D9). - /// - /// The distro name as `wsl.exe -l -q` prints it. Nothing else is carried, - /// because nothing else exists: no user (the distribution's default user is - /// whoever `/etc/wsl.conf` says), no port, no host key, no credentials. Wsl { distro: String }, - /// A child process on *this* machine, for CI and end-to-end tests. - /// - /// This grants no authority the connection did not already have: - /// `ClientMsg::Spawn`'s shell override already runs an arbitrary program as - /// this user over the same socket, and that socket is user-private (Unix - /// permissions; a token-checked loopback port on Windows). LocalStdio { program: String, args: Vec<String> }, } -/// What the router should *do* with a routed connection. -/// -/// Everything else in this module assumes [`Forward`](RouteAction::Forward) — -/// the connection is a pipe and the daemon is in the middle of it. The one thing -/// that is *not* a pipe is "Restart Server": it needs the -/// machine's `Arc<SshConnection>`, which exists only in the daemon process, -/// while the decision to do it can only be made by the process with a user in -/// front of it. So it travels the same way every other cross-process question on -/// this connection does — except that this one runs in the *other* direction. -/// The setup window relays daemon → client questions (may I install? what is the -/// password?); this is the client telling the daemon to act. -/// -/// **Additive on the wire**, exactly like [`RouteChannel`]: a header written -/// before the field existed decodes as `Forward`, which is what it meant. A -/// daemon that predates the field ignores it and forwards — which is why -/// [`RouteAck::action`] exists, so a client can tell "restarted" from -/// "silently routed by an older daemon" instead of reporting a restart that -/// never happened. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum RouteAction { - /// Open the link and copy bytes. Every connection before this existed. #[default] Forward, - /// Stop the `tty7-server` serving the target machine and start this - /// client's build instead, then answer and close. **No bytes are forwarded** - /// and no link is opened: there is nothing to talk to afterwards, since the - /// daemon that was serving is the one being replaced. - /// - /// This drops every pane that daemon hosts. It happens only when - /// a user has answered the dialect-mismatch prompt with "Restart Server". RestartServer, - /// [`RestartServer`](Self::RestartServer), plus rewriting the binary first. - /// - /// The one action that installs over a server already sitting at the path - /// this client's dialect names. Nothing on the connect path does that — the - /// name is checked against the binary before it is published, so a name that - /// lies can only come from outside tty7. The handshake is what discovers it, - /// and this is what its error offers as the way out. - /// - /// Drops every pane, same as `RestartServer`, and needs the same explicit - /// answer from a user behind it. ReplaceServer, } -/// The frame that turns a local connection into a routed one. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct RouteHeader { pub target: RouteTarget, - /// Overrides the command used for the `--stdio` fallback. `None` uses - /// [`crate::daemon::remote_link::DEFAULT_REMOTE_SERVER_CMD`]. - /// - /// TODO(B2): `install::ensure_remote_server` is what will really know this - /// (it resolves, and may install, the binary); the field exists so a remote - /// whose `tty7-server` is not on the non-interactive `PATH` is reachable - /// before that lands. #[serde(default)] pub server_command: Option<String>, - /// Which of the remote's dialects this connection carries. - /// - /// `#[serde(default)]` = [`RouteChannel::Control`], which is what every - /// header written before the field existed meant. #[serde(default)] pub channel: RouteChannel, - /// What the daemon should do with this connection. - /// - /// `#[serde(default)]` = [`RouteAction::Forward`], the only thing a routed - /// connection did before the restart needed a way across the - /// process boundary. #[serde(default)] pub action: RouteAction, } -/// The daemon's one and only answer on a routed connection. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct RouteAck { pub ok: bool, - /// The link's [`RemoteLink::kind_label`] when `ok` — which transport the - /// session actually got, since the fallback is invisible from the far end. #[serde(default)] pub link: Option<String>, - /// What the daemon actually did, and the reason it is an `Option`. - /// - /// A daemon built before [`RouteAction`] existed ignores the field it does - /// not know and *forwards* a restart request like any other route. Its ack - /// says `None`, which is the difference between "the server was restarted" - /// and "an older local daemon opened a link and told me nothing" — and the - /// client must not report the first when it got the second. #[serde(default)] pub action: Option<RouteAction>, #[serde(default)] @@ -291,7 +80,6 @@ pub struct RouteAck { } impl RouteHeader { - /// Route to an SSH host. pub fn ssh(spec: NativeSshSpec) -> RouteHeader { RouteHeader { target: RouteTarget::Ssh(Box::new(spec)), @@ -301,36 +89,21 @@ impl RouteHeader { } } - /// The same route, carrying one pane instead of the control dialect. pub fn for_pane(mut self) -> RouteHeader { self.channel = RouteChannel::Pane; self } - /// The same machine, but asking the daemon to replace the `tty7-server` - /// running there rather than to talk to it. - /// - /// The connection carries nothing afterwards: the ack is the whole - /// conversation. Callers must have a user's explicit "Restart Server" behind - /// this — every pane that machine hosts dies with the old daemon. pub fn restart_server(mut self) -> RouteHeader { self.action = RouteAction::RestartServer; self } - /// The same machine, asking the daemon to rewrite the `tty7-server` binary - /// this client's dialect names and restart onto it. Same warning as - /// [`restart_server`](Self::restart_server): every pane there dies. pub fn replace_server(mut self) -> RouteHeader { self.action = RouteAction::ReplaceServer; self } - /// Route to a WSL distribution on this machine. - /// - /// The name is not validated here: a header is data, and refusing it at - /// *decode* time on the daemon side (where [`open_link`] does validate) is - /// what keeps a malformed one from being a client-side panic. pub fn wsl(distro: impl Into<String>) -> RouteHeader { RouteHeader { target: RouteTarget::Wsl { @@ -342,7 +115,6 @@ impl RouteHeader { } } - /// Route to a child process on this machine (tests and CI). pub fn local_stdio(program: impl Into<String>, args: &[&str]) -> RouteHeader { RouteHeader { target: RouteTarget::LocalStdio { @@ -355,8 +127,6 @@ impl RouteHeader { } } - /// Write this header as the opening frame of a connection. The client's - /// half of the contract; the next thing to read is a [`RouteAck`]. pub fn write<W: Write>(&self, w: &mut W) -> io::Result<()> { let payload = serde_json::to_vec(self).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; @@ -364,12 +134,10 @@ impl RouteHeader { w.flush() } - /// Decode a `ROUTE_KIND` frame's payload. pub fn decode(payload: &[u8]) -> io::Result<RouteHeader> { serde_json::from_slice(payload).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e)) } - /// A short label for logs — never the whole spec, which carries secrets. pub fn describe(&self) -> String { match &self.target { RouteTarget::Ssh(spec) => format!("ssh {}@{}:{}", spec.user, spec.host, spec.port), @@ -380,28 +148,6 @@ impl RouteHeader { } impl RouteTarget { - /// **Which machine a routed connection is for**, as a string both sides of - /// the process boundary derive the same way. - /// - /// This is the router's answer to "who is asking?" — the question a relayed - /// prompt raises and that the client alone can act on. It is deliberately - /// *not* a [`HostId`](crate::host::HostId): the client reaches one machine - /// under several names (a saved profile, a `~/.ssh/config` alias, a typed - /// `user@host`), each with its own id, and the daemon knows none of them. It - /// knows the endpoint, so the endpoint is what it names; mapping that back to - /// whichever id *this* client filed the machine under is the client's job and - /// only the client can do it. - /// - /// For an SSH target the key is byte-identical to - /// [`ConnectionKey::from_spec`], which is also the label - /// [`MismatchedRemoteDaemon::host`] carries — so one lookup answers both - /// "whose password sheet is this?" and "which machine's server did the user - /// just ask to restart?". `the_origin_key_of_an_ssh_target_is_its_connection_key` - /// pins that. - /// - /// The `LocalStdio` key drops the arguments on purpose: the pane channel - /// appends `--pane` to them (`PaneWorkspace::route_header`) and it is still - /// the same machine. pub fn origin_key(&self) -> String { match self { RouteTarget::Ssh(spec) => ConnectionKey::from_spec(spec).as_str().to_string(), @@ -411,15 +157,6 @@ impl RouteTarget { } } -// --------------------------------------------------------------------------- -// Setup questions: the daemon asks, the client answers, before the ack. -// --------------------------------------------------------------------------- - -/// An [`InstallRequest`] in a form that survives a wire. -/// -/// `InstallRequest::asset` is a `&'static str` because on the producing side it -/// is always one of two consts; a decoder cannot promise that, so the wire form -/// carries a `String` and [`Self::into_request`] resolves it back. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct InstallRequestWire { pub host: String, @@ -444,8 +181,6 @@ impl InstallRequestWire { } } - /// Rebuild the request the daemon raised, so the client's handler sees - /// exactly the type [`InstallConfirm`] is written against. pub fn into_request(self) -> InstallRequest { InstallRequest { host: self.host, @@ -459,40 +194,26 @@ impl InstallRequestWire { } } -/// A question the daemon needs answered before it can open the link, or a fact -/// it needs the client to know. #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum RoutePrompt { - /// An interactive SSH decision — password, passphrase, - /// keyboard-interactive, host key. Carries the *same* [`AuthPromptKind`] a - /// pane's `DaemonMsg::AuthPrompt` does, so a client that can already render - /// one needs no new dialog. Auth { request_id: u64, prompt: AuthPromptKind, }, - /// "May tty7 write a server binary onto this machine?". Install { request_id: u64, request: Box<InstallRequestWire>, }, - /// The remote is serving at a different build than this client. Told, not - /// asked: the daemon keeps using it either way (it owns live panes), and the - /// keep-or-restart choice belongs to the GUI's own prompt, on its own - /// schedule. Fire-and-forget, so no `request_id`. Mismatch { daemons: Vec<MismatchedRemoteDaemon>, }, - /// How far the install this connection is performing has got. Told, not - /// asked, like `Mismatch` — but unlike it, **freely droppable**: these - /// arrive hundreds of times per install and each one supersedes the last, so - /// a client that misses some has lost nothing a later frame will not - /// correct. - InstallProgress { host: String, phase: InstallPhase }, + InstallProgress { + host: String, + phase: InstallPhase, + }, } -/// The client's answer to a [`RoutePrompt`]. #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum RouteReply { @@ -533,33 +254,10 @@ impl RouteReply { } } -/// Answers the interactive SSH questions a routed connection raises. -/// -/// The install prompt goes through [`InstallConfirm`], which the GUI already -/// registers; auth has no such registry because until now the only auth prompts -/// were a *pane's*, delivered on that pane's own stream and rendered by its -/// view. A routed connection has no pane yet, so it needs a handler that is not -/// attached to one. -/// -/// The default ([`CancelAuth`]) cancels, which is byte-for-byte the behaviour -/// this path had before the relay existed — a routed connection could only ever -/// use an agent, an unencrypted key, or a connection already authenticated for -/// something else. Registering a real one is what makes a password-protected -/// host reachable for a workspace. -/// -/// **`machine` is the connection's own target**, handed down from the -/// [`RouteHeader`] this negotiation opened with. A client with more than one -/// machine has to know which one is asking — to name it in the sheet, and to -/// queue one sheet per machine (D7) — and the header is the only -/// place that fact is certain. It used to be inferred from the answering -/// *thread*, which held for the workspace connect and silently did not for a -/// pane's (`connect_routed` never set it), leaving every routed pane prompt -/// attributed to nothing. pub trait RouteAuthResponder: Send + Sync { fn respond(&self, machine: &RouteTarget, prompt: &AuthPromptKind) -> AuthResponse; } -/// The default: no UI, so no answer. pub struct CancelAuth; impl RouteAuthResponder for CancelAuth { @@ -574,14 +272,12 @@ fn auth_responder_slot() -> &'static Mutex<Arc<dyn RouteAuthResponder>> { AUTH_RESPONDER.get_or_init(|| Mutex::new(Arc::new(CancelAuth))) } -/// Register the client-side answerer for routed auth prompts. Last call wins. pub fn set_route_auth_responder(responder: Arc<dyn RouteAuthResponder>) { if let Ok(mut slot) = auth_responder_slot().lock() { *slot = responder; } } -/// The registered answerer, or [`CancelAuth`]. pub fn route_auth_responder() -> Arc<dyn RouteAuthResponder> { auth_responder_slot() .lock() @@ -589,15 +285,6 @@ pub fn route_auth_responder() -> Arc<dyn RouteAuthResponder> { .unwrap_or_else(|_| Arc::new(CancelAuth)) } -/// Write `header` and drive the setup exchange until the daemon acks or refuses. -/// **The client's whole half of the routing contract.** -/// -/// Answers every question on the calling thread, so this blocks for as long as -/// the user takes — which is why callers run it on a background thread. The -/// handlers it consults are the process-wide ones -/// ([`crate::daemon::install::install_confirm`], [`route_auth_responder`]), -/// because in the GUI process there is one user and one set of dialogs; the -/// *daemon* side is what needs per-connection scoping, and gets it. pub fn negotiate<S>(stream: &mut S, header: &RouteHeader) -> io::Result<RouteAck> where for<'a> &'a mut S: Read + Write, @@ -613,9 +300,6 @@ where } } other => { - // A daemon that answered something else did not route this - // connection; surfacing its own error frame beats a decode - // failure. if let Ok(DaemonMsg::Error(e)) = DaemonMsg::from_frame(other, payload) { return Err(io::Error::other(e)); } @@ -628,11 +312,6 @@ where } } -/// Answer one setup question, or `None` when it wanted no answer. -/// -/// `machine` is the header's target — see [`RouteAuthResponder`] for why the -/// attribution comes from here and not from whichever thread happens to be -/// answering. fn answer(machine: &RouteTarget, prompt: RoutePrompt) -> Option<RouteReply> { match prompt { RoutePrompt::Auth { request_id, prompt } => { @@ -654,15 +333,10 @@ fn answer(machine: &RouteTarget, prompt: RoutePrompt) -> Option<RouteReply> { }) } RoutePrompt::Mismatch { daemons } => { - // Straight into *this* process's registry, which is the one the GUI - // drains — the whole point of relaying it. crate::daemon::install::record_remote_mismatches(daemons); None } RoutePrompt::InstallProgress { host, phase } => { - // Into this process's sink, which in the GUI is what the switcher - // reads. Same shape as `Mismatch`: relayed precisely so it lands on - // the side with a user on it. crate::daemon::install::install_progress().report(&host, phase); None } @@ -679,14 +353,6 @@ impl RouteAck { } } - /// The answer to a header that asked for a one-shot action: the machine's - /// server is this client's build again, and there is no link because there - /// is nothing more to say on this connection. - /// - /// Echoes back the action it performed rather than hard-coding one, because - /// that echo is exactly what [`RouteAck::performed`] checks — an ack naming - /// the wrong action would read to the client as an older daemon that - /// silently did something else. fn acted(action: RouteAction) -> RouteAck { RouteAck { ok: true, @@ -705,23 +371,13 @@ impl RouteAck { } } - /// Whether this ack is a daemon confirming it really performed `action`. - /// - /// `false` for the ack of a daemon too old to know the field — see - /// [`RouteAck::action`]. pub fn performed(&self, action: RouteAction) -> bool { self.ok && self.action == Some(action) } - /// Read the daemon's answer to a [`RouteHeader`] on a connection where no - /// setup question can arrive. Prefer [`negotiate`], which writes the header - /// and handles the questions too; this stays for the tests that assert on - /// the ack frame alone. pub fn read<R: io::Read>(r: &mut R) -> io::Result<RouteAck> { let (kind, payload) = protocol::read_frame(r)?; if kind != ROUTE_KIND { - // A daemon that answered something else did not route this - // connection; surfacing its own error frame beats a decode failure. if let Ok(DaemonMsg::Error(e)) = DaemonMsg::from_frame(kind, payload) { return Err(io::Error::other(e)); } @@ -733,7 +389,6 @@ impl RouteAck { RouteAck::from_payload(&payload) } - /// Decode an ack payload, turning a refusal into its reason. fn from_payload(payload: &[u8]) -> io::Result<RouteAck> { let ack: RouteAck = serde_json::from_slice(payload) .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; @@ -754,35 +409,15 @@ impl RouteAck { } } -/// Everything the daemon needs in order to *reach a user* while it sets a -/// routed connection up: the client on the other end of the very socket being -/// routed. -/// -/// Every field is scoped to one connection, which is the point — the statics -/// they stand in for ([`crate::daemon::install::set_install_confirm`], the -/// mismatch registry) are process-wide, and two workspaces connecting to two -/// machines at once would answer each other's questions. pub struct RouteSetup { - /// Interactive SSH decisions, in the shape the auth engine already speaks. pub broker: Arc<PromptBroker>, - /// Install consent, in the shape [`crate::daemon::install::Installer`] - /// already speaks. pub confirm: Arc<dyn InstallConfirm>, - /// Where that install's byte counts go. Separate from `confirm` even though - /// one `Relay` is both, because [`unattended`](RouteSetup::unattended) wants - /// a sink that discards and a confirm that refuses — two different defaults. pub progress: Arc<dyn InstallProgress>, - /// Where a build mismatch discovered during setup is collected, to be - /// handed to the client instead of to this process's registry. pub mismatches: Arc<Mutex<Vec<MismatchedRemoteDaemon>>>, - /// Which of the remote's two sockets this connection is for. pub channel: RouteChannel, } impl RouteSetup { - /// A setup that can answer nothing — the daemon's behaviour before the relay - /// existed, kept for callers with no client to ask (tests, and any future - /// link the daemon opens on its own initiative). pub fn unattended(channel: RouteChannel) -> RouteSetup { RouteSetup { broker: PromptBroker::new(Box::new(|_| false)), @@ -793,14 +428,6 @@ impl RouteSetup { } } - /// Run `f` on a blocking thread with this connection's consent handler and - /// mismatch sink in force. - /// - /// The wrapper is here rather than at each call site because forgetting - /// either half is silent: without the confirm scope the first install on a - /// machine fails with "was not confirmed" even though a user is sitting - /// right there, and without the sink scope the mismatch lands in a registry - /// only this process ever drains. pub async fn blocking<T, F>(&self, f: F) -> io::Result<T> where F: FnOnce() -> T + Send + 'static, @@ -821,8 +448,6 @@ impl RouteSetup { } } -/// The client-facing half of a [`RouteSetup`]: turns a question into a frame and -/// waits for the frame that answers it. struct Relay { out: tokio::sync::mpsc::UnboundedSender<(u8, Vec<u8>)>, pending: Mutex<HashMap<u64, std::sync::mpsc::SyncSender<bool>>>, @@ -830,8 +455,6 @@ struct Relay { } impl Relay { - /// Hand a client's answer to whoever is blocked on it. Unknown ids are a - /// late reply to a question that already timed out; dropping them is right. fn fulfil(&self, request_id: u64, approve: bool) { if let Ok(mut pending) = self.pending.lock() && let Some(tx) = pending.remove(&request_id) @@ -848,9 +471,6 @@ impl Relay { } impl InstallConfirm for Relay { - /// **Called on a blocking thread, never on a runtime worker** — see - /// [`RouteSetup::blocking`]. `Installer` is blocking start to finish, and - /// this is the one point in it that waits on a human. fn confirm(&self, request: &InstallRequest) -> InstallDecision { let request_id = self.next_id.fetch_add(1, Ordering::Relaxed); let (tx, rx) = std::sync::mpsc::sync_channel(1); @@ -874,8 +494,6 @@ impl InstallConfirm for Relay { match rx.recv_timeout(REPLY_TIMEOUT) { Ok(true) => InstallDecision::Approve, - // Timeout, hangup, or an explicit no. All three mean the same thing - // here, and the safe reading of "no answer" is not to write. _ => { self.forget(request_id); InstallDecision::Decline @@ -885,14 +503,6 @@ impl InstallConfirm for Relay { } impl InstallProgress for Relay { - /// Fire-and-forget onto the outbox, with no reply to wait for and no error - /// path — the send fails only once the client has gone, and an install that - /// nobody is watching any more should carry on rather than abort over a - /// progress frame. - /// - /// The outbox is unbounded, so this never blocks the thread pushing bytes - /// over SFTP. The frames are small (tens of bytes) and the writer drains - /// them between transfer chunks. fn report(&self, host: &str, phase: InstallPhase) { let prompt = RoutePrompt::InstallProgress { host: host.to_string(), @@ -904,52 +514,30 @@ impl InstallProgress for Relay { } } -/// The forwarding hub. Stateless: a routed connection's only state is the two -/// halves of the pipe, and both die with it. pub struct RemoteRouter; impl RemoteRouter { - /// Take over `local` — a connection whose opening frame was a - /// [`RouteHeader`] — and forward it to the machine the header names, until - /// either end stops. - /// - /// Returns once the pipe closes. Errors describe *this* hop only; a failure - /// on the far side arrives as an end of stream, exactly as it would have if - /// the GUI had been connected to the remote directly. pub fn route(local: Stream, header: &RouteHeader) -> io::Result<()> { - // The daemon's only tokio runtime. Blocking on it from a connection - // thread is the same crossing the SFTP layer makes - // (`SshManager::handle`); these threads are never runtime workers. SshManager::global().handle().block_on(drive(local, header)) } } -/// Set the connection up (asking the client whatever has to be asked), ack, then -/// forward until either end stops. async fn drive(local: Stream, header: &RouteHeader) -> io::Result<()> { let mut local = into_async(local)?; let (out, mut outbox) = tokio::sync::mpsc::unbounded_channel::<(u8, Vec<u8>)>(); let emitter = out.clone(); - // Built as itself first: the reader half has to fulfil the install prompts - // the relay issued, which `dyn InstallConfirm` cannot express. let relay = Arc::new(Relay { out, pending: Mutex::new(HashMap::new()), next_id: AtomicU64::new(1), }); let setup = RouteSetup { - // `PromptBroker`'s contract is "did a subscriber receive this frame"; - // here the subscriber is the client socket, and a queued frame is one - // the loop below will write before it waits on anything else. broker: PromptBroker::new(Box::new(move |msg| match msg { DaemonMsg::AuthPrompt { request_id, prompt } => { serde_json::to_vec(&RoutePrompt::Auth { request_id, prompt }) .is_ok_and(|payload| emitter.send((ROUTE_PROMPT_KIND, payload)).is_ok()) } - // Spawn-progress frames have no place to land on a connection with - // no pane behind it yet. Reported as delivered so a status update - // never stalls the auth flow retrying it. _ => true, })), confirm: relay.clone(), @@ -958,8 +546,6 @@ async fn drive(local: Stream, header: &RouteHeader) -> io::Result<()> { channel: header.channel, }; - // `None` = the connection's whole purpose was the setup window (a restart), - // so there is nothing to pipe and nothing left over. let Some((mut link, conn, leftover)) = ({ let (mut read_half, mut write_half) = local.split(); let mut frames = FrameReader::default(); @@ -967,8 +553,6 @@ async fn drive(local: Stream, header: &RouteHeader) -> io::Result<()> { let opened = loop { tokio::select! { - // Biased so a finished setup always wins: once the link is open - // (or refused) nothing else on this connection matters. biased; result = &mut opening => break result, Some((kind, payload)) = outbox.recv() => { @@ -981,8 +565,6 @@ async fn drive(local: Stream, header: &RouteHeader) -> io::Result<()> { } }; - // A mismatch is told, not asked, and it goes out before the ack so the - // client has it in hand the moment the connection is usable. let found = std::mem::take(&mut *setup.mismatches.lock().unwrap_or_else(|e| e.into_inner())); if !found.is_empty() @@ -1000,17 +582,8 @@ async fn drive(local: Stream, header: &RouteHeader) -> io::Result<()> { ); let payload = ack_payload(&RouteAck::ok(&link))?; write_frame(&mut write_half, ROUTE_KIND, &payload).await?; - // Anything the client pipelined behind its last answer is the - // remote's, not ours. In practice this is empty (the client - // waits for the ack before it speaks the far end's dialect), - // but dropping it would be a silent truncation. Some((link, conn, frames.into_buffer())) } - // The restart: the daemon that would have been on the other - // end of this pipe is the one that was just replaced, so the ack is - // the last thing this connection carries. The client reconnects to - // the new one on its own — the supervisor's reconnect is already the - // path for "the machine's server went away". Ok(Performed::Acted(action)) => { log::info!("performed {action:?} on {}", header.describe()); let payload = ack_payload(&RouteAck::acted(action))?; @@ -1034,19 +607,14 @@ async fn drive(local: Stream, header: &RouteHeader) -> io::Result<()> { } let (to_remote, to_local) = tokio::io::copy_bidirectional(&mut local, &mut *link).await?; log::debug!("routed connection closed after {to_remote} up / {to_local} down bytes"); - // Held to here on purpose: the connection is shared, and dropping the last - // `Arc` is what tears the SSH transport down. drop(conn); Ok(()) } -/// Encode an ack. fn ack_payload(ack: &RouteAck) -> io::Result<Vec<u8>> { serde_json::to_vec(ack).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e)) } -/// Frame-write on an async half, matching [`protocol::write_frame`] byte for -/// byte. One `write_all` so a frame is never interleaved with another. async fn write_frame<W>(w: &mut W, kind: u8, payload: &[u8]) -> io::Result<()> where W: tokio::io::AsyncWrite + Unpin, @@ -1066,12 +634,6 @@ where w.flush().await } -/// A cancel-safe frame reader. -/// -/// The buffer lives outside the future on purpose: [`drive`]'s `select!` drops -/// the read future every time a prompt goes out, and a reader that held partial -/// bytes on its own stack would lose them. Bytes accumulate here instead, and -/// whatever is left when setup ends belongs to the remote. #[derive(Default)] struct FrameReader { buf: Vec<u8>, @@ -1104,7 +666,6 @@ impl FrameReader { } } -/// Route one frame the client sent during setup. fn deliver(kind: u8, payload: &[u8], setup: &RouteSetup, relay: &Relay) { if kind != ROUTE_REPLY_KIND { log::debug!("ignoring kind {kind} during route setup"); @@ -1123,21 +684,11 @@ fn deliver(kind: u8, payload: &[u8], setup: &RouteSetup, relay: &Relay) { } } -/// What the setup phase produced: a pipe to hold open, or a finished action. enum Performed { - /// The link and the connection that has to outlive it. Boxed because a - /// `RemoteLink` is two orders of magnitude larger than the other variant. Linked(Box<RemoteLink>, Option<Arc<SshConnection>>), - /// A one-shot action ran. Nothing is left to forward. Acted(RouteAction), } -/// Do what the header asks — open a link, or carry out a one-shot action. -/// -/// Both branches run *inside* the setup window, so both can ask the client -/// whatever they need (a password for a machine whose connection has since -/// dropped, most obviously) and both report failure as a reason on the ack -/// rather than as a closed socket. async fn perform(header: &RouteHeader, setup: &RouteSetup) -> anyhow::Result<Performed> { match header.action { RouteAction::Forward => { @@ -1151,14 +702,6 @@ async fn perform(header: &RouteHeader, setup: &RouteSetup) -> anyhow::Result<Per } } -/// "Restart Server", on the side of the boundary that holds -/// the connection. -/// -/// SSH only, and that is not a gap being deferred: a WSL distribution's server -/// is started by this very process ([`crate::daemon::install::wsl`]) and a -/// `LocalStdio` "machine" is a child process spawned per connection — neither -/// has a long-lived remote daemon that could be replaced, so neither can raise -/// the mismatch prompt this action answers. Saying so beats a silent success. async fn restart_server( header: &RouteHeader, setup: &RouteSetup, @@ -1182,7 +725,6 @@ async fn restart_server( } } -/// Open the link the header asks for. async fn open_link( header: &RouteHeader, setup: &RouteSetup, @@ -1194,16 +736,6 @@ async fn open_link( .await?; Ok((link, Some(conn))) } - // WSL: no connection object, so nothing is returned to - // hold open — the link *is* the child process, and dropping it reaps it. - // - // `ensure_wsl_server` runs first, on a blocking thread: it is several - // `wsl.exe` round trips on a cold distribution and it may stop to ask - // the user. It is deliberately the same shape as the SSH side's - // `ensure_remote_server` — a `?` here means no link is opened at all, so - // "this distribution has no tty7-server and one could not be installed" - // arrives as a route ack with a reason instead of as a stream that never - // speaks. RouteTarget::Wsl { distro } => { let resolved = match header.server_command { Some(_) => None, @@ -1218,9 +750,6 @@ async fn open_link( ) } }; - // `setup.channel`, not `Control`: which of the remote's two sockets - // this stream is for is carried by the header, and WSL is the one - // transport that builds its own argv — see [`RemoteLink::wsl`]. let link = match (header.server_command.as_deref(), resolved.as_deref()) { (Some(command), _) => RemoteLink::wsl_shell(distro, command, setup.channel)?, (None, Some(binary)) => RemoteLink::wsl(distro, binary, setup.channel)?, @@ -1235,8 +764,6 @@ async fn open_link( } } -/// Hand the accepted connection to tokio. The socket is blocking (every other -/// daemon connection is read that way), and tokio requires it not to be. #[cfg(unix)] fn into_async(local: Stream) -> io::Result<tokio::net::UnixStream> { local.set_nonblocking(true)?; @@ -1253,8 +780,6 @@ fn into_async(local: Stream) -> io::Result<tokio::net::TcpStream> { mod tests { use super::*; - /// The header survives the wire, and the ack comes back through the same - /// framing the rest of the protocol uses. #[test] fn a_header_round_trips_through_a_frame() { let header = RouteHeader::local_stdio("cat", &["-u"]); @@ -1274,9 +799,6 @@ mod tests { assert_eq!(back.server_command, None); } - /// A WSL header carries the distro name and nothing else — no user, no - /// port, no credentials, because none of those exist for a distribution on - /// this machine (D9). #[test] fn a_wsl_header_round_trips_with_only_a_distro_name() { let mut buf = Vec::new(); @@ -1291,25 +813,10 @@ mod tests { assert_eq!(back.server_command, None); assert_eq!(back.describe(), "wsl Ubuntu-22.04"); - // The wire tag is `wsl`, and it is what a *different* build of the - // daemon will match on — so it is pinned rather than left to the enum's - // variant name. let json = String::from_utf8(payload).unwrap(); assert!(json.contains(r#""wsl""#), "{json}"); } - /// **The WSL branch is really wired**, and a distro name `wsl.exe` would - /// misread as an option never reaches a process spawn: the route fails with - /// an argument error rather than running an unintended `wsl.exe` command. - /// - /// Both of the branch's two paths are covered, because they validate in - /// different places — the default path inside `ensure_wsl_server`, the - /// `server_command` override inside `RemoteLink::wsl_shell`. - /// - /// Deterministic on every platform, which is why the assertion is on the - /// *reason* and not on "the spawn failed": `wsl.exe` ships with Windows even - /// when no distribution is installed, so a spawn of a nonexistent distro - /// succeeds there and fails here. Validation happens before either. #[tokio::test] async fn a_wsl_route_refuses_a_distro_name_that_could_be_an_option() { for header in [ @@ -1333,8 +840,6 @@ mod tests { } } - /// A refused route is a *reason*, not a closed socket — the one thing the - /// ack exists for. #[test] fn a_failed_route_reports_why() { let mut buf = Vec::new(); @@ -1345,9 +850,6 @@ mod tests { assert!(err.to_string().contains("no such host"), "{err}"); } - /// A successful ack names the transport, because the fallback is otherwise - /// invisible: a session that silently took the `--stdio` bridge behaves the - /// same and diagnoses very differently. #[tokio::test] async fn a_successful_ack_names_the_transport() { let link = RemoteLink::local_stdio("cat", &[]).unwrap(); @@ -1357,8 +859,6 @@ mod tests { assert_eq!(ack.link.as_deref(), Some("local-stdio")); } - /// An old daemon answers a route frame with its own `Error`, and the client - /// should read that rather than "expected a route ack". #[test] fn a_daemons_error_frame_is_surfaced_verbatim() { let mut buf = Vec::new(); @@ -1372,13 +872,6 @@ mod tests { ); } - /// **Pure forwarding.** Bytes that are not frames, not UTF-8, and not - /// anything the protocol knows go through the router untouched and come back - /// untouched — including a payload far larger than any copy buffer, so the - /// loop is doing real work rather than passing one buffer along. - /// - /// `cat` stands in for the remote server precisely because it has no - /// dialect: if the router understood the stream at all, this could not work. #[test] #[cfg(unix)] fn the_router_forwards_bytes_it_cannot_parse() { @@ -1394,8 +887,6 @@ mod tests { let ack = RouteAck::read(&mut client_read).expect("routed"); assert_eq!(ack.link.as_deref(), Some("local-stdio")); - // A deliberate non-frame: a length prefix claiming more than MAX_FRAME, - // an unknown kind, invalid UTF-8. Anything that parsed this would fail. let mut garbage: Vec<u8> = vec![0xff, 0xff, 0xff, 0xff, 0xfe, 0x00, 0x80, 0xc3, 0x28]; garbage.extend((0..512 * 1024u32).map(|i| (i % 256) as u8)); @@ -1414,8 +905,6 @@ mod tests { routed.join().unwrap().expect("clean close"); } - /// A route to something unspawnable fails with its reason instead of - /// leaving the client parked on a socket that will never speak. #[test] #[cfg(unix)] fn an_unopenable_link_fails_the_route() { @@ -1431,16 +920,6 @@ mod tests { assert!(routed.join().unwrap().is_err()); } - // ----------------------------------------------------------------------- - // The setup relay (gap B): questions raised in the daemon, answered by the - // client on the other end of the socket being routed. - // ----------------------------------------------------------------------- - - /// Serializes the tests that swap the process-wide auth responder. - /// - /// [`set_route_auth_responder`] is last-call-wins by design — the GUI - /// process has one user — so two tests holding it at once would answer each - /// other's prompts, which is a flake that only shows up under load. fn responder_lock() -> std::sync::MutexGuard<'static, ()> { static LOCK: Mutex<()> = Mutex::new(()); LOCK.lock().unwrap_or_else(|e| e.into_inner()) @@ -1458,12 +937,6 @@ mod tests { } } - /// The install request survives the wire, `&'static str` field and all. - /// - /// The asset name is the interesting part: it is `&'static str` on the - /// producing side and cannot be one on the decoding side, so a round trip is - /// the only thing that proves the prompt the user reads names the same - /// binary the daemon is about to write. #[test] fn an_install_request_round_trips_through_a_prompt() { let original = a_request(); @@ -1488,9 +961,6 @@ mod tests { } } - /// **The daemon half of gap B.** `Installer` asks the way it always has, the - /// question leaves as a frame, an answer arrives as a frame, and the - /// blocked installer gets it. #[test] fn the_relay_turns_a_consent_question_into_a_frame_and_back() { let (out, mut outbox) = tokio::sync::mpsc::unbounded_channel(); @@ -1500,7 +970,6 @@ mod tests { next_id: AtomicU64::new(1), }); - // `confirm` blocks, exactly as it does on the installer's thread. let asking = { let relay = relay.clone(); std::thread::spawn(move || relay.confirm(&a_request())) @@ -1516,12 +985,10 @@ mod tests { assert_eq!(asking.join().unwrap(), InstallDecision::Approve); } - /// No answer is a *decline*, and it is the answer a hung-up client gets too. - /// The safe direction has to be the one that happens by accident. #[test] fn an_unanswerable_consent_question_declines() { let (out, outbox) = tokio::sync::mpsc::unbounded_channel(); - drop(outbox); // nobody is reading — the client is gone + drop(outbox); let relay = Relay { out, pending: Mutex::new(HashMap::new()), @@ -1530,10 +997,6 @@ mod tests { assert_eq!(relay.confirm(&a_request()), InstallDecision::Decline); } - /// **The client half of gap B.** `negotiate` answers the question with the - /// handler this process registered, then reads the ack — which is what makes - /// `set_install_confirm` (registered in the GUI) reachable from an install - /// running in the daemon. #[test] #[cfg(unix)] fn negotiate_answers_a_consent_question_and_then_takes_the_ack() { @@ -1542,7 +1005,6 @@ mod tests { struct Approve; impl InstallConfirm for Approve { fn confirm(&self, request: &InstallRequest) -> InstallDecision { - // The prompt the user would read is the one the daemon raised. assert_eq!(request.host, "me@build-box:22"); assert_eq!(request.asset, crate::daemon::install::asset::ASSET_AARCH64); InstallDecision::Approve @@ -1551,7 +1013,6 @@ mod tests { let (client, daemon) = UnixStream::pair().unwrap(); - // The daemon's side: ask, then ack. let daemon = std::thread::spawn(move || { let mut daemon = daemon; let (kind, payload) = protocol::read_frame(&mut daemon).unwrap(); @@ -1590,7 +1051,6 @@ mod tests { }); let mut client = client; - // Scoped, so this test cannot decide anything for a concurrent one. let ack = crate::daemon::install::with_install_confirm(Arc::new(Approve), || { negotiate(&mut client, &RouteHeader::local_stdio("x", &[]).for_pane()) }) @@ -1599,11 +1059,6 @@ mod tests { assert_eq!(daemon.join().unwrap(), (3, true)); } - /// The auth relay: a password question raised on a routed connection reaches - /// the registered responder and its answer goes back on the same socket. - /// - /// Before this, `router.rs` cancelled every prompt outright, so a host - /// without an agent or an unencrypted key simply could not back a workspace. #[test] #[cfg(unix)] fn negotiate_answers_an_auth_question() { @@ -1662,9 +1117,6 @@ mod tests { } } - /// The default responder still cancels, so a build with no UI attached - /// behaves exactly as it did before the relay: the auth step fails cleanly - /// instead of hanging on a question nobody can see. #[test] fn the_default_auth_responder_cancels() { assert!(matches!( @@ -1681,11 +1133,6 @@ mod tests { )); } - /// A scoped mismatch sink takes the record instead of the process-wide - /// registry — the whole reason the daemon can hand one to the client that - /// asked rather than filing it where only it can read. - /// - /// Deliberately never touches the global registry, which another test owns. #[test] fn a_scoped_mismatch_sink_diverts_the_record() { let sink = Arc::new(Mutex::new(Vec::new())); @@ -1706,8 +1153,6 @@ mod tests { ); } - /// A scoped confirm handler outranks the process-wide one and is put back - /// afterwards. Two routed connections must not answer for each other. #[test] fn a_scoped_confirm_handler_outranks_the_global_and_restores() { struct Yes; @@ -1725,9 +1170,6 @@ mod tests { assert_eq!(after, before, "the previous handler is back"); } - /// A pane route and a control route are different requests, and the default - /// on the wire stays `control` so a header written before the field existed - /// still means what it meant. #[test] fn the_channel_defaults_to_control_and_survives_the_wire() { let header = RouteHeader::local_stdio("cat", &[]); @@ -1748,16 +1190,6 @@ mod tests { ); } - // ----------------------------------------------------------------------- - // The restart action: the one thing on this connection that - // travels client → daemon. - // ----------------------------------------------------------------------- - - /// A restart request survives the wire, and — the part that matters for - /// every *existing* deployment — a header written before the field existed - /// still means `Forward`. Bumping `PROTOCOL_VERSION` for this would have put - /// a "Restart Daemon?" prompt in front of every user in the world, so the - /// field has to be additive in fact and not just in intent. #[test] fn the_action_defaults_to_forward_and_survives_the_wire() { let header = RouteHeader::local_stdio("cat", &[]); @@ -1770,7 +1202,6 @@ mod tests { RouteHeader::decode(&payload).unwrap().action, RouteAction::RestartServer ); - // The wire tag is what a *different* build matches on, so it is pinned. assert!( String::from_utf8(payload) .unwrap() @@ -1784,10 +1215,6 @@ mod tests { assert_eq!(back.channel, RouteChannel::Pane, "and nothing else moved"); } - /// **An older daemon must not look like a successful restart.** It ignores - /// the field it does not know and forwards the connection, acking a link; - /// `performed` is what keeps the client from reporting a restart that never - /// happened. #[test] fn an_ack_without_an_action_is_not_a_restart() { let legacy = br#"{"ok":true,"link":"session-exec"}"#; @@ -1796,8 +1223,6 @@ mod tests { assert!(!ack.performed(RouteAction::RestartServer)); assert!(RouteAck::acted(RouteAction::RestartServer).performed(RouteAction::RestartServer)); - // A forwarding ack is not one either, which is the same daemon answering - // a header whose action it *did* understand. let forwarded = RouteAck { ok: true, link: Some("session-exec".into()), @@ -1808,10 +1233,6 @@ mod tests { assert!(forwarded.performed(RouteAction::Forward)); } - /// A restart aimed at something with no remote daemon to replace is refused - /// with its reason, and **nothing is spawned** — the alternative shape, where - /// the action quietly falls back to opening a link, would report success - /// over a server still running the old build. #[tokio::test] async fn a_restart_is_refused_for_a_machine_that_has_no_remote_daemon() { for header in [ @@ -1827,11 +1248,6 @@ mod tests { } } - /// **The restart route is a setup window and nothing else.** The client gets - /// an ack naming the action, the connection closes, and no link is opened — - /// which is checked here by pointing the route at a machine whose "restart" - /// cannot work: a forwarding router would have spawned `cat` and sat there - /// copying bytes instead of answering. #[test] #[cfg(unix)] fn a_restart_route_answers_and_closes_without_forwarding() { @@ -1847,13 +1263,6 @@ mod tests { assert!(routed.join().unwrap().is_err()); } - /// **The origin key is the same string on both sides of the boundary.** - /// - /// The daemon labels a mismatch record with the connection key - /// (`install::connection_label`) and the client resolves "which machine is - /// this prompt about?" from the route target — so if these two ever stop - /// agreeing, a relayed password sheet loses its machine and the - /// keep-or-restart answer has nowhere to go, both silently. #[test] fn the_origin_key_of_an_ssh_target_is_its_connection_key() { let spec: NativeSshSpec = serde_json::from_str( @@ -1868,8 +1277,6 @@ mod tests { .to_string() ); - // A pane header and a control header for one machine name it the same, - // including the `--stdio` case where the argv differs by `--pane`. let control = RouteTarget::LocalStdio { program: "/opt/tty7-server".into(), args: vec!["--stdio".into()], @@ -1888,9 +1295,6 @@ mod tests { ); } - /// **A relayed prompt is attributed to the header's machine**, not to - /// whatever the answering thread believes. Both routed paths write a header; - /// only one of them ever set the thread-local this replaced. #[test] #[cfg(unix)] fn a_relayed_prompt_names_the_machine_from_the_header() { @@ -1933,7 +1337,6 @@ mod tests { let recorder = Arc::new(Recorder::default()); set_route_auth_responder(recorder.clone()); let mut client = client; - // A *pane* header — the path that set no attribution at all before. negotiate(&mut client, &RouteHeader::wsl("Ubuntu-22.04").for_pane()).expect("acked"); set_route_auth_responder(Arc::new(CancelAuth)); daemon.join().unwrap(); @@ -1941,9 +1344,6 @@ mod tests { assert_eq!(recorder.0.lock().unwrap().as_slice(), ["wsl:Ubuntu-22.04"]); } - /// The pane channel asks the far side for the *pane* socket, and the control - /// channel is left byte-identical — a remote whose daemon predates this must - /// keep serving control connections unchanged. #[test] fn only_the_pane_channel_changes_the_bridge_command() { let base = crate::daemon::remote_link::DEFAULT_REMOTE_SERVER_CMD; diff --git a/crates/tty7-core/src/daemon/server.rs b/crates/tty7-core/src/daemon/server.rs index 939d8848..b8a39583 100644 --- a/crates/tty7-core/src/daemon/server.rs +++ b/crates/tty7-core/src/daemon/server.rs @@ -1,22 +1,3 @@ -//! Daemon server: the Unix-domain-socket listener, pane registry, and `--daemon` -//! entry point. -//! -//! One process hosts many panes ([`DaemonPane`]); one socket connection drives one -//! pane (matching the protocol's "one connection = one pane" model). The server: -//! 1. resolves the socket path under the (config-dir-aware) config directory, -//! so `cargo dev` / `--config-dir` isolation reaches the daemon too; -//! 2. clears a *stale* socket (one that nothing is listening on) before binding; -//! 3. accepts connections, spawning a thread per connection. -//! -//! Per-connection flow (see [`handle_conn`]): read the first `ClientMsg`. -//! - `Spawn` → create a pane, reply `Spawned`, attach this connection, stream. -//! - `Attach` → look the pane up; on hit attach + stream, on miss reply `Error`. -//! - `List` → reply `PaneList`, then close. -//! While streaming, a small writer thread drains the pane's `DaemonMsg` channel to -//! the socket, while the main connection thread reads further client messages -//! (`Input` / `Resize` / `Detach` / `Kill`). Connection close == detach (the pane -//! keeps running headless). - use std::collections::HashMap; use std::io::Write; use std::sync::atomic::{AtomicU64, Ordering}; @@ -28,7 +9,6 @@ use crate::daemon::protocol::{ClientMsg, DaemonMsg, DaemonVersion, RemoteKind}; use crate::daemon::ssh::SshConnection; use crate::daemon::transport::{self, Stream}; -/// Shared pane registry: id → pane, plus a monotonic id source. struct Registry { panes: Mutex<HashMap<u64, Arc<DaemonPane>>>, next_id: AtomicU64, @@ -46,10 +26,6 @@ impl Registry { self.next_id.fetch_add(1, Ordering::Relaxed) } - /// Never mint an id `machine`'s tree already names — see the caller in - /// [`run`] for the aliasing failures this closes. The registry and the - /// leaves are checked both: a pane record can outlive its leaf briefly, - /// and either one aliased is one too many. fn seed_ids_past(&self, machine: &crate::core::machine::Machine) { let max = machine .panes @@ -64,12 +40,7 @@ impl Registry { ) .max() .unwrap_or(0); - // Saturating: a tree (or a hostile seed) naming u64::MAX must not - // panic the daemon at startup. The counter parking at the ceiling is - // a bounded absurdity; overflowing is a dead process. let next = max.saturating_add(1); - // fetch_max rather than store: harmless today (this runs before any - // spawn), but a seed must never move the counter backwards. let before = self.next_id.fetch_max(next, Ordering::Relaxed); if next > before { log::info!("pane ids start at {next} (the tree names panes up to {max})"); @@ -84,18 +55,10 @@ impl Registry { self.panes.lock().unwrap().get(&id).cloned() } - /// Remove a pane from the registry (its `Arc` drop hangs up + reaps the child - /// once the last connection releases it). fn remove(&self, id: u64) -> Option<Arc<DaemonPane>> { self.panes.lock().unwrap().remove(&id) } - /// Remove every pane and hang up its child. Used by the `Shutdown` control - /// message right before the process exits: the children must be signalled now - /// (SIGHUP → SIGKILL, via `pane.kill()`), or the exit would orphan them — - /// reparented to launchd and still holding their PTYs — instead of ending the - /// session cleanly. Drains under the lock, then kills with the lock released - /// so a pane's teardown can't deadlock against the registry. fn drain_and_kill(&self) { let panes: Vec<Arc<DaemonPane>> = { let mut guard = self.panes.lock().unwrap(); @@ -106,7 +69,6 @@ impl Registry { } } - /// Snapshot of all panes' metadata for `List`. fn list(&self) -> Vec<crate::daemon::protocol::PaneInfo> { self.panes .lock() @@ -117,21 +79,8 @@ impl Registry { } } -/// How often the orphan sweep looks, which doubles as its grace period: a pane -/// is only reported after it has been unreferenced across two consecutive -/// looks, so a freshly-spawned pane whose adopting operation is still in -/// flight is never flagged. const ORPHAN_SWEEP_INTERVAL: std::time::Duration = std::time::Duration::from_secs(600); -/// Periodically report live panes the machine tree does not reference. -/// -/// **Log-only, on purpose.** An unreferenced pane is not proof of a leak: -/// a native-SSH pane opened inside a *remote* workspace's window runs in this -/// (the client's) daemon while belonging to the other machine's tree, so it is -/// unreferenced here by design — and a reclaim would kill a session the user -/// is looking at. Until the tree provably references everything legitimate, -/// the sweep's job is to make leaks observable, not to act on them; killing -/// can be layered on once the log has shown the false-positive rate is zero. fn spawn_orphan_sweep(registry: Arc<Registry>) { let spawned = std::thread::Builder::new() .name("tty7-orphan-sweep".into()) @@ -139,7 +88,6 @@ fn spawn_orphan_sweep(registry: Arc<Registry>) { let mut previous: std::collections::HashSet<u64> = std::collections::HashSet::new(); loop { std::thread::sleep(ORPHAN_SWEEP_INTERVAL); - // No tree served (a pane-only daemon) means no opinion. let Some(store) = crate::core::machine::observed_store() else { continue; }; @@ -170,10 +118,6 @@ fn spawn_orphan_sweep(registry: Arc<Registry>) { } } -/// Resolve a pane id to its live native-SSH connection, for the SFTP control -/// handlers. Errors (as a client-facing string) when the pane is unknown or isn't -/// a native-SSH pane with an established connection (a PTY / compat-`ssh` pane, or -/// one still authenticating). fn ssh_connection_for( registry: &Registry, pane_id: u64, @@ -186,33 +130,7 @@ fn ssh_connection_for( }) } -/// Run the *whole* daemon — panes **and** control — until killed. The one -/// entry point behind both `tty7 --daemon` and `tty7-server --daemon`. -/// -/// Local and remote are deliberately the same shape: a machine is a machine, -/// whether the client sits on it or an ocean away, and the design's terminal -/// state is "one machine = one daemon = one workspace tree". That tree is -/// served over the control dialect, so the *local* daemon has to speak it too — -/// which is why this lives here rather than staying a `tty7-server` detail. -/// -/// Control comes up first, and on its own thread: a machine that cannot host -/// panes (no pty, a locked-down container) should still be able to back a -/// workspace's files, so a control failure is logged and stepped over rather -/// than being fatal. The pane listener then owns this thread until the process -/// is killed, exactly as [`run`] always has. -/// -/// Both platforms serve it, over the transport each one's pane socket already -/// uses: a Unix-domain socket gated by its file permissions, or a loopback -/// `TcpListener` gated by the token in a user-private marker file. The tree is -/// what a client's layout *is* now, so a platform without a control listener is -/// a platform where tabs do not come back — which is not a difference a build -/// gets to have. pub fn run_daemon() -> anyhow::Result<()> { - // Reported on **stderr**, not only the log: a headless server's log file is - // off unless `TTY7_LOG` asks for it, and the bound path is this daemon's - // one observable answer to "where do I connect". The remote-router test - // reads this exact line back to prove the client's derivation and the - // server's bind agree, so the prefix is part of the contract. #[cfg(any(unix, windows))] match crate::host::server::spawn_control_listener_with( crate::host::local::LocalHost::shared(), @@ -227,31 +145,11 @@ pub fn run_daemon() -> anyhow::Result<()> { run() } -/// What this machine offers over a control connection, beyond its filesystem. -/// -/// The machine tree is why a daemon serves control at all: the workspace -/// list, the tab/pane tree and each pane's facts live on **the machine the -/// panes run on**, so that every client of this machine — the GUI on it, a -/// laptop across the world — sees the same thing. Clients keep only their own -/// view state. -/// -/// A machine with no home directory to place the file in still serves files -/// and panes — it simply omits `machine-tree` from its capabilities, and -/// clients see the same "does not serve the machine tree" answer a server -/// without one has always given. pub fn control_services() -> crate::host::server::Services { use crate::core::machine::MachineStore; - // Reported on stderr as well as the log, like the socket line in - // [`run_daemon`]: on a headless box the log file is off by default, and - // "does this daemon actually serve the tree" is the first question a - // capability mismatch raises. match MachineStore::shared() { Ok(machine) => { eprintln!("machine tree at {}", machine.path().display()); - // From here on the pane server's own observations — OSC 7 cwds, - // agent identities, deaths — land on the tree's pane records, so - // what a client revives from is what the machine saw, not what - // some client last remembered to write. crate::core::machine::publish_observations(&machine); crate::host::server::Services::with_machine(machine) } @@ -262,13 +160,7 @@ pub fn control_services() -> crate::host::server::Services { } } -/// Run the daemon: bind the socket and serve connections forever. Returns `Err` -/// only on a fatal setup failure (bad socket path, bind error); the accept loop -/// itself runs until the process is killed. pub fn run() -> anyhow::Result<()> { - // If an endpoint marker is already there, it's either a live daemon (we should - // bail) or a stale leftover from a crash (we should clear it and take over). - // Probe by connecting: success means someone's listening — don't double-run. if transport::endpoint_exists() { match transport::connect() { Ok(_) => { @@ -278,8 +170,6 @@ pub fn run() -> anyhow::Result<()> { ); } Err(_) => { - // Nothing listening: stale endpoint from a previous run. Clear it so - // `bind` below can recreate it. transport::remove_stale_endpoint(); } } @@ -288,68 +178,38 @@ pub fn run() -> anyhow::Result<()> { let listener = transport::bind()?; log::info!("daemon listening on {}", transport::endpoint_display()); - // Record who owns this endpoint. If this process later becomes unreachable - // (wedged, or a protocol the client no longer speaks), the client's takeover - // paths read this back and reap us instead of stranding our panes. crate::daemon::pidfile::write_current(); let registry = Arc::new(Registry::new()); - // Reap-by-signal path: a client that can't reach us over the socket sends - // SIGTERM (see `spawn::reap_recorded_daemon`). Tear down exactly like the - // `Shutdown` message — every pane's child gets the SIGHUP-grace-SIGKILL - // treatment — rather than dying with the default action, which would only - // HUP each PTY's foreground group and leave background jobs behind. #[cfg(unix)] serve_sigterm(registry.clone()); - // Pane ids must never alias across restarts: the persisted tree still - // names the previous process's panes, and a fresh process minting from 1 - // would hand a new shell an id some dead leaf claims — at which point the - // record's `live` flag flips back on for the wrong pane, revival stalls on - // "pane N is already part of this machine's tree", and a window attaching - // by the stale id steals an unrelated workspace's stream. Starting past - // everything the tree knows makes the id a name, not a slot. if let Some(store) = crate::core::machine::observed_store() { registry.seed_ids_past(&store.machine()); - // And let the store ask *us* whether a seeded pane is still alive at - // registration time — the pane that dies between its spawn and its - // adopting operation would otherwise be filed `live: true` with its - // death observation already dropped, and nothing left to flip it. let probe = registry.clone(); store.set_liveness_probe(Arc::new(move |id| { probe.get(id).is_some_and(|pane| pane.info().alive) })); } - // Now that the tree has an owner filling it, the daemon can *see* panes - // nothing references any more — but it only reports them, deliberately. spawn_orphan_sweep(registry.clone()); for stream in listener.incoming() { match stream { Ok(stream) => { - // Both directions get tuned: `transport::connect` covers the - // GUI's end, this covers the daemon's (where the send buffer - // carries the full output throughput). transport::tune(&stream); let registry = registry.clone(); - // One thread per connection; the connection owns its pane stream. std::thread::Builder::new() .name("tty7-daemon-conn".to_string()) .spawn(move || { - // This thread relays client input (keystrokes) to the - // PTY: interactive by definition. crate::core::threads::promote_to_user_interactive(); if let Err(e) = handle_conn(stream, registry) { - // A clean client disconnect surfaces as an EOF error; log - // at debug so it isn't noise. log::debug!("connection ended: {e}"); } }) .ok(); } - // A transient accept error shouldn't kill the daemon; log and continue. Err(e) => log::warn!("accept failed: {e}"), } } @@ -357,21 +217,8 @@ pub fn run() -> anyhow::Result<()> { Ok(()) } -/// Handle SIGTERM as a graceful daemon stop, mirroring `ClientMsg::Shutdown`. -/// -/// SIGTERM is blocked on the calling thread *before* any connection thread -/// spawns (new threads inherit the mask), then a dedicated thread `sigwait`s -/// for it — the one way to run non-trivial teardown (locks, allocation) in -/// response to a signal without breaking async-signal-safety. Must be called -/// from the daemon's main thread ahead of the accept loop. -/// -/// If the watcher can't be set up, SIGTERM keeps (or reverts to) its default -/// terminate action; the takeover path's SIGKILL escalation still covers a -/// daemon that ignores it either way. #[cfg(unix)] fn serve_sigterm(registry: Arc<Registry>) { - // SAFETY: building a local sigset and masking it on the current thread; - // nothing here aliases or races. let set = unsafe { let mut set: libc::sigset_t = std::mem::zeroed(); libc::sigemptyset(&mut set); @@ -386,8 +233,6 @@ fn serve_sigterm(registry: Arc<Registry>) { .name("tty7-daemon-sigterm".to_string()) .spawn(move || { let mut sig: libc::c_int = 0; - // SAFETY: `set` is the initialized sigset masked above; `sigwait` - // blocks until one of its signals is delivered to the process. if unsafe { libc::sigwait(&set, &mut sig) } == 0 { log::info!("daemon shutting down on SIGTERM"); registry.drain_and_kill(); @@ -398,14 +243,6 @@ fn serve_sigterm(registry: Arc<Registry>) { .ok(); } -/// What every daemon exit owes the next one. -/// -/// The tree's observations first: a pane's cwd and its agent session are -/// deferred by design (`machine::Persist::Soon`) and are exactly what the next -/// launch revives that pane from, so the last couple of seconds of them are -/// worth one write on the way out. Then the endpoint markers — **both** -/// dialects', since on Windows each listener has its own — and the pidfile, so -/// nothing left on disk points at a process that is gone. fn on_shutdown() { if let Some(store) = crate::core::machine::observed_store() { store.flush(); @@ -416,29 +253,13 @@ fn on_shutdown() { crate::daemon::pidfile::remove(); } -/// Handle one connection start-to-finish. Reads the opening `ClientMsg` and -/// dispatches; for the streaming variants it then runs [`stream_pane`]. fn handle_conn(stream: Stream, registry: Arc<Registry>) -> anyhow::Result<()> { let mut read_stream = stream; - // Authenticate before touching the protocol. On Windows the transport is - // loopback TCP, reachable by any local process; the client proves it read the - // user-private port file by presenting the daemon's token as a preamble. A - // failed check drops the connection here, before any `ClientMsg` is parsed or a - // pane is spawned. No-op on Unix (the socket's filesystem perms already gate it). transport::authenticate(&mut read_stream)?; - // Separate read/write halves so the writer thread and reader loop don't share a - // `&mut` (the stream is just a socket; `try_clone` dups the handle — both - // directions are independent). let write_stream = read_stream.try_clone()?; - // The opening frame decides whether this connection is *ours* at all. A - // route header means the rest of it belongs to a remote `tty7-server`, and - // this daemon becomes a byte pipe for the remainder of its life — see - // `daemon::router`. Read at the frame level rather than through - // `ClientMsg::read` because a routed connection's later bytes are not this - // dialect, and nothing here may assume they are. let (first_kind, first_payload) = crate::daemon::protocol::read_frame(&mut read_stream)?; if first_kind == crate::daemon::router::ROUTE_KIND { drop(write_stream); @@ -458,13 +279,6 @@ fn handle_conn(stream: Stream, registry: Arc<Registry>) -> anyhow::Result<()> { owner, } => { 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 - // the registry, freeing the ring and reaping the zombie child. The - // removal runs on its own short-lived thread because `on_dead` fires - // on the pane's reader thread, and dropping the last `Arc` there - // would make `DaemonPane::drop`'s reader join wait (bounded) on the - // very thread it is running on. let on_dead = { let registry = registry.clone(); move || { @@ -479,7 +293,6 @@ fn handle_conn(stream: Stream, registry: Arc<Registry>) -> anyhow::Result<()> { let pane = match DaemonPane::spawn(id, cwd, size, shell, owner, on_dead) { Ok(p) => p, Err(e) => { - // Report the failure to the client and close. let mut w = write_stream; let _ = DaemonMsg::Error(format!("spawn failed: {e}")).encode(&mut w); return Err(e); @@ -487,7 +300,6 @@ fn handle_conn(stream: Stream, registry: Arc<Registry>) -> anyhow::Result<()> { }; registry.insert(pane.clone()); - // Reply with the new id, then attach this connection and stream. { let mut w = &write_stream; DaemonMsg::Spawned { pane_id: id }.encode(&mut w)?; @@ -496,10 +308,6 @@ fn handle_conn(stream: Stream, registry: Arc<Registry>) -> anyhow::Result<()> { } ClientMsg::SpawnNativeSsh { cwd: _, size, spec } => { - // A native russh-backed pane. Same lifecycle as `Spawn` (allocate, - // reclaim-on-detached-death, reply `Spawned`, attach, stream); the pane - // spawns fast and the connect/auth runs asynchronously, sending - // `AuthPrompt`/`SshStatus` over this same connection. let id = registry.alloc_id(); let on_dead = { let registry = registry.clone(); @@ -529,10 +337,6 @@ fn handle_conn(stream: Stream, registry: Arc<Registry>) -> anyhow::Result<()> { stream_pane(pane, id, read_stream, write_stream, registry) } - // The attach `size` is the client's pre-layout placeholder and is - // deliberately ignored: the daemon reports the recorded geometry via - // `DaemonMsg::Size` for the replay, and the client sends a real - // `Resize` once laid out (see `DaemonPane::attach`). ClientMsg::Attach { pane_id, size: _ } => match registry.get(pane_id) { Some(pane) => { stream_pane_with_attach(pane, pane_id, read_stream, write_stream, registry) @@ -557,12 +361,6 @@ fn handle_conn(stream: Stream, registry: Arc<Registry>) -> anyhow::Result<()> { } ClientMsg::Shutdown => { - // Force a full daemon stop (the GUI's "Restart Background Service"): - // hang up every child so nothing is orphaned, drop the endpoint - // marker so a fresh daemon binds cleanly, then exit. The accept loop - // has no cooperative stop — a hard exit *is* the daemon's defined stop - // (see `run`'s "runs until the process is killed"). This is the one - // place the daemon terminates itself. log::info!("daemon shutting down on client request"); registry.drain_and_kill(); on_shutdown(); @@ -570,8 +368,6 @@ fn handle_conn(stream: Stream, registry: Arc<Registry>) -> anyhow::Result<()> { } ClientMsg::Kill { pane_id } => { - // A control-only `Kill` as the opening message: terminate + forget the - // pane, then close (no stream). if let Some(pane) = registry.remove(pane_id) { pane.kill(); } @@ -588,8 +384,6 @@ fn handle_conn(stream: Stream, registry: Arc<Registry>) -> anyhow::Result<()> { DaemonMsg::Error("pane has no ssh remote context".to_string()).encode(&mut w)?; return Ok(()); }; - // A loopback forward (FR-F4) is a Local `direct-tcpip` on the pane's - // russh connection — native-SSH panes only. let result = if remote.kind == RemoteKind::NativeSsh { match pane.ssh_connection() { Some(conn) => crate::daemon::ssh::SshManager::global() @@ -637,8 +431,6 @@ fn handle_conn(stream: Stream, registry: Arc<Registry>) -> anyhow::Result<()> { ClientMsg::DeleteKnownHost(id) => { let mut w = write_stream; - // Best effort: a delete failure still returns the (unchanged) list so - // the UI reflects reality rather than hanging. let _ = crate::daemon::ssh::known_hosts::delete(&id); let list = crate::daemon::ssh::known_hosts::list(); DaemonMsg::KnownHostsList(list).encode(&mut w)?; @@ -726,9 +518,6 @@ fn handle_conn(stream: Stream, registry: Arc<Registry>) -> anyhow::Result<()> { ClientMsg::QueryProcs { pane_id } => { let mut w = write_stream; - // An unknown/dead pane answers empty rather than `Error`: the details - // panel polls while the user watches, and a pane closing mid-flight is - // ordinary, not a failure worth surfacing. let procs = registry.get(pane_id).map(|p| p.procs()).unwrap_or_default(); DaemonMsg::Procs(procs).encode(&mut w)?; Ok(()) @@ -741,18 +530,12 @@ fn handle_conn(stream: Stream, registry: Arc<Registry>) -> anyhow::Result<()> { Ok(()) } - // A remote workspace has no pane here to address, so its forwards and - // SFTP go through one envelope that names the connection instead - //. The whole answer — including every failure — is built by - // `ssh::workspace::handle`, so this arm stays a pipe. ClientMsg::OnWorkspace(req) => { let mut w = write_stream; crate::daemon::ssh::workspace::handle(&req).encode(&mut w)?; Ok(()) } - // `Input` / `Resize` / `Detach` as an opening message are meaningless (no - // pane is bound yet); ignore and close. other => { log::debug!("unexpected opening message: {other:?}"); Ok(()) @@ -760,9 +543,6 @@ fn handle_conn(stream: Stream, registry: Arc<Registry>) -> anyhow::Result<()> { } } -/// Resolve a pane to its live native-SSH connection for a managed-forward request, -/// or a human-readable reason it can't (wrong pane, PTY/compat pane, or a -/// still-authenticating / dropped connection). fn forward_pane_connection( registry: &Registry, pane_id: u64, @@ -774,10 +554,6 @@ fn forward_pane_connection( .ok_or_else(|| "pane is not a ready native-ssh session".to_string()) } -/// `Attach` path: subscribe the connection to an existing pane (sending the -/// recorded `Size` + `Snapshot` + known cwd/prompt), then stream. Splitting -/// this out keeps the `Spawn` path (which mustn't re-snapshot before its -/// `Spawned` reply ordering) distinct from `Attach`. fn stream_pane_with_attach( pane: Arc<DaemonPane>, id: u64, @@ -790,10 +566,6 @@ fn stream_pane_with_attach( run_stream(pane, id, epoch, rx, read_stream, write_stream, registry) } -/// `Spawn` path: the pane was just created (empty ring), so attaching now sends an -/// empty `Snapshot` (plus the spawn geometry as `Size`) — harmless, and it keeps -/// the single attach code path. The `Spawned` reply has already been written by -/// the caller. fn stream_pane( pane: Arc<DaemonPane>, id: u64, @@ -806,12 +578,6 @@ fn stream_pane( run_stream(pane, id, epoch, rx, read_stream, write_stream, registry) } -/// Drive the bidirectional stream for an attached pane: -/// - a writer thread drains the pane→client `DaemonMsg` channel to the socket; -/// - this thread reads further `ClientMsg`s (`Input` / `Resize` / `Detach` / -/// `Kill`) until the client disconnects or detaches. -/// On exit we detach (never kill — the pane lives on headless) unless the client -/// explicitly asked to `Kill`. fn run_stream( pane: Arc<DaemonPane>, id: u64, @@ -821,27 +587,19 @@ fn run_stream( write_stream: Stream, registry: Arc<Registry>, ) -> anyhow::Result<()> { - // Writer thread: pull daemon messages and frame them onto the socket. It ends - // when the channel's senders are all dropped (pane detached / replaced) or a - // socket write fails (client gone). let writer = spawn_writer(rx, write_stream, pane.gate()); - // Reader loop: process client→daemon messages until disconnect/detach. let mut killed = false; loop { match ClientMsg::read(&mut read_stream) { Ok(ClientMsg::Input(bytes)) => pane.write_input(&bytes), Ok(ClientMsg::Resize(size)) => pane.resize(size), - // The GUI's reply to a native-SSH auth/host-key prompt; route it to the - // pane's prompt broker (a no-op for non-native panes). Ok(ClientMsg::AuthResponse { request_id, response, }) => pane.deliver_auth_response(request_id, response), Ok(ClientMsg::Detach) => break, Ok(ClientMsg::Kill { pane_id }) => { - // Honor a kill for *this* pane; for another id, just remove+kill it - // and keep streaming this one. if pane_id == id { killed = true; break; @@ -849,19 +607,11 @@ fn run_stream( other.kill(); } } - // Re-`Attach` / `Spawn` / `List` mid-stream aren't part of v1's single - // connection-per-pane model; ignore. Ok(_) => {} - // EOF / error == the client went away: detach. Err(_) => break, } } - // Detach this connection from the pane so its reader stops sending to our - // channel. `detach` drops the pane's `Sender`; with no senders left, the - // writer thread's `rx.recv()` returns `Err` and it exits on its own. Join it so - // the socket fd it holds is released before we return. `detach` also reports - // whether the pane is now reclaimable (child already exited + no subscriber). let reclaimable = pane.detach(epoch); let _ = writer.join(); @@ -870,34 +620,13 @@ fn run_stream( p.kill(); } } else if reclaimable { - // The shell exited while we were attached; now that the last client is - // leaving, drop the dead pane instead of leaving it (and its ~8 MiB ring, - // PTY fds, and unreaped child) in the registry forever. A `!alive` pane is - // never re-attached — clients spawn fresh for it — so this is invisible to - // them. The `Arc` we still hold reaps the child when this frame returns. registry.remove(id); } Ok(()) } -/// While coalescing, stop growing a merged `Output` frame past this size. Big -/// enough to turn a flood's ~1 KiB PTY reads into a few large frames per client -/// wake, small enough to keep any single socket write (and the client's -/// apply-under-lock for it) bounded. const OUTPUT_COALESCE_CAP: usize = 256 * 1024; -/// Spawn the per-connection writer thread that frames pane `DaemonMsg`s onto the -/// socket. The thread self-terminates when its channel closes (all senders dropped -/// — i.e. the pane detached us) or a socket write fails (client gone). -/// -/// Consecutive `Output` messages already queued are merged into one frame (up to -/// [`OUTPUT_COALESCE_CAP`]) before encoding. macOS PTYs hand the pane reader -/// ~1 KiB per read, so a flood otherwise becomes thousands of tiny frames per -/// second, and the *client* pays per frame (term lock + parser call + wakeup); -/// merging here collapses that to a handful of large frames. Only what is -/// already in the channel is drained — `try_recv` never waits — so a lone -/// keystroke echo still goes out immediately, and ordering with non-`Output` -/// messages (Cwd/Prompt/Exited…) is preserved. fn spawn_writer( rx: Receiver<DaemonMsg>, mut write_stream: Stream, @@ -906,16 +635,11 @@ fn spawn_writer( std::thread::Builder::new() .name("tty7-daemon-writer".to_string()) .spawn(move || { - // On the visible-output path (PTY reader → here → client socket): - // keep it off the efficiency cores. crate::core::threads::promote_to_user_interactive(); - // A non-Output message that interrupted a coalescing run, waiting - // its turn behind the merged frame it arrived after. let mut carried: Option<DaemonMsg> = None; loop { let msg = match carried.take() { Some(m) => m, - // Block on the channel until the next message (or close). None => match rx.recv() { Ok(m) => m, Err(_) => break, @@ -925,8 +649,6 @@ fn spawn_writer( while buf.len() < OUTPUT_COALESCE_CAP { match rx.try_recv() { Ok(DaemonMsg::Output(more)) => buf.extend_from_slice(&more), - // A different message ends the run; it must be - // written *after* the bytes that preceded it. Ok(other) => { carried = Some(other); break; @@ -938,9 +660,6 @@ fn spawn_writer( } else { msg }; - // Credit the gate whether the write succeeds or not: either - // way the bytes leave the queue, and the reader must not stay - // throttled against them. let drained = match &msg { DaemonMsg::Output(b) => b.len(), _ => 0, @@ -952,8 +671,6 @@ fn spawn_writer( if !write_ok { break; } - // Flush so interactive output isn't held in a buffer (socket - // writes are unbuffered, but be explicit/future-proof). let _ = write_stream.flush(); } }) @@ -972,11 +689,6 @@ mod tests { assert_eq!(reg.alloc_id(), 3); } - /// Pane ids are names, not slots: a fresh process must never re-mint an id - /// the persisted tree still references, or a stale leaf aliases a new - /// shell — the tree marks the wrong pane live, revival's re-registration - /// is refused forever, and an attach by the old id steals another - /// workspace's stream. #[test] fn pane_ids_never_alias_what_the_persisted_tree_references() { use crate::core::machine::{MachineStore, PaneSeed}; @@ -991,14 +703,10 @@ mod tests { reg.seed_ids_past(&store.machine()); assert_eq!(reg.alloc_id(), 8, "past the highest id the tree names"); - // A seed can only move the counter forward. reg.seed_ids_past(&store.machine()); assert_eq!(reg.alloc_id(), 9); } - /// A tree naming `u64::MAX` (a corrupted file, an absurd client seed) - /// must not panic the daemon at startup: `max + 1` overflowed in a debug - /// build, taking every pane on the machine down with a bookkeeping add. #[test] fn a_tree_naming_the_maximum_pane_id_does_not_panic_the_seed() { use crate::core::machine::{Machine, PaneRecord}; @@ -1007,7 +715,6 @@ mod tests { workspaces: Vec::new(), panes: vec![PaneRecord::new(u64::MAX)], }); - // The counter parks at the ceiling — a bounded absurdity, not a crash. assert_eq!(reg.alloc_id(), u64::MAX); } @@ -1019,10 +726,6 @@ mod tests { assert!(reg.list().is_empty()); } - // The connection-dispatch tests drive `handle_conn` over a real socket pair, - // exercising only the branches that need no PTY (List / Attach-miss / Kill / - // unexpected-open) plus the writer thread. Unix-only: the Windows transport is - // loopback TCP, which has no `pair()` helper. #[cfg(unix)] mod conn { use super::super::{OUTPUT_COALESCE_CAP, Registry, handle_conn, spawn_writer}; @@ -1038,8 +741,6 @@ mod tests { cell_h: 17, }; - /// Run `handle_conn` on the server end of a socket pair; hand back the client - /// end plus the server thread's join handle. fn serve() -> (UnixStream, thread::JoinHandle<()>) { let (client, server) = UnixStream::pair().unwrap(); let reg = Arc::new(Registry::new()); @@ -1082,7 +783,6 @@ mod tests { ClientMsg::Kill { pane_id: 123 } .encode(&mut client) .unwrap(); - // Kill as the opening message produces no reply — the server just closes. assert!(DaemonMsg::read(&mut client).is_err()); h.join().unwrap(); } @@ -1090,7 +790,6 @@ mod tests { #[test] fn unexpected_opening_message_is_ignored_and_closed() { let (mut client, h) = serve(); - // A `Resize` with no pane bound is meaningless; the server closes cleanly. ClientMsg::Resize(SIZE).encode(&mut client).unwrap(); assert!(DaemonMsg::read(&mut client).is_err()); h.join().unwrap(); @@ -1113,27 +812,16 @@ mod tests { DaemonMsg::Exited { code: Some(0) } ); - // Dropping the last sender ends the writer thread on its own. drop(tx); writer.join().unwrap(); } - /// Consecutive `Output`s already sitting in the channel leave the socket - /// as a *single* merged frame with their bytes concatenated in order — - /// the coalescing that collapses a flood's thousands of ~1 KiB PTY reads - /// into a few large frames. The messages are queued (and the sender - /// dropped) *before* the writer spawns, so all three are guaranteed - /// visible inside one `recv` + `try_recv` window; a regression back to - /// frame-per-message would deliver `Output("one")` first and fail the - /// first assertion. #[test] fn spawn_writer_coalesces_queued_outputs_into_one_frame() { let (tx, rx) = mpsc::channel::<DaemonMsg>(); tx.send(DaemonMsg::Output(b"one".to_vec())).unwrap(); tx.send(DaemonMsg::Output(b"two".to_vec())).unwrap(); tx.send(DaemonMsg::Output(b"three".to_vec())).unwrap(); - // Close the channel up front: the writer drains the backlog and then - // exits, so the EOF below proves nothing trailed the merged frame. drop(tx); let (mut client, server) = UnixStream::pair().unwrap(); @@ -1144,22 +832,12 @@ mod tests { DaemonMsg::Output(b"onetwothree".to_vec()), "queued Outputs must merge into one frame, bytes in send order" ); - // EOF, not another frame: the three messages became exactly one. assert!(DaemonMsg::read(&mut client).is_err()); writer.join().unwrap(); } - /// A queued `Output` backlog larger than `OUTPUT_COALESCE_CAP` is split - /// into multiple frames — every byte delivered, in order — rather than - /// merged into one unbounded write. The cap is checked before each - /// append, so a frame may overshoot it by at most one message; anything - /// bigger means the cap stopped bounding socket writes (and the - /// client's apply-under-lock per frame). #[test] fn spawn_writer_splits_output_backlog_at_the_coalesce_cap() { - // Six 64 KiB chunks: 384 KiB total against the 256 KiB cap. Each is - // filled with a distinct byte so the concatenation check below also - // proves the split kept the chunks in order. const CHUNK: usize = 64 * 1024; let chunks: Vec<Vec<u8>> = (0u8..6).map(|i| vec![i; CHUNK]).collect(); let expected: Vec<u8> = chunks.concat(); @@ -1178,7 +856,6 @@ mod tests { match DaemonMsg::read(&mut client) { Ok(DaemonMsg::Output(bytes)) => frames.push(bytes), Ok(other) => panic!("expected only Output frames, got {other:?}"), - // EOF: the writer drained the backlog and exited. Err(_) => break, } } @@ -1199,11 +876,6 @@ mod tests { assert_eq!(frames.concat(), expected, "no bytes lost or reordered"); } - /// A non-`Output` message queued between `Output`s goes out in its - /// original position: it ends the coalescing run, and the `Output`s on - /// either side of it must not merge across it. Guards the `carried` - /// handoff — dropping or reordering the interrupting message would tell - /// the client (say) the shell exited around the wrong bytes. #[test] fn spawn_writer_does_not_coalesce_outputs_across_a_non_output_message() { let (tx, rx) = mpsc::channel::<DaemonMsg>(); @@ -1233,35 +905,14 @@ mod tests { writer.join().unwrap(); } - /// A dead client (socket write fails) ends the writer thread even while - /// the pane-side sender is still alive — otherwise every disconnect - /// would leave a writer parked in `recv()` until the pane detached it, - /// and `run_stream`'s join of the writer would inherit that wait. #[test] fn spawn_writer_exits_on_write_failure_while_sender_is_alive() { let (tx, rx) = mpsc::channel::<DaemonMsg>(); let (client, server) = UnixStream::pair().unwrap(); let writer = spawn_writer(rx, server, Arc::new(crate::daemon::pane::OutputGate::new())); - // Kill the client end first, then hand the writer messages: an - // encode hits a broken pipe and the thread must bail on its own. drop(client); - // Bounded poll rather than a bare `join()`: the sender stays alive - // for the whole wait, so only the write-failure path can finish the - // thread — and a regression fails in bounded time instead of - // hanging. The bound is generous because it is only a hang-catcher: - // the passing case finishes in microseconds, while a loaded machine - // running the whole suite in parallel can leave this thread - // unscheduled for seconds. A tight bound turns that into a flake - // that says nothing about the behaviour under test. - // - // Kept fed rather than sent one message: the first write into a - // freshly-closed socket can *succeed* (the kernel has not - // processed the peer's close yet, especially under load), and a - // writer that swallowed it would park in `recv()` for the rest of - // the deadline. Only a later write is guaranteed to see the - // broken pipe, so the loop keeps offering them. let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); while !writer.is_finished() && std::time::Instant::now() < deadline { let _ = tx.send(DaemonMsg::Output(b"into the void".to_vec())); diff --git a/crates/tty7-core/src/daemon/shell_integration.rs b/crates/tty7-core/src/daemon/shell_integration.rs index f260c7f2..6cae6d2f 100644 --- a/crates/tty7-core/src/daemon/shell_integration.rs +++ b/crates/tty7-core/src/daemon/shell_integration.rs @@ -1,84 +1,6 @@ -//! Shell integration: inject a small startup snippet into the shell tty7 spawns -//! so the shell *actively reports* its state — prompt boundaries, command -//! start/finish, exit codes, and cwd — instead of us guessing from the outside. -//! -//! This is the foundation the inline input editor builds on. The reporting -//! protocol is the FinalTerm / iTerm2 **OSC 133** semantic-prompt standard, so it -//! interoperates with the wider ecosystem rather than a bespoke scheme: -//! - `OSC 133 ; A ST` prompt start -//! - `OSC 133 ; B ST` prompt end / command input begins -//! - `OSC 133 ; C [; <cmd>] ST` command output begins; all four integrations -//! append the submitted command line percent-encoded (tty7 extension — the -//! Windows coding-agent detection input, see `core::cli_agent`) -//! - `OSC 133 ; D ; <exit> ST` command finished, with its exit code -//! - `OSC 133 ; V ; 0/1 ST` tty7 extension: shell edit mode -//! -//! plus `OSC 7` to report the cwd precisely (many login shells don't emit it -//! unless they think they're in Terminal.app). -//! -//! Supports zsh, bash, fish and PowerShell; each needs a different injection -//! mechanism because the shells disagree on how much control they hand an -//! integrator: -//! - **zsh** has `ZDOTDIR`, an env var that retargets *all* of its startup -//! files at once — the cleanest hook of the four. See [`zsh_redirectors`]. -//! - **fish** has no such redirect, but its `-C`/`--init-command` flag runs -//! extra commands after fish's own (unmodified) config load — no throwaway -//! directory needed at all. See [`setup_fish`]. -//! - **bash** has neither: no env var retargets its rc file, and `--rcfile` -//! (the only override it does have) is silently ignored for *login* -//! shells, which is how terminals normally spawn it. So we spawn bash as a -//! plain non-login shell instead and have our rcfile manually replay the -//! login-shell startup-file chain (`/etc/profile`, `~/.bash_profile` & -//! co.) before layering hooks on top — see [`setup_bash`] and -//! [`Injection::replaces_argv`]. Bash also has no native precmd/preexec, -//! so the hook body vendors the relevant parts of -//! [bash-preexec](https://github.com/rcaloras/bash-preexec) (MIT), the -//! same shim VS Code relies on for this. This path covers Git Bash too — -//! the msys2 bash Git for Windows ships is spawned as `bash.exe` by -//! absolute path, and needs only its rcfile path spelled with forward -//! slashes (see [`bash_path`]). -//! - **WSL** is not a shell but a launcher: `wsl.exe` starts a shell *inside* -//! a distro, so the integration has to reach through it. We probe the -//! distro's login shell, write the matching rcfile on the Windows side, and -//! pass its path in via `WSLENV`, which translates it to the distro's view -//! of the filesystem. See [`setup_wsl`]. Only bash is wired up so far. -//! - **PowerShell** (the Windows default, and any `pwsh`) has no dotfile -//! redirect either, but `-EncodedCommand` runs a script *after* its own -//! profiles load — like fish's `-C`, no file on disk. It has no -//! precmd/preexec, so — following VS Code — the body wraps two -//! host hooks: the `prompt` function (for the A/B/D marks + cwd) and -//! `PSConsoleHostReadLine`, PSReadLine's line reader (the closest thing to -//! a preexec, for the C mark). See [`setup_powershell`]. -//! -//! Across all of them: **the user's own dotfiles are never modified** — the -//! mechanisms above only affect shells tty7 itself launches. -//! -//! All of the above configure a *local* process spawn. Native-SSH panes have no -//! spawn to configure — only the command string an `exec` channel request -//! carries — so they take a different route to the same place: probe the remote -//! for its login shell, then send a script that recreates these very files on -//! the remote side and `exec`s through them. See [`remote`]. -//! -//! **cmd** stays unintegrated by design, not omission. It exposes exactly one -//! hook, the `PROMPT` env var, which can emit the `A`/`B` marks but not `C` or -//! `D`: it has no preexec/postexec, and `PROMPT` is expanded when it is *set*, -//! so even `%ERRORLEVEL%` is out of reach. Since only `C` clears `at_prompt` -//! (see `pane::handle_osc133`), an A/B-only shell would leave the line editor -//! owning the keyboard for the whole of every command — worse than no -//! integration at all. -//! -//! The install-guard sentinel (`TTY7_SHELL_INTEGRATION`, see [`setup`]) does -//! not cross into WSL, and deliberately isn't listed in `WSLENV`: only vars -//! named there cross, so a distro shell always starts with it unset — which is -//! correct, since it *is* a fresh top-level interactive shell. Its own -//! descendants inside the distro then see the `1` it exports, as on any Linux. - use std::collections::HashMap; use std::path::{Path, PathBuf}; -/// The zsh integration body, sourced from our injected `.zshrc` after the user's -/// own `.zshrc` has run. Guarded so it installs exactly once per interactive -/// shell. See the module docs for the OSC 133 semantics. const ZSH_INTEGRATION: &str = r#" # --- tty7 shell integration (zsh) --- if [[ -o interactive ]] && [[ -z "$TTY7_SHELL_INTEGRATION" ]]; then @@ -180,16 +102,6 @@ fi # --- end tty7 shell integration --- "#; -/// The fish integration body, passed verbatim as a `-C`/`--init-command` -/// argument (see [`setup_fish`]) — fish has already loaded the user's *real* -/// `config.fish` by the time this runs, so unlike zsh/bash there's nothing here -/// to source manually. -/// -/// fish has no event that fires *after* the prompt is drawn, so the B marker -/// (prompt end / input begins) can't be emitted from an `--on-event` handler -/// the way A/C/D are — it has to be spliced into `fish_prompt` itself. We -/// capture whatever `fish_prompt` already is (the user's own, or a prompt -/// framework's) and wrap it: call the original, then emit B right after. const FISH_INTEGRATION: &str = r#" # --- tty7 shell integration (fish) --- # Guard on *emptiness* (`test -z`), not definedness (`set -q`): `setup()` resets the @@ -254,23 +166,6 @@ end # --- end tty7 shell integration --- "#; -/// The bash integration body, appended after the replayed login-file chain -/// (see [`setup_bash`]). Bash has no native precmd/preexec, so this vendors the -/// core mechanism from [bash-preexec](https://github.com/rcaloras/bash-preexec) -/// (MIT) — the same shim VS Code uses — trimmed of everything but the -/// precmd/preexec plumbing: a `DEBUG` trap infers "a command is genuinely about -/// to run interactively" (as opposed to firing mid-completion, mid readline -/// binding, or for a piece of `PROMPT_COMMAND` itself), and `PROMPT_COMMAND` -/// runs registered precmd functions before each prompt. -/// -/// If the user's own `.bashrc` already loaded bash-preexec (several prompt -/// frameworks bundle it) we don't install it a second time — re-running the -/// install sequence would clear and never restore the already-installed -/// `DEBUG` trap. We detect that via bash-preexec's own `bash_preexec_imported` -/// sentinel and, either way, register our hooks through its public extension -/// points (`precmd_functions` / `preexec_functions`) rather than the "function -/// literally named `precmd`/`preexec`" convenience, which could collide with -/// the user's own. const BASH_INTEGRATION: &str = r#" # --- tty7 shell integration (bash) --- if [[ $- == *i* ]] && [[ -z "$TTY7_SHELL_INTEGRATION" ]]; then @@ -536,32 +431,6 @@ fi # --- end tty7 shell integration --- "#; -/// The PowerShell integration body, base64-encoded (see -/// [`powershell_encoded_command`]) and passed as `-EncodedCommand`, which -/// PowerShell runs *after* loading the user's profiles — so, like fish's `-C`, -/// it layers hooks on top of the user's own prompt without a file on disk and -/// without touching their config. -/// -/// PowerShell has no precmd/preexec, so — mirroring VS Code — we wrap -/// two host hooks: -/// - **`prompt`** runs before each prompt is drawn. It emits `133;D` (the -/// last command's exit code) and the `OSC 7` cwd as side effects, then -/// returns the user's own prompt wrapped in `133;A` … `133;B`. The byte -/// order is therefore `[D][cwd][A]prompt[B]`, exactly what the daemon's -/// sniffer keys `at_prompt` off (see `daemon::pane::handle_osc133`). -/// - **`PSConsoleHostReadLine`** is PSReadLine's line reader — the closest -/// thing PowerShell has to a preexec. After it returns the submitted line, -/// before the command runs, we emit `133;C;<command>` (command output -/// begins), carrying the submitted line percent-encoded as a tty7 -/// extension. That capture is the Windows coding-agent detection input: -/// ConPTY has no foreground process group for the daemon's process-table -/// poll to read an `argv` from, so — like Warp — the daemon learns what -/// runs from the line the shell itself reported (see -/// `core::cli_agent::CLIAgent::detect_from_command_with`). -/// -/// `$?` must be captured as the very first statement of `prompt` (an -/// assignment sets `$?` to true, clobbering it), and is restored before the -/// user's own prompt runs so a status-aware prompt still sees the real result. const POWERSHELL_INTEGRATION: &str = r#" # --- tty7 shell integration (PowerShell) --- if (-not $env:TTY7_SHELL_INTEGRATION) { @@ -663,30 +532,7 @@ if (-not $env:TTY7_SHELL_INTEGRATION) { # --- end tty7 shell integration --- "#; -/// The redirector files written into our throwaway `ZDOTDIR`. zsh reads its -/// startup files from `$ZDOTDIR`, so for zsh to reach all four of ours we must -/// keep `ZDOTDIR` pointing at *our* dir at every hand-off between files. But -/// while each redirector actually *sources the user's real file* — and once the -/// live session begins — `ZDOTDIR` has to point at the user's real config dir -/// instead: a whole ecosystem of zsh tooling (Zim, oh-my-zsh, `compinit`'s -/// `.zcompdump`) locates its own state via `${ZDOTDIR:-$HOME}`, and if that -/// resolved to our *empty* throwaway dir it would reinstall / rebuild from -/// scratch on every new pane — the 3-second stall and Zim "Installed" spam of -/// issue #15. So each redirector swaps `ZDOTDIR` to the real dir around the -/// `source`, then swaps our dir back so zsh still reaches the next redirector; -/// the integration body ([`ZSH_INTEGRATION`]) restores the real dir for good -/// once every startup file has run. -/// -/// The source is done at top level (never wrapped in a function) so the user's -/// config keeps its normal global scope. fn zsh_redirectors() -> [(&'static str, String); 4] { - // Run the user's file of the same name with ZDOTDIR aimed at their *real* - // config dir, then restore ours so zsh reads the next redirector. The real - // dir is `TTY7_USER_ZDOTDIR`, captured into the env before launch; when it's - // absent we *unset* ZDOTDIR (not fall back to $HOME) so the file sees exactly - // what a real launch gives it — an unset ZDOTDIR — and the classic relocate - // idiom `: ${ZDOTDIR:=~/.config/zsh}` still fires. `tail` runs after the - // source but before the restore. let redirect = |name: &str, tail: &str| { format!( "__tty7_ztmp=$ZDOTDIR\n\ @@ -697,18 +543,11 @@ fn zsh_redirectors() -> [(&'static str, String); 4] { ) }; [ - // The user's own .zshenv may itself relocate ZDOTDIR — the classic tiny - // `~/.zshenv` that does `ZDOTDIR=~/.config/zsh`. Capture wherever it points - // *after* sourcing as the real dir for the later redirectors (and nested - // tty7); otherwise they'd look under $HOME and miss the user's real config. ( ".zshenv", redirect(".zshenv", "export TTY7_USER_ZDOTDIR=${ZDOTDIR:-$HOME}\n"), ), (".zprofile", redirect(".zprofile", "")), - // Our integration is appended *after* the user's .zshrc (and after ZDOTDIR - // is restored to ours) so it extends — not gets clobbered by — the user's - // PROMPT / hooks. ( ".zshrc", format!("{}{ZSH_INTEGRATION}", redirect(".zshrc", "")), @@ -717,40 +556,15 @@ fn zsh_redirectors() -> [(&'static str, String); 4] { ] } -/// Environment overrides + spawn adjustments produced by `setup`. pub struct Injection { - /// Env vars to add to the child shell's environment. pub env: HashMap<String, String>, - /// Extra argv entries to append after the program (e.g. bash's - /// `--rcfile <path>`, fish's `-C <script>`). Empty for zsh, which needs no - /// spawn-time changes at all. When [`replaces_argv`](Self::replaces_argv) - /// is set these are the *whole* argv, not an addition to it. pub args: Vec<String>, - /// If set, [`args`](Self::args) replace the argv the caller would otherwise - /// have used, rather than extending it. Only offered when the caller can - /// freely choose the spawn invocation (i.e. no user-configured custom shell - /// args to preserve). Two integrations need it, for different reasons: - /// - /// - **bash**, because `--rcfile` is ignored for a *login* shell, so the - /// caller's login invocation has to become a plain one (the rcfile - /// replays the login chain itself — see the module docs). - /// - **WSL**, because the launch flags and the command must be reordered - /// around a `--` separator, which appending cannot express. pub replaces_argv: bool, - /// The throwaway dir we created, if any; the terminal owns it and removes - /// it on drop so it doesn't accumulate across sessions. `None` for fish, - /// which needs no files on disk at all. pub dir: Option<PathBuf>, } -/// Prefix of the throwaway redirector dirs we create under the temp dir (see -/// `setup`). Used to recognize *our own* `ZDOTDIR` when it's inherited. const ZDOTDIR_PREFIX: &str = "tty7-zdotdir-"; -/// True if `path` is one of our own redirector dirs (by basename). When tty7 is -/// launched from inside a tty7 shell, the inherited `ZDOTDIR` already points at -/// such a dir — chaining to it would source a `.zshrc` that doesn't hold the -/// user's real config, dropping their dotfiles (oh-my-zsh, aliases, prompt). fn is_our_zdotdir(path: &str) -> bool { Path::new(path) .file_name() @@ -758,13 +572,6 @@ fn is_our_zdotdir(path: &str) -> bool { .is_some_and(|n| n.starts_with(ZDOTDIR_PREFIX)) } -/// Resolve the user's *real* ZDOTDIR for the redirectors to source from, -/// surviving nested tty7 launches: -/// 1. An outer tty7 may have already exported `TTY7_USER_ZDOTDIR` (the real -/// one it resolved) — trust it, keeping the chain anchored to the user. -/// 2. Otherwise use the inherited `ZDOTDIR`, but only if it isn't one of *our* -/// throwaway dirs (which would have no user dotfiles). -/// 3. Otherwise `None` → the redirectors fall back to `$HOME`, as zsh would. fn real_user_zdotdir() -> Option<String> { if let Ok(z) = std::env::var("TTY7_USER_ZDOTDIR") { if !z.is_empty() { @@ -776,17 +583,11 @@ fn real_user_zdotdir() -> Option<String> { .filter(|z| !z.is_empty() && !is_our_zdotdir(z)) } -/// Detected interactive shell kind, resolved from the program tty7 is actually -/// about to spawn (falling back to `$SHELL` when the caller doesn't know it, -/// e.g. because it'll be resolved from the passwd database at spawn time). enum ShellKind { Zsh, Bash, Fish, PowerShell, - /// `wsl.exe`, the Windows-side launcher. Not a shell itself — the - /// integration has to reach *through* it to the distro's own shell. See - /// [`setup_wsl`]. Wsl, } @@ -795,12 +596,6 @@ fn shell_kind(program: Option<&str>) -> Option<ShellKind> { Some(p) => p.to_string(), None => std::env::var("SHELL").ok()?, }; - // Lowercase the basename and drop any `.exe`: Windows program names are - // case-insensitive and carry the suffix, so `PowerShell.exe` and `pwsh` - // must both match — as must Git Bash, which tty7 launches by its absolute - // `...\Git\bin\bash.exe` path (`core::shells::find_git_bash`). The Unix - // shells are conventionally lowercase and suffix-free already, so this only - // ever normalizes the Windows spellings. let base = Path::new(&owned) .file_name()? .to_str()? @@ -816,15 +611,6 @@ fn shell_kind(program: Option<&str>) -> Option<ShellKind> { } } -/// The distro named by a `wsl.exe` argv, if any. tty7's own launch args spell it -/// `--distribution <name>` (`core::shells::detect_shells`); `-d` is the short -/// form a user-configured shell may use. Absent means "the default distro", -/// which is also what `wsl.exe` does with no flag — so `None` is a valid answer, -/// not a failure. -/// -/// Shared with `pane::wsl_remote_context`, which names the same distro in the -/// pane's [`RemoteContext`](crate::daemon::protocol::RemoteContext) from the -/// same argv: two parsers for one flag would be free to disagree. pub(crate) fn wsl_distro(args: &[String]) -> Option<String> { let mut it = args.iter(); while let Some(a) = it.next() { @@ -838,13 +624,6 @@ pub(crate) fn wsl_distro(args: &[String]) -> Option<String> { None } -/// Add our entries to a `WSLENV` value, preserving whatever was already there. -/// -/// `WSLENV` is a colon-separated list of variable names, each optionally -/// suffixed with flags — `/p` meaning "translate this value as a path when it -/// crosses the boundary", which is how the rcfile's Windows path becomes a -/// `/mnt/c/...` one the distro can read. Overwriting it wholesale would silently -/// drop the user's own entries, so append and de-duplicate by name. #[cfg_attr(not(windows), allow(dead_code))] fn wslenv_with(existing: Option<&str>, additions: &[&str]) -> String { let mut out: Vec<String> = existing @@ -855,8 +634,6 @@ fn wslenv_with(existing: Option<&str>, additions: &[&str]) -> String { .collect(); for add in additions { let name = add.split('/').next().unwrap_or(add); - // A name already present wins whatever flags the user gave it; ours is - // additive, not a correction of their configuration. if !out.iter().any(|e| e.split('/').next().unwrap_or(e) == name) { out.push((*add).to_string()); } @@ -864,29 +641,13 @@ fn wslenv_with(existing: Option<&str>, additions: &[&str]) -> String { out.join(":") } -/// Whether a Windows `bash` program path is the msys bash that Git for Windows -/// (or msys2) ships, as opposed to `C:\Windows\System32\bash.exe` — the WSL -/// launcher, which exists on any machine with WSL and normally sits ahead of -/// `Git\bin` on PATH. -/// -/// The asymmetry is why this fails closed: getting it wrong in the WSL -/// direction is destructive, because `--rcfile` *replaces* `~/.bashrc` rather -/// than supplementing it and the path we pass does not exist inside the distro -/// — the user silently loses aliases, prompt, and PATH. Getting it wrong in the -/// Git Bash direction merely costs them shell integration, which they did not -/// have before it was implemented. So anything not positively identifiable as -/// msys is declined, including a bare `bash` / `bash.exe` whose PATH lookup we -/// cannot predict. -/// -/// Always `true` off Windows: `cfg!(windows)` gates the only call site, and -/// keeping the body platform-neutral lets the tests run everywhere. fn is_msys_bash(program: &str) -> bool { if !cfg!(windows) { return true; } let normalized = program.replace('/', "\\").to_ascii_lowercase(); let Some((dir, _)) = normalized.rsplit_once('\\') else { - return false; // bare name — resolved through PATH at spawn time + return false; }; let system_root = std::env::var("SystemRoot") .unwrap_or_else(|_| r"C:\Windows".to_string()) @@ -896,13 +657,6 @@ fn is_msys_bash(program: &str) -> bool { !(dir == system_root || dir.starts_with(&format!("{system_root}\\"))) } -/// A unique throwaway dir under the OS temp dir, prefixed for later -/// recognition (see `is_our_zdotdir`), one *per pane*. We avoid Date/random by -/// combining the process id with a monotonic counter: the daemon is one -/// long-lived process that spawns many panes, so keying on pid alone would -/// have every pane share a single dir, and the first pane's cleanup (removed -/// on drop) would yank the integration files out from under all the others -/// still running. fn throwaway_dir(prefix: &str) -> Option<PathBuf> { use std::sync::atomic::{AtomicU64, Ordering}; static COUNTER: AtomicU64 = AtomicU64::new(0); @@ -920,12 +674,6 @@ fn setup_zsh() -> Option<Injection> { } let mut env = HashMap::new(); - // Preserve the user's real ZDOTDIR so our redirectors can source from it; - // when unset they fall back to $HOME, as zsh itself would. Crucially this - // resolves correctly under *nested* tty7 (launching tty7 from a tty7 shell): - // the inherited ZDOTDIR there points at an outer redirector dir of ours, not - // the user's config — `real_user_zdotdir` sees through that. We always - // (re)export it so deeper nesting stays anchored to the same real dir. if let Some(user_zdotdir) = real_user_zdotdir() { env.insert("TTY7_USER_ZDOTDIR".to_string(), user_zdotdir); } @@ -939,9 +687,6 @@ fn setup_zsh() -> Option<Injection> { }) } -/// fish reads `-C`/`--init-command` after its own (untouched) `config.fish`, so -/// there's nothing to write to disk or redirect — the whole body is just an -/// extra argv entry. fn setup_fish() -> Option<Injection> { Some(Injection { env: HashMap::new(), @@ -951,14 +696,6 @@ fn setup_fish() -> Option<Injection> { }) } -/// PowerShell reads `-EncodedCommand` after loading its own profiles, so — like -/// fish — the whole body is just extra argv, with nothing on disk. We pass it -/// base64-encoded rather than as a plain `-Command` string so an arbitrary -/// script (quotes, `$`, newlines) survives the Windows command line intact, and -/// because an encoded command isn't subject to the script-file execution policy -/// that would otherwise block a dot-sourced `.ps1` on a stock Windows install. -/// `-NoLogo` drops the startup banner; `-NoExit` keeps the session interactive -/// after the command runs. fn setup_powershell() -> Option<Injection> { Some(Injection { env: HashMap::new(), @@ -973,15 +710,11 @@ fn setup_powershell() -> Option<Injection> { }) } -/// Encode a PowerShell script for `-EncodedCommand`, which expects base64 of the -/// command's UTF-16LE bytes. Hand-rolled (both steps) rather than pulling in a -/// base64 crate for this single call site. fn powershell_encoded_command(script: &str) -> String { let utf16le: Vec<u8> = script.encode_utf16().flat_map(u16::to_le_bytes).collect(); base64_encode(&utf16le) } -/// Standard base64 (RFC 4648) with `=` padding. fn base64_encode(input: &[u8]) -> String { const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; let mut out = String::with_capacity(input.len().div_ceil(3) * 4); @@ -1006,9 +739,6 @@ fn base64_encode(input: &[u8]) -> String { out } -/// Bash rcfile content: replay the login-shell startup-file chain (since we're -/// about to force a non-login spawn so `--rcfile` takes effect at all — see the -/// module docs), then append the integration body. fn bash_rcfile() -> String { format!( r#" @@ -1028,14 +758,6 @@ if [[ -f ~/.bashrc ]]; then source ~/.bashrc; fi ) } -/// Render a path for bash's own consumption. The only bash reachable on -/// Windows is the msys2 one Git for Windows ships: its runtime does accept a -/// native `C:\...` path, but the backslashes then survive into any bash string -/// context that path is later interpolated into (`$BASH_SOURCE`, an error -/// message re-evaluated by a user's `PROMPT_COMMAND`) where they *are* escape -/// characters. Forward slashes are accepted just as readily by the msys -/// runtime and carry no such second meaning, so normalize to them. No-op on -/// Unix, where the separator is already `/`. fn bash_path(path: &Path) -> String { let s = path.to_string_lossy().into_owned(); if cfg!(windows) { @@ -1045,12 +767,6 @@ fn bash_path(path: &Path) -> String { } } -/// Force a non-login bash with our rcfile, replaying the login-file chain -/// ourselves inside it (see [`bash_rcfile`]) since `--rcfile` only takes effect -/// on non-login shells in the first place. Only offered when the caller has no -/// user-configured custom args to preserve (`setup`'s `has_custom_args`) — we -/// can't safely guess how `--rcfile <path> -i` should combine with arbitrary -/// user-supplied bash args. fn setup_bash() -> Option<Injection> { let dir = throwaway_dir("tty7-bashrc-")?; let rcfile = dir.join("bashrc"); @@ -1058,35 +774,14 @@ fn setup_bash() -> Option<Injection> { Some(Injection { env: HashMap::new(), - // `--rcfile` (a GNU long option) must precede `-i`: bash 3.2 — still - // macOS's shipped `/bin/bash` — refuses to parse a long option once a - // short one has been seen. args: vec!["--rcfile".to_string(), bash_path(&rcfile), "-i".to_string()], replaces_argv: true, dir: Some(dir), }) } -/// Env var carrying the rcfile path across the Windows/WSL boundary. Listed in -/// `WSLENV` with the `/p` flag so WSL rewrites it to the distro's view of the -/// path (`C:\Users\…` -> `/mnt/c/Users/…`), which is why we don't hardcode the -/// `/mnt` automount root ourselves — it is configurable in `/etc/wsl.conf`. const WSL_RCFILE_ENV: &str = "TTY7_RC"; -/// Pick the distro's shell and exec it, *inside the distro*. -/// -/// Deliberately not a Windows-side probe. Spawning `wsl.exe` to ask which shell -/// a distro uses blocks the whole spawn path: the client waits synchronously for -/// the daemon's `Spawn` reply (see `terminal::remote::spawn`), so on a cold WSL -/// start — seconds, while the distro boots — the entire window freezes. Folding -/// the decision into the one `wsl.exe` invocation we were always going to make -/// costs nothing and cannot block, because there is no second invocation. -/// -/// `$SHELL` rather than `getent passwd`: WSL populates it from the user's passwd -/// entry, so inside the distro it already *is* the login shell of record — the -/// same source `shell_kind` trusts on Unix. Written without a variable -/// assignment so the whole thing stays one `case`, which keeps it robust to the -/// layers of quoting between here and `sh`. const WSL_EXEC_SCRIPT: &str = concat!( r#"case "${SHELL:-}" in "#, r#"*/bash) exec "$SHELL" --rcfile "$TTY7_RC" -i ;; "#, @@ -1094,29 +789,6 @@ const WSL_EXEC_SCRIPT: &str = concat!( "esac" ); -/// Reach through `wsl.exe` to the distro's own shell. -/// -/// `wsl.exe` is a launcher, not a shell: injecting into it directly would never -/// reach the thing that draws the prompt. So we write the integration rcfile on -/// the Windows side and hand `wsl.exe` a command that starts the distro's shell -/// with it — the distro's own startup chain replayed inside it exactly as on any -/// other bash. -/// -/// The argv shape is `[<launch flags>] -- sh -c <script>` rather than -/// `-- <shell> --rcfile <path>` because the path only exists as an env var -/// *inside* the distro after `WSLENV` translation, and `wsl.exe` execs its -/// command directly without a shell to expand it. The one-shot `sh` costs a -/// process and `exec`s away immediately. -/// -/// Only bash is wired up. A distro on zsh or fish falls through to -/// [`WSL_EXEC_SCRIPT`]'s second arm and launches as a plain login shell — the -/// behavior every WSL pane had before this, just reached one `exec` later. They -/// are integrable the same way (`ZDOTDIR` would need translating too; fish's -/// `-C` needs no file at all), but each needs its own verification pass. -/// -/// The rcfile is written unconditionally, before we know the shell — it is a -/// local write into a throwaway dir the terminal already cleans up on drop, and -/// paying it always is what buys the decision being free. #[cfg(windows)] fn setup_wsl(args: &[String]) -> Option<Injection> { let distro = wsl_distro(args); @@ -1124,9 +796,6 @@ fn setup_wsl(args: &[String]) -> Option<Injection> { let rcfile = dir.join("bashrc"); std::fs::write(&rcfile, bash_rcfile()).ok()?; - // Rebuild the launch flags rather than appending to them: `--` must come - // last, and everything after it is the command. Preserve the distro and - // `--cd` the caller asked for. let mut argv: Vec<String> = Vec::new(); if let Some(d) = &distro { argv.push("--distribution".to_string()); @@ -1162,9 +831,6 @@ fn setup_wsl(args: &[String]) -> Option<Injection> { }) } -/// The `--cd` value from a `wsl.exe` argv. tty7's own launch args pass `--cd ~` -/// so the shell lands in the distro's home rather than a translated Windows path -/// (`core::shells::detect_shells`). #[cfg_attr(not(windows), allow(dead_code))] fn wsl_cd(args: &[String]) -> Option<String> { let mut it = args.iter(); @@ -1179,20 +845,6 @@ fn wsl_cd(args: &[String]) -> Option<String> { None } -/// Set up shell integration for a shell tty7 is about to spawn. `program` is -/// the resolved program path/name if the caller already knows it (e.g. the -/// user's configured custom shell, or the default shell resolved from the -/// passwd database) — passing it, rather than relying on `$SHELL`, is what -/// makes detection correct when they disagree. `has_custom_args` should be -/// `true` when the caller is about to pass user-configured shell args it can't -/// safely override (only affects bash — see [`setup_bash`]). -/// -/// `args` are the launch args the caller would otherwise use; only the WSL path -/// reads them (for the distro), and only on Windows. -/// -/// Returns the env/arg overrides and the temp dir to clean up, or `None` when -/// the shell isn't supported or anything goes wrong — in which case the -/// terminal launches bare, exactly as before (integration is best-effort). #[cfg_attr(not(windows), allow(unused_variables))] pub fn setup(program: Option<&str>, args: &[String], has_custom_args: bool) -> Option<Injection> { let mut injection = match shell_kind(program)? { @@ -1200,29 +852,13 @@ pub fn setup(program: Option<&str>, args: &[String], has_custom_args: bool) -> O ShellKind::Fish => setup_fish(), ShellKind::Bash if !has_custom_args => setup_bash(), ShellKind::Bash => None, - // PowerShell's `-EncodedCommand` is mutually exclusive with a - // user-supplied `-Command`/`-File`, so — like bash — don't second-guess - // a custom-arg invocation; launch it bare. ShellKind::PowerShell if !has_custom_args => setup_powershell(), ShellKind::PowerShell => None, - // WSL rebuilds the argv around a `--` separator, so — like bash — it - // can't be reconciled with args the user wrote. #[cfg(windows)] ShellKind::Wsl if !has_custom_args => setup_wsl(args), ShellKind::Wsl => None, }?; - // Reset the install-guard sentinel for the shell we're about to spawn. Each - // integration body sets e.g. `TTY7_SHELL_INTEGRATION=1` and *exports* it, so - // it leaks to every child process — including a tty7 launched from inside a - // tty7 shell, and (crucially) the persistent daemon, which inherits it and - // would otherwise hand it to every shell it spawns. Since the PTY child - // inherits our process env, that stale `1` makes the guard skip the install - // → no OSC 133 → no inline line editor. Every shell tty7 spawns is a - // fresh top-level interactive shell that *should* install the hooks, so we - // blank the sentinel at this spawn boundary (empty still satisfies the - // guard's emptiness check); the body re-exports `1` for that shell's own - // descendants. injection .env .insert("TTY7_SHELL_INTEGRATION".to_string(), String::new()); @@ -1230,66 +866,15 @@ pub fn setup(program: Option<&str>, args: &[String], has_custom_args: bool) -> O Some(injection) } -/// Reaching a shell on *another machine* — the native-SSH panes (`daemon::ssh`). -/// -/// Everything above this point injects by arranging a *local* process spawn: we -/// write files into a throwaway dir and hand the child an env var or an argv -/// entry pointing at them. None of that is available over SSH, where the only -/// lever is the one string the `exec` channel request carries — sshd runs it as -/// `$SHELL -c <string>` and that is the whole interface. -/// -/// So the remote path inverts the mechanism: instead of *configuring* a spawn, -/// we send a short script that **materializes the same files on the remote side -/// and then `exec`s the real shell through them**. The shell that comes out the -/// other end is configured exactly as a local one — same `ZDOTDIR` redirector -/// chain, same bash rcfile replaying the login-file chain, same fish `-C` — so -/// the integration bodies above are reused verbatim rather than forked. -/// -/// **Why probe first.** The bootstrap script cannot be shell-agnostic: sshd -/// hands it to the user's *login* shell, so a POSIX script is parsed by fish (a -/// syntax error) and a fish script by zsh. Warp solves this with a single -/// expression contorted to parse identically in sh/bash/zsh/fish; we instead -/// spend one cheap round-trip on [`PROBE_COMMAND`] — deliberately written to be -/// valid in all of them because it contains no substitution, no assignment and -/// no grouping — and then emit a script in the dialect we now know we're -/// talking to. The result reads like ordinary shell code instead of a puzzle, -/// and it also tells us when to keep our hands off entirely: a remote whose -/// login shell isn't one of the three (or isn't POSIX at all — a Windows -/// `cmd.exe`, where the probe echoes a literal `$SHELL`) parses as `None` and -/// the caller falls back to a plain shell request. That negative answer is the -/// load-bearing half: it is what keeps a non-Unix remote from being handed a -/// script it would choke on, and it's why the probe exists rather than us just -/// sending a bootstrap and hoping. -/// -/// The probe's cost is paid once per *connection*, not once per pane — the -/// caller caches it on the connection registry key, so extra tabs to a host -/// already open cost nothing. pub mod remote { use super::{FISH_INTEGRATION, bash_rcfile, zsh_redirectors}; - /// Asks the remote for the login shell it would have started. - /// - /// Runs under whatever that shell is, so it is restricted to the - /// intersection of sh/bash/zsh/fish/csh syntax: two `echo`s and a `;`. - /// Notably absent is any command substitution — fish only learned `$(…)` in - /// 3.4 and csh never had it — and any assignment, which fish spells - /// differently from everyone else. - /// - /// The marker line is what makes the answer parseable rather than guessed: - /// a remote `.zshenv`/`config.fish` that prints something of its own (banner, - /// version-manager chatter) is ignored, because we only read the line that - /// follows the marker. See [`parse_probe`]. pub const PROBE_COMMAND: &str = "echo __tty7_shell; echo $SHELL"; - /// The line [`PROBE_COMMAND`] prints immediately before the shell path. const PROBE_MARKER: &str = "__tty7_shell"; - /// Heredoc delimiter for the rc files the bootstrap writes remotely. Quoted - /// at the use site (`<<'…'`) so the bodies are copied *literally* — they are - /// full of `$`, backticks and backslashes that must reach the file intact. const HEREDOC: &str = "__TTY7_RC_EOF__"; - /// A remote login shell we know how to integrate. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum RemoteShell { Zsh, @@ -1308,20 +893,12 @@ pub mod remote { } } - /// Read [`PROBE_COMMAND`]'s output: the first non-empty line after the - /// marker is the remote's `$SHELL`. - /// - /// Returns `None` — meaning "launch a plain shell, inject nothing" — unless - /// that line is an absolute path to a shell we support. The absolute-path - /// requirement is what rejects a non-POSIX remote: `cmd.exe` echoes the - /// string `$SHELL` back unexpanded, and PowerShell prints an empty line, so - /// neither can be mistaken for an answer. pub fn parse_probe(output: &str) -> Option<(RemoteShell, String)> { let mut lines = output .lines() .map(|l| l.trim_end_matches('\r').trim()) .skip_while(|l| *l != PROBE_MARKER); - lines.next()?; // the marker itself + lines.next()?; let path = lines.find(|l| !l.is_empty())?; if !path.starts_with('/') { return None; @@ -1329,12 +906,6 @@ pub mod remote { RemoteShell::from_path(path).map(|shell| (shell, path.to_string())) } - /// The script to send as the channel's `exec` request: it sets the remote up - /// for integration and `exec`s `shell_path` as the session's shell. - /// - /// Every arm ends in an `exec` of the user's own shell, and every arm - /// reaches that `exec` even if its setup failed — a remote with a full or - /// read-only `$TMPDIR` loses the integration, never the session. pub fn bootstrap_command(shell: RemoteShell, shell_path: &str) -> String { match shell { RemoteShell::Zsh => zsh_bootstrap(shell_path), @@ -1343,26 +914,14 @@ pub mod remote { } } - /// Quote for a POSIX-family shell (zsh/bash): wrap in single quotes and - /// spell an embedded quote the only way single-quoting allows. fn shell_quote(s: &str) -> String { format!("'{}'", s.replace('\'', r"'\''")) } - /// Quote for fish, whose single quotes — unlike POSIX's fully literal ones — - /// honour exactly two escapes, `\\` and `\'`. Both need doubling up, and - /// nothing else may be touched: the fish body is dense with `\e`, `\a` and - /// `$argv`, all of which must survive verbatim. fn fish_quote(s: &str) -> String { format!("'{}'", s.replace('\\', r"\\").replace('\'', r"\'")) } - /// `command cat > <dir>/<name> <<'EOF' … EOF`, the remote-side equivalent of - /// the local `std::fs::write`. - /// - /// `command` bypasses any alias or function the remote's own startup files - /// have put in the way — sshd's `$SHELL -c` still reads `.zshenv`, so we are - /// not running in a pristine environment. fn write_file(out: &mut String, name: &str, body: &str) { out.push_str(&format!( "command cat > \"$__tty7_d/{name}\" <<'{HEREDOC}'\n{}\n{HEREDOC}\n", @@ -1370,15 +929,6 @@ pub mod remote { )); } - /// Recreate the local `ZDOTDIR` redirector dir on the remote, point `ZDOTDIR` - /// at it, and exec zsh as a login shell — the same shape as [`setup_zsh`], - /// with the throwaway dir built by a script instead of by `std::fs`. - /// - /// `ZDOTDIR` is only exported once every redirector is confirmed written: - /// pointing zsh at a half-populated dir would silently cost the user their - /// dotfiles, which is far worse than not integrating at all. - /// - /// [`setup_zsh`]: super::setup_zsh fn zsh_bootstrap(shell_path: &str) -> String { let mut out = String::new(); out.push_str("__tty7_d=${TMPDIR:-/tmp}/tty7-zdotdir-$$\n"); @@ -1386,8 +936,6 @@ pub mod remote { let mut guard = String::new(); for (name, contents) in zsh_redirectors() { - // The cleanup hook rides along in .zshrc, after the integration body - // — see ZSH_CLEANUP_HOOK for why it can't be a plain `rm` here. let body = if name == ".zshrc" { format!("{contents}{ZSH_CLEANUP_HOOK}") } else { @@ -1403,12 +951,6 @@ pub mod remote { out } - /// Write the rcfile and exec bash *non-login* through it, exactly as - /// [`setup_bash`] does locally and for the same reason: `--rcfile` is - /// silently ignored for a login shell, so the rcfile replays the login-file - /// chain itself. - /// - /// [`setup_bash`]: super::setup_bash fn bash_bootstrap(shell_path: &str) -> String { let quoted = shell_quote(shell_path); let mut out = String::new(); @@ -1427,8 +969,6 @@ pub mod remote { out } - /// fish needs nothing on disk at either end: `-C` runs the body after the - /// user's own `config.fish`, so the whole bootstrap is one `exec`. fn fish_bootstrap(shell_path: &str) -> String { format!( "exec {} -C {} -l\n", @@ -1437,24 +977,6 @@ pub mod remote { ) } - /// Remove the throwaway rc dir once the shell has finished reading it. - /// - /// Unlike a local pane — where the terminal owns the dir and deletes it on - /// drop — nothing on the tty7 side can reach the remote filesystem, so the - /// shell has to clean up after itself. The deletion can't happen in the - /// bootstrap script (zsh hasn't read the files yet) nor at the end of the - /// rc file (zsh still has `.zlogin` to read), so it hangs off the first - /// `precmd`: by the time a prompt is drawn every startup file has been read, - /// and unlinking them is invisible to the running shell. - /// - /// The path arrives in an exported `TTY7_RM_DIR` because a quoted heredoc - /// copies its body literally — there is no interpolation to splice a Rust - /// value into. It's immediately demoted to a plain shell variable so it - /// doesn't leak into every child process. - /// - /// The hook doesn't unregister itself, unlike its neighbours: re-running a - /// two-line no-op each prompt is cheaper than the array surgery removing it - /// would cost. const ZSH_CLEANUP_HOOK: &str = r#" # --- tty7 remote cleanup (zsh) --- if [[ -n "$TTY7_RM_DIR" ]]; then @@ -1471,16 +993,6 @@ fi # --- end tty7 remote cleanup --- "#; - /// The bash counterpart of [`ZSH_CLEANUP_HOOK`], registered through the - /// `precmd_functions` array that the integration body's vendored - /// bash-preexec drives. - /// - /// This sits *outside* that body's install guard, so on a remote that - /// already has tty7 integration in its own `.bashrc` the guard skips the - /// install and no `precmd_functions` ever runs — the dir then outlives the - /// session. That is the one case we let leak: a few KB under `/tmp` on a - /// host the user has explicitly set up, versus the alternative of an `EXIT` - /// trap that would clobber whatever trap their dotfiles installed. const BASH_CLEANUP_HOOK: &str = r#" # --- tty7 remote cleanup (bash) --- if [[ -n "$TTY7_RM_DIR" ]]; then @@ -1507,8 +1019,6 @@ fi parse_probe("__tty7_shell\n/bin/zsh\n"), Some((RemoteShell::Zsh, "/bin/zsh".to_string())) ); - // Startup chatter ahead of the marker is ignored — a remote - // `.zshenv` that echoes a banner must not be read as the answer. assert_eq!( parse_probe("Welcome to prod!\n__tty7_shell\n/usr/local/bin/fish\n"), Some((RemoteShell::Fish, "/usr/local/bin/fish".to_string())) @@ -1521,21 +1031,15 @@ fi #[test] fn probe_declines_anything_that_isnt_a_shell_we_know() { - // cmd.exe echoes the variable back unexpanded; PowerShell prints - // nothing. Neither may be mistaken for a POSIX remote. assert_eq!(parse_probe("__tty7_shell\n$SHELL\n"), None); assert_eq!(parse_probe("__tty7_shell\n\n"), None); - // A shell we have no integration body for. assert_eq!(parse_probe("__tty7_shell\n/bin/ksh\n"), None); - // No marker at all: the command never ran as intended. assert_eq!(parse_probe("/bin/zsh\n"), None); } #[test] fn zsh_bootstrap_gates_zdotdir_on_every_redirector_landing() { let script = bootstrap_command(RemoteShell::Zsh, "/bin/zsh"); - // Pointing zsh at a partially-written dir would drop the user's - // dotfiles, so all four files are checked before ZDOTDIR is set. for name in [".zshenv", ".zprofile", ".zshrc", ".zlogin"] { assert!( script.contains(&format!("[ -s \"$__tty7_d/{name}\" ] &&")), @@ -1545,16 +1049,11 @@ fi let export = script.find("export ZDOTDIR=").expect("exports ZDOTDIR"); let exec = script.find("exec '/bin/zsh' -l").expect("execs zsh"); assert!(export < exec); - // The integration body must actually reach the remote .zshrc. assert!(script.contains("__tty7_report_cwd")); } #[test] fn file_writing_bootstraps_end_in_a_bare_exec_of_the_users_shell() { - // The last thing either script does is hand over an unintegrated - // shell, so a remote where the setup failed loses the integration - // and nothing else. (fish writes no files and so has no failure - // path to fall out of — it is a single `exec`, checked below.) for (shell, path) in [ (RemoteShell::Zsh, "/bin/zsh"), (RemoteShell::Bash, "/bin/bash"), @@ -1572,8 +1071,6 @@ fi #[test] fn bash_bootstrap_forces_a_non_login_shell_through_the_rcfile() { let script = bootstrap_command(RemoteShell::Bash, "/bin/bash"); - // `--rcfile` is ignored for login shells, so the integrated arm must - // be `-i`, with the rcfile replaying the login chain itself. assert!(script.contains("exec '/bin/bash' --rcfile \"$__tty7_d/bashrc\" -i")); assert!(script.contains("source /etc/profile")); } @@ -1581,18 +1078,10 @@ fi #[test] fn fish_bootstrap_is_one_exec_carrying_the_escaped_body() { let script = bootstrap_command(RemoteShell::Fish, "/usr/bin/fish"); - // The whole bootstrap is a single (multi-line) command: fish reads - // `-C` after its own config.fish, so there is nothing to write to - // disk and no failure path to fall out of. assert!(script.starts_with("exec '/usr/bin/fish' -C '")); assert!(script.trim_end().ends_with("' -l")); assert!(!script.contains("mkdir")); - // fish single quotes honour exactly \\ and \', so both must be - // doubled up on the way in. The body's `printf '\e]%s\a' $argv[1]` - // therefore arrives with its quotes escaped *and* its backslashes - // doubled — get either wrong and fish sees a terminated string or - // an escape sequence instead of the literal text. assert!(script.contains(r"printf \'\\e]%s\\a\' $argv[1]")); } @@ -1604,26 +1093,12 @@ fi #[test] fn heredoc_delimiter_cannot_appear_in_a_body_it_delimits() { - // A body containing the delimiter on its own line would end the - // heredoc early and spill shell code into the script. for body in [ZSH_INTEGRATION, BASH_INTEGRATION, FISH_INTEGRATION] { assert!(!body.contains(HEREDOC)); } assert!(!bash_rcfile().contains(HEREDOC)); } - /// Parse `script` with the real shell, without running it. Returns - /// `None` when that shell isn't installed here, which is a skip and not - /// a failure — these tests are a local safety net, not a CI dependency. - /// - /// Unix-only, and not merely for convenience: on Windows a bare `bash` - /// resolves through `PATH` to `C:\Windows\System32\bash.exe` — the WSL - /// launcher, not a shell — which on a machine with no distro installed - /// exits non-zero with an empty stderr and is indistinguishable from a - /// rejected script. (`is_msys_bash` above exists for the same trap on - /// the production path.) Nothing is lost by skipping: these scripts are - /// destined for a remote POSIX host, so their syntax has nothing to do - /// with the platform running the test, and the Unix CI jobs cover them. #[cfg(unix)] fn parse_check( shell: &str, @@ -1653,19 +1128,6 @@ fi )) } - /// The bootstrap scripts, and the rc files they carry, must parse under - /// the shells they're written for. - /// - /// This is worth a real subprocess where the local paths' unit tests - /// aren't, because a syntax error costs far more here: a local shell - /// with a broken rcfile still opens (bash just complains), but a remote - /// one takes the whole `exec` request down with it and the user gets a - /// session that dies on connect. Quoting is also doing much more work - /// on this path — a heredoc, two escaping dialects, and a body that - /// travels as an argv entry — so there is correspondingly more to break. - /// - /// Skipped where the shell isn't installed; `-n` / `--no-execute` parse - /// without running anything, so this never spawns a shell session. #[cfg(unix)] #[test] fn bootstrap_scripts_parse_under_their_real_shells() { @@ -1682,8 +1144,6 @@ fi } } - /// A quoted heredoc's body is data, so the check above never parses the - /// rc files it writes — they have to be fed to the shell separately. #[cfg(unix)] #[test] fn heredoc_bodies_parse_under_their_real_shells() { @@ -1711,19 +1171,11 @@ mod tests { #[test] fn edit_mode_detection_survives_rebound_escape_and_inputrc() { - // zsh: plugins like zsh-vi-mode rebind `^[` to their own widgets - // (`zvm_readkeys_handler`), so sniffing the Esc widget for - // `vi-cmd-mode` misses them. The `main` keymap link is durable: both - // plain `bindkey -v` and zsh-vi-mode link main to viins, and emacs - // mode links it to emacs (`bindkey -A viins main` vs `-A emacs main`). assert!( ZSH_INTEGRATION.contains("bindkey -lL main"), "zsh edit-mode detection must key off the main keymap link" ); assert!(ZSH_INTEGRATION.contains("viins")); - // bash: `[[ -o vi ]]` misses vi mode set only via ~/.inputrc - // (`set editing-mode vi` flips readline but not the shell option); - // `bind -v` reports readline's actual mode either way. assert!( BASH_INTEGRATION.contains("editing-mode vi"), "bash edit-mode detection must read readline's mode via bind -v" @@ -1732,29 +1184,14 @@ mod tests { #[test] fn is_our_zdotdir_matches_only_our_prefix() { - // A dir we created (basename carries the tty7 prefix) is recognized. assert!(is_our_zdotdir("/tmp/tty7-zdotdir-1234-0")); assert!(is_our_zdotdir("tty7-zdotdir-x")); - // The user's real dirs and unrelated paths are not ours. assert!(!is_our_zdotdir("/home/alice/.config/zsh")); assert!(!is_our_zdotdir("/tmp/other-zdotdir")); assert!(!is_our_zdotdir("")); - // A component that only contains the prefix mid-name is not a match. assert!(!is_our_zdotdir("/tmp/not-tty7-zdotdir-1")); } - /// Drive a real shell over a real PTY through `injection`, submit one - /// failing command, and return everything it wrote up to the `D` mark. - /// - /// Shared by the Git Bash and WSL end-to-end tests. Two ConPTY behaviors - /// are baked in and must not be "simplified" away: - /// - /// - the writer is held for the whole call, because closing a ConPTY's - /// input side raises a console control event that kills the shell with - /// `STATUS_CONTROL_C_EXIT` before it ever reaches a prompt; and - /// - draining happens on a worker thread against a deadline, because a - /// ConPTY master does not reliably EOF when its child exits, so an - /// inline read would block forever rather than fail. #[cfg(windows)] fn prompt_cycle_over_pty(program: &str, injection: &Injection) -> String { use portable_pty::{CommandBuilder, PtySize, native_pty_system}; @@ -1777,8 +1214,6 @@ mod tests { let mut writer = pty.master.take_writer().expect("writer"); let mut reader = pty.master.try_clone_reader().expect("reader"); - // `false` gives D a non-zero exit code to carry, so a hardcoded 0 in - // the report path can't pass these tests. writer.write_all(b"false\n").expect("write"); writer.flush().expect("flush"); @@ -1810,8 +1245,6 @@ mod tests { String::from_utf8_lossy(&out).into_owned() } - /// The OSC 7 cwd a captured PTY transcript reported, decoded by the - /// daemon's own parser so emitter and consumer are proven to agree. #[cfg(windows)] fn reported_cwd(text: &str) -> PathBuf { let payload = text @@ -1823,13 +1256,6 @@ mod tests { .unwrap_or_else(|| panic!("daemon could not parse OSC 7 payload {payload:?}")) } - /// End-to-end on a real PTY: spawn the actual Git Bash through the actual - /// `setup` output and assert the full A/B/C/D cycle comes back. Guards the - /// parts no pure test can see — that msys2 bash accepts the rcfile path we - /// hand it, that our hooks survive Git Bash's own `/etc/profile` (which - /// installs a `PROMPT_COMMAND` of its own), and that bash-preexec's DEBUG - /// trap actually fires under a Windows pty. Skips when Git for Windows - /// isn't installed, so it's a no-op on a machine without it. #[cfg(windows)] #[test] fn git_bash_reports_the_full_prompt_cycle_over_a_real_pty() { @@ -1838,8 +1264,6 @@ mod tests { return; }; let bash = bash.to_string_lossy().into_owned(); - // `has_custom_args: false` — the dropdown's `-i -l` are tty7's own, so - // the real spawn path reaches setup_bash with them overridable. let injection = setup(Some(&bash), &[], false).expect("bash integration"); let text = prompt_cycle_over_pty(&bash, &injection); @@ -1849,11 +1273,6 @@ mod tests { "Git Bash must report {mark}; got:\n{text}" ); } - // cwd reporting rides along on the same hooks. Assert the *decoded* - // path, not just the marker's presence: Git Bash's `$PWD` is an msys - // path (`/c/Users/x`) that Windows resolves drive-relative to a - // non-existent `C:\c\Users\x`, which silently disables the git-status - // probe and breaks split/new-tab. let cwd = reported_cwd(&text); assert!( cwd.exists(), @@ -1862,18 +1281,6 @@ mod tests { ); } - /// End-to-end on a real PTY, through `wsl.exe` into an actual distro. - /// This is the only thing that can show the injection survives the whole - /// chain: `WSLENV` translating the rcfile path to the distro's view of the - /// filesystem, `wsl.exe` passing our `sh -c` through without a shell to - /// mangle its quoting, and the distro's own `/etc/profile` + `~/.bashrc` - /// running before our hooks layer on top. - /// - /// Also covers the in-distro shell pick: this machine's distro runs bash, so - /// reaching the marks at all means [`WSL_EXEC_SCRIPT`]'s `case` took its - /// bash arm after surviving Windows argv quoting. - /// - /// Skips when WSL isn't installed, so it's a no-op on a machine without it. #[cfg(windows)] #[test] fn wsl_reports_the_full_prompt_cycle_over_a_real_pty() { @@ -1881,7 +1288,6 @@ mod tests { eprintln!("skipping: no WSL distributions installed"); return; }; - // Exactly the args the new-tab dropdown produces for this distro. let args: Vec<String> = vec![ "--distribution".into(), distro.clone(), @@ -1897,10 +1303,6 @@ mod tests { "WSL ({distro}) must report {mark}; got:\n{text}" ); } - // The distro's cwd is a *Linux* path, and must stay one — translating it - // to something Windows-resolvable would be wrong, not helpful. What - // matters is that the pane is tagged so nothing local consumes it; that - // tagging is asserted in `pane`'s `wsl_remote_context` tests. let cwd = reported_cwd(&text); assert!( cwd.to_string_lossy().starts_with('/'), @@ -1920,10 +1322,6 @@ mod tests { shell_kind(Some("/usr/local/bin/fish")), Some(ShellKind::Fish) )); - // PowerShell, in every spelling: bare and `.exe`, Windows PowerShell and - // pwsh 7+, and case-insensitively (Windows program names ignore case). - // Paths use `/` so `Path::file_name` splits them the same on every host; - // backslash separators are `std::path`'s job and only split on Windows. for prog in [ "powershell.exe", "powershell", @@ -1937,25 +1335,12 @@ mod tests { "{prog} should map to PowerShell" ); } - // Unknown shells (and absolute paths to them) resolve to None. assert!(shell_kind(Some("/bin/sh")).is_none()); - // cmd has no preexec hook of any kind, so it stays unsupported on - // purpose (see the module docs). assert!(shell_kind(Some("cmd.exe")).is_none()); - // `wsl.exe` is the launcher, not a shell — it maps to its own kind so - // `setup` can reach through it into the distro. assert!(matches!(shell_kind(Some("wsl.exe")), Some(ShellKind::Wsl))); assert!(matches!(shell_kind(Some("wsl")), Some(ShellKind::Wsl))); } - /// Regression: `setup_wsl` used to probe the distro's login shell with a - /// synchronous `wsl.exe` call. The client waits for the daemon's `Spawn` - /// reply (`terminal::remote::spawn`), so on a cold WSL start — seconds, - /// while the distro boots — that froze the entire window. - /// - /// Naming a distro that cannot exist is the deterministic form of the - /// check: if anything asked the distro a question, this could not succeed. - /// A timing bound would only catch it on a cold machine. #[cfg(windows)] #[test] fn wsl_setup_never_contacts_the_distro() { @@ -1968,8 +1353,6 @@ mod tests { let inj = setup(Some("wsl.exe"), &args, false) .expect("setup must not depend on reaching the distro"); - // The launch flags are rebuilt, not appended to, and the command sits - // after `--`. let sep = inj.args.iter().position(|a| a == "--").expect("`--`"); assert_eq!( &inj.args[..sep], @@ -1982,7 +1365,6 @@ mod tests { ); assert_eq!(inj.args[sep + 1], "sh"); assert_eq!(inj.args[sep + 2], "-c"); - // The shell decision is inside the script, not resolved out here. assert!(inj.args[sep + 3].contains("$SHELL")); assert!(inj.args[sep + 3].contains("--rcfile")); assert!(inj.replaces_argv); @@ -2008,29 +1390,21 @@ mod tests { assert_eq!(wsl_distro(&eq).as_deref(), Some("Arch")); assert_eq!(wsl_cd(&eq).as_deref(), Some("/tmp")); - // No distro flag is a valid answer — `wsl.exe` then picks the default. assert_eq!(wsl_distro(&[]), None); - // A trailing flag with no value must not panic. assert_eq!(wsl_distro(&["--distribution".to_string()]), None); } #[test] fn wslenv_preserves_the_users_own_entries() { - // Regression guard: overwriting `WSLENV` silently drops whatever the - // user configured, breaking *their* Windows->WSL variable passing. assert_eq!( wslenv_with(Some("MYVAR/p:OTHER"), &["TTY7_RC/p"]), "MYVAR/p:OTHER:TTY7_RC/p" ); assert_eq!(wslenv_with(None, &["TTY7_RC/p"]), "TTY7_RC/p"); assert_eq!(wslenv_with(Some(""), &["TTY7_RC/p"]), "TTY7_RC/p"); - // Already present: left exactly as the user spelled it, not duplicated. assert_eq!(wslenv_with(Some("TTY7_RC/l"), &["TTY7_RC/p"]), "TTY7_RC/l"); } - /// Git Bash is spawned by its absolute `bash.exe` path, so `.exe` must be - /// stripped for *every* shell and not just PowerShell — otherwise the one - /// bash reachable on Windows silently gets no integration. #[test] fn shell_kind_strips_exe_for_non_powershell_shells() { for prog in [ @@ -2042,7 +1416,6 @@ mod tests { "{prog} should map to Bash" ); } - // Off Windows the guard is inert, so the bare spellings still resolve. if !cfg!(windows) { for prog in ["bash.exe", "BASH.EXE", "bash"] { assert!(matches!(shell_kind(Some(prog)), Some(ShellKind::Bash))); @@ -2050,11 +1423,6 @@ mod tests { } } - /// `C:\Windows\System32\bash.exe` is the WSL launcher, not a shell we can - /// inject into: `--rcfile` replaces `~/.bashrc` rather than adding to it, - /// and the Windows path we pass does not exist inside the distro, so the - /// user would silently lose their whole bash config. It also normally sits - /// ahead of `Git\bin` on PATH, which is why a bare name is declined too. #[test] #[cfg(windows)] fn shell_kind_declines_the_wsl_bash_launcher() { @@ -2070,8 +1438,6 @@ mod tests { "{prog} is the WSL launcher and must not be treated as Bash" ); } - // A bare name resolves through PATH at spawn time, where System32 - // usually wins — unpredictable, so fail closed. for prog in ["bash", "bash.exe", "BASH.EXE"] { assert!( shell_kind(Some(prog)).is_none(), @@ -2080,8 +1446,6 @@ mod tests { } } - /// The rcfile path is handed to msys2 bash, which reads `\` as an escape in - /// the string contexts the path can later reach — see [`bash_path`]. #[test] fn bash_rcfile_path_uses_forward_slashes_on_windows() { let rendered = bash_path(Path::new( @@ -2093,8 +1457,6 @@ mod tests { "C:/Users/a/AppData/Local/Temp/tty7-bashrc-1-0/bashrc" ); } - // Unix paths are already separator-correct and must pass through - // untouched on every host. assert_eq!( bash_path(Path::new("/tmp/tty7-bashrc-1-0/bashrc")), "/tmp/tty7-bashrc-1-0/bashrc" @@ -2108,9 +1470,6 @@ mod tests { let names: Vec<&str> = files.iter().map(|(n, _)| *n).collect(); assert_eq!(names, [".zshenv", ".zprofile", ".zshrc", ".zlogin"]); for (name, body) in &files { - // Every redirector sources the user's real file of the same name, - // resolved via the captured real ZDOTDIR (`$TTY7_USER_ZDOTDIR`, or - // $HOME when the user never set one). assert!( body.contains("$TTY7_USER_ZDOTDIR"), "{name} should reference the user's real ZDOTDIR" @@ -2118,7 +1477,6 @@ mod tests { assert!(body.contains(name), "{name} should source its own name"); assert!(body.contains("source"), "{name} should source"); } - // Only .zshrc carries our integration body (so it extends the user's PROMPT). let zshrc = &files[2].1; assert!(zshrc.contains("__tty7_precmd")); assert!(zshrc.contains("133;A")); @@ -2127,12 +1485,6 @@ mod tests { #[test] fn zsh_redirectors_point_zdotdir_at_the_real_dir_only_while_sourcing() { - // Issue #15: ZDOTDIR must resolve to the user's *real* config dir while - // their startup files run — Zim/oh-my-zsh/compinit key their install state - // off ${ZDOTDIR:-$HOME}, and our throwaway dir is empty, so leaving ZDOTDIR - // pointed there makes them reinstall on every pane. Each redirector must: - // 1. stash our dir, 2. aim ZDOTDIR at the real dir, 3. source, then - // 4. restore our dir so zsh still finds the *next* redirector. for (name, body) in zsh_redirectors() { let save = body.find("__tty7_ztmp=$ZDOTDIR").expect("stashes our dir"); let aim = body @@ -2156,11 +1508,6 @@ mod tests { #[test] fn zshenv_recaptures_a_user_relocated_zdotdir() { - // The canonical layout is a tiny ~/.zshenv that does `ZDOTDIR=~/.config/zsh`, - // with the real config living there. After sourcing the user's .zshenv we - // must capture wherever ZDOTDIR now points so the .zprofile/.zshrc/.zlogin - // redirectors source from the *relocated* dir (and nested tty7 sees it too), - // rather than falling back to $HOME and dropping the user's config. let files = zsh_redirectors(); let zshenv = &files[0].1; let source = zshenv.find("source \"${ZDOTDIR:-$HOME}/.zshenv\"").unwrap(); @@ -2172,7 +1519,6 @@ mod tests { source < recapture && recapture < restore, "recapture must run after sourcing the user's .zshenv, before we restore our dir" ); - // Only .zshenv recaptures; the other three just source and restore. for (name, body) in &files[1..] { assert!( !body.contains("export TTY7_USER_ZDOTDIR"), @@ -2183,10 +1529,6 @@ mod tests { #[test] fn zsh_integration_restores_real_zdotdir_after_startup() { - // With every startup file read, the integration body must hand ZDOTDIR back - // to the user's real dir for the live session (runtime ${ZDOTDIR:-$HOME} - // lookups, a nested plain `zsh`). It's a one-shot precmd hook that unhooks - // itself so it doesn't re-fire on every prompt. assert!(ZSH_INTEGRATION.contains("__tty7_restore_zdotdir")); assert!(ZSH_INTEGRATION.contains("ZDOTDIR=${TTY7_USER_ZDOTDIR:-$HOME}")); assert!( @@ -2201,21 +1543,12 @@ mod tests { assert!(rc.contains("/etc/profile")); assert!(rc.contains("~/.bash_profile")); assert!(rc.contains("~/.bashrc")); - // Our integration (bash-preexec derived) is appended. assert!(rc.contains("__tty7")); assert!(rc.contains("133;")); } #[test] fn every_integration_guards_install_on_empty_sentinel() { - // `setup()` resets TTY7_SHELL_INTEGRATION to an empty-but-exported "" at each - // spawn boundary (never *unsets* it), so every shell's install-once guard must - // key off the sentinel being *empty*, i.e. the `-z "$TTY7_SHELL_INTEGRATION"` - // idiom shared by zsh/bash. Fish once used `not set -q TTY7_SHELL_INTEGRATION` - // (definedness), and fish reports an empty exported var as *set* — so the guard - // was false on every launch and OSC 133 never armed. All three must share the - // emptiness test so the reset installs a fresh top-level shell while an inherited - // `1` still blocks re-install. for (shell, body) in [ ("zsh", ZSH_INTEGRATION), ("bash", BASH_INTEGRATION), @@ -2227,8 +1560,6 @@ mod tests { (matching setup()'s empty-string reset), not on its mere definedness", ); } - // Fish specifically must not regress to the definedness test that broke it: an - // empty exported sentinel reads as *set*, which would skip the install. assert!( !FISH_INTEGRATION.contains("set -q TTY7_SHELL_INTEGRATION"), "fish must guard on emptiness (`test -z`), never `set -q`", @@ -2237,13 +1568,6 @@ mod tests { #[test] fn d_emitter_is_prepended_ahead_of_user_precmd_hooks() { - // The app only switches back to prompt-editing mode when `133;D` arrives. - // If D waited for the user's whole precmd chain (git-status prompts, - // conda — easily 100ms+), keys typed right after a command finished - // would be passed raw to the PTY and kernel-echoed into the grid — the - // stray-char + PROMPT_SP `%` artifact. So zsh/bash must emit D from a - // dedicated hook *prepended* to precmd_functions, while the rest of the - // bookkeeping (cwd, A, the PS1 B marker) stays appended/last. assert!( ZSH_INTEGRATION.contains("precmd_functions=(__tty7_precmd_d $precmd_functions)"), "zsh must prepend the D emitter (add-zsh-hook can only append)" @@ -2253,8 +1577,6 @@ mod tests { .contains(r#"precmd_functions=(__tty7_precmd_d "${precmd_functions[@]}")"#), "bash must prepend the D emitter" ); - // D comes from exactly one hook per shell — a second emission site would - // double-fire on every prompt. for (shell, body) in [ ("zsh", ZSH_INTEGRATION), ("bash", BASH_INTEGRATION), @@ -2270,11 +1592,6 @@ mod tests { #[test] fn every_cwd_report_escapes_literal_percent() { - // Regression: the daemon percent-DECODES the OSC 7 payload, so the - // reporters must escape a literal `%` in `$PWD` as %25 — otherwise a - // real dir like `/tmp/a%20b` is recorded as `/tmp/a b` (and `%2F` - // rewrites the path *structure*), breaking cwd-inheriting new tabs and - // session restore. for (shell, body, escape) in [ ("zsh", ZSH_INTEGRATION, r"${PWD//\%/%25}"), ("bash", BASH_INTEGRATION, r"${PWD//\%/%25}"), @@ -2293,21 +1610,12 @@ mod tests { "{shell} must not emit the raw $PWD in its OSC 7 report" ); } - // bash's msys branch reports `pwd -W` output rather than $PWD, so it - // needs the same escaping on its own variable. assert!( BASH_INTEGRATION.contains(r"${d//\%/%25}"), "bash's msys OSC 7 reporter must %-escape the literal percent too" ); } - /// Under Git Bash `$PWD` is an msys path (`/c/Users/x`). Windows reads that - /// as drive-relative, so it would land on `C:\c\Users\x` — a directory that - /// does not exist, silently killing the git-status probe and path completion, - /// and actively breaking split/new-tab (the client cwd wins over every - /// fallback in `pane::initial_working_directory`, so the next shell is - /// spawned with a bogus working directory). `pwd -W` is msys's translation - /// to the real Windows path. #[test] fn bash_reports_a_windows_path_under_msys() { let s = BASH_INTEGRATION; @@ -2319,17 +1627,10 @@ mod tests { s.contains("builtin pwd -W"), "bash's msys branch must translate the cwd with `pwd -W`" ); - // `pwd -W` yields `C:/Users/x` with no leading slash; a file: URI needs - // one so the daemon's `strip_uri_drive_slash` recognises the drive. assert!( s.contains(r#"file://%s/%s"#), "bash's msys branch must make the translated path URI-absolute" ); - // `pwd -W` is the identity for msys-only virtual mounts (`/proc`, - // `/dev`), which have no Windows path at all. Requiring a drive letter - // is what separates a translated path from an untranslated one — a - // leading-slash test cannot. Falling back to `$PWD` would defeat the - // whole point, so silence is the only safe answer here. assert!( s.contains(r#"[[ "$d" == ?:* ]] || return 0"#), "bash's msys branch must report nothing when `pwd -W` yields no drive" @@ -2340,9 +1641,6 @@ mod tests { ); } - /// The payload the msys branch builds must survive the daemon's own parser - /// and come out as a path Windows can actually use — the shape assertions - /// above cannot see that. Mirrors what `__tty7_report_cwd` emits. #[test] fn msys_payload_round_trips_through_parse_osc7() { let parse = |payload: &str| { @@ -2353,7 +1651,6 @@ mod tests { ("C:/Users/thoma/repo", "C:/Users/thoma/repo"), ("C:/", "C:/"), ("D:/work/a b", "D:/work/a b"), - // The `%` the reporter escapes must survive the round trip. ("C:/tmp/a%25c", "C:/tmp/a%c"), ] { let got = parse(&format!("7;file://localhost/{translated}")); @@ -2365,9 +1662,6 @@ mod tests { assert_eq!(got, want, "payload for {translated}"); } - // And the shape the guard exists to suppress: an untranslated msys path - // parses fine but yields a drive-relative path Windows resolves against - // the current drive, which is how `/c/Users/x` became `C:\c\Users\x`. if cfg!(windows) { let got = parse("7;file://localhost/c/Users/thoma"); assert_ne!(got, PathBuf::from("C:/Users/thoma")); @@ -2387,7 +1681,6 @@ mod tests { assert!(inj.args[1].contains("133;")); assert!(inj.env.is_empty()); assert!(!inj.replaces_argv); - // fish needs no throwaway dir on disk. assert!(inj.dir.is_none()); } @@ -2395,19 +1688,16 @@ mod tests { fn setup_zsh_writes_redirectors_and_points_zdotdir_at_them() { let inj = setup_zsh().expect("zsh setup should succeed"); let dir = inj.dir.clone().expect("zsh needs a throwaway dir"); - // ZDOTDIR points the shell at our throwaway dir. assert_eq!( inj.env.get("ZDOTDIR").map(String::as_str), Some(dir.to_string_lossy().as_ref()) ); assert!(!inj.replaces_argv); assert!(inj.args.is_empty()); - // All four redirector files landed on disk with the expected content. for (name, body) in zsh_redirectors() { let written = std::fs::read_to_string(dir.join(name)).expect("redirector written"); assert_eq!(written, body); } - // The dir basename is recognizable as ours (so a nested launch skips it). assert!(is_our_zdotdir(&dir.to_string_lossy())); let _ = std::fs::remove_dir_all(&dir); } @@ -2416,11 +1706,9 @@ mod tests { fn setup_bash_writes_rcfile_and_forces_non_login() { let inj = setup_bash().expect("bash setup should succeed"); let dir = inj.dir.clone().expect("bash needs a throwaway dir"); - // argv is `--rcfile <path> -i`, in that order. assert_eq!(inj.args[0], "--rcfile"); assert_eq!(inj.args[2], "-i"); assert!(inj.replaces_argv); - // The rc file on disk matches the generated template. let rc = std::fs::read_to_string(&inj.args[1]).expect("rcfile written"); assert_eq!(rc, bash_rcfile()); let _ = std::fs::remove_dir_all(&dir); @@ -2428,7 +1716,6 @@ mod tests { #[test] fn setup_dispatches_by_shell_and_sets_sentinel() { - // zsh → an injection carrying the "already active" sentinel (empty value). let inj = setup(Some("zsh"), &[], false).expect("zsh setup"); assert_eq!( inj.env.get("TTY7_SHELL_INTEGRATION").map(String::as_str), @@ -2438,13 +1725,9 @@ mod tests { let _ = std::fs::remove_dir_all(d); } - // fish → same sentinel, no files. let inj = setup(Some("fish"), &[], false).expect("fish setup"); assert!(inj.env.contains_key("TTY7_SHELL_INTEGRATION")); - // bash without custom args → full injection with non-login override. - // On Windows only a path identifiable as msys counts as Bash, since a - // bare name could resolve to the WSL launcher — see `is_msys_bash`. let bash = if cfg!(windows) { "C:/Program Files/Git/bin/bash.exe" } else { @@ -2457,34 +1740,25 @@ mod tests { let _ = std::fs::remove_dir_all(d); } - // bash WITH custom args → we must not second-guess the user: no injection. assert!(setup(Some(bash), &[], true).is_none()); - // PowerShell without custom args → encoded-command injection, no files. let inj = setup(Some("powershell.exe"), &[], false).expect("powershell setup"); assert!(inj.env.contains_key("TTY7_SHELL_INTEGRATION")); assert!(inj.dir.is_none()); assert!(!inj.replaces_argv); - // PowerShell WITH custom args → `-EncodedCommand` would collide with the - // user's own `-Command`/`-File`, so we launch bare. assert!(setup(Some("pwsh"), &[], true).is_none()); - // Unknown shell → no integration at all. assert!(setup(Some("/bin/sh"), &[], false).is_none()); } #[test] fn setup_powershell_injects_encoded_command_without_files() { let inj = setup_powershell().expect("powershell injection is infallible"); - // `-NoLogo -NoExit -EncodedCommand <base64>`, in that order — the encoded - // command must come last, since PowerShell treats it as the value. assert_eq!(inj.args[0], "-NoLogo"); assert_eq!(inj.args[1], "-NoExit"); assert_eq!(inj.args[2], "-EncodedCommand"); assert_eq!(inj.args.len(), 4); - // The payload is pure base64 (so it survives the Windows command line and - // needs no quoting) and decodes, as UTF-16LE, back to our script. let b64 = &inj.args[3]; assert!( b64.bytes() @@ -2492,7 +1766,6 @@ mod tests { "encoded command must be pure base64" ); assert_eq!(decode_utf16le_base64(b64), POWERSHELL_INTEGRATION); - // No throwaway dir, no forced spawn mode, no env of its own. assert!(inj.env.is_empty()); assert!(inj.dir.is_none()); assert!(!inj.replaces_argv); @@ -2501,44 +1774,30 @@ mod tests { #[test] fn powershell_integration_emits_every_osc_133_mark_and_cwd() { let s = POWERSHELL_INTEGRATION; - // A/B wrap the returned prompt; C from the readline hook; D with the exit - // code from the prompt hook; plus the OSC 7 cwd report. assert!(s.contains("]133;A")); assert!(s.contains("]133;B")); assert!(s.contains("]133;C")); assert!(s.contains("]133;D;$code")); assert!(s.contains("]7;file://")); - // Guarded on the empty sentinel like the other shells (PowerShell's own - // idiom for "unset or empty"), so an inherited `1` blocks re-install. assert!(s.contains("if (-not $env:TTY7_SHELL_INTEGRATION)")); - // $? must be captured before $LASTEXITCODE — an assignment resets $?. let ok_at = s.find("$ok = $?").expect("captures $?"); let exit_at = s.find("$lastExit = $LASTEXITCODE").expect("captures exit"); assert!(ok_at < exit_at, "$? must be read before the exit code"); - // The user's own prompt is preserved and called through, not replaced. assert!(s.contains("$global:__Tty7OrigPrompt = $function:prompt")); assert!(s.contains("& $global:__Tty7OrigPrompt")); - // The literal `%` in the cwd is escaped before the payload is built. assert!(s.contains(".Replace('%', '%25')")); } #[test] fn powershell_integration_sets_an_osc_title() { let s = POWERSHELL_INTEGRATION; - // Without an OSC 0/2 title every Windows tab stays generic (PowerShell - // profiles, unlike macOS's default zsh, set no title). The prompt hook - // must emit an OSC 0 "user@host:dir" title. assert!(s.contains("]0;$($env:USERNAME)@$($env:COMPUTERNAME):")); - // Home is abbreviated to `~`, matching how the other shells' titles read. assert!(s.contains("$titlePath = '~'")); - // The title path uses forward slashes so the tab-label parser (which splits - // on `/`) can take the last path segment on Windows too. assert!(s.contains("$titlePath = $fsPath.Replace('\\', '/')")); } #[test] fn base64_encode_matches_rfc4648_vectors() { - // The canonical RFC 4648 §10 test vectors, covering both padding cases. assert_eq!(base64_encode(b""), ""); assert_eq!(base64_encode(b"f"), "Zg=="); assert_eq!(base64_encode(b"fo"), "Zm8="); @@ -2550,7 +1809,6 @@ mod tests { #[test] fn powershell_encoded_command_round_trips_utf16le() { - // A string with a multi-byte char to exercise the UTF-16LE step. let script = "Write-Host 'héllo ✓'"; assert_eq!( decode_utf16le_base64(&powershell_encoded_command(script)), @@ -2558,9 +1816,6 @@ mod tests { ); } - /// Decode a base64 UTF-16LE string back to a Rust `String` — the inverse of - /// [`powershell_encoded_command`], used to check the encoder round-trips - /// without a PowerShell interpreter. fn decode_utf16le_base64(b64: &str) -> String { fn val(c: u8) -> Option<u32> { match c { @@ -2576,7 +1831,7 @@ mod tests { let mut acc = 0u32; let mut nbits = 0; for c in b64.bytes() { - let Some(v) = val(c) else { continue }; // skip padding + let Some(v) = val(c) else { continue }; acc = (acc << 6) | v; nbits += 6; if nbits >= 8 { @@ -2595,7 +1850,6 @@ mod tests { fn throwaway_dir_is_unique_per_call() { let a = throwaway_dir("tty7-test-").expect("dir a"); let b = throwaway_dir("tty7-test-").expect("dir b"); - // The monotonic counter guarantees distinct dirs even within one process. assert_ne!(a, b); assert!( a.file_name() diff --git a/crates/tty7-core/src/daemon/spawn.rs b/crates/tty7-core/src/daemon/spawn.rs index 60be5f31..5410c8c8 100644 --- a/crates/tty7-core/src/daemon/spawn.rs +++ b/crates/tty7-core/src/daemon/spawn.rs @@ -1,20 +1,3 @@ -//! GUI-side daemon launcher: make sure the persistent terminal daemon is running -//! before the GUI tries to connect, auto-spawning it as a *detached* background -//! process if it isn't. -//! -//! The daemon (`tty7 --daemon`, see `main.rs`) is a long-lived process that owns -//! all PTYs and outlives the GUI. The GUI must not become its parent in any way -//! that would let a GUI exit kill it, so we: -//! - re-exec our own binary with `--daemon` (and the same `--config-dir`, so the -//! spawned daemon shares the GUI's config-dir-isolated endpoint — dev and prod -//! deliberately run separate daemons); -//! - detach the child from the GUI's process group/session (`setsid()` on Unix; -//! `DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP` creation flags on Windows); -//! - give it no console of its own (std streams → the null device); -//! - never `wait()` on it (it's meant to run forever). -//! Then we poll the endpoint until it's connectable, so the caller can immediately -//! proceed to connect. - use std::io; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; @@ -24,59 +7,27 @@ use crate::core::config; use crate::daemon::protocol::{ClientMsg, DaemonMsg, DaemonVersion, PROTOCOL_VERSION}; use crate::daemon::{pidfile, transport}; -/// How long to wait for a freshly spawned daemon to start listening before we -/// give up. Generous enough to cover a cold process start, short enough that a -/// genuinely-broken daemon surfaces as an error quickly rather than hanging the -/// GUI launch. const STARTUP_TIMEOUT: Duration = Duration::from_secs(3); -/// Poll interval while waiting for the socket to come up. const POLL_INTERVAL: Duration = Duration::from_millis(50); -/// How long the version handshake with an already-running daemon may take. -/// Local socket, tiny reply — a daemon that can't answer within this is wedged -/// (or so old it dropped the connection), and gets replaced either way. const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(2); -/// How long to wait for the old daemon to exit after we ask it to shut down. -/// Generous on purpose: the daemon hangs up every pane's child (a ~200 ms SIGHUP -/// grace each) before it exits, so a session with several panes needs a moment. const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(6); -/// How long a SIGTERMed daemon gets to finish its graceful teardown (same -/// per-pane SIGHUP grace as above) before we escalate to SIGKILL. #[cfg(any(target_os = "macos", target_os = "linux"))] const REAP_TERM_TIMEOUT: Duration = Duration::from_secs(6); -/// How long a SIGKILLed daemon gets to disappear from the process table. #[cfg(any(target_os = "macos", target_os = "linux"))] const REAP_KILL_TIMEOUT: Duration = Duration::from_secs(2); -/// A live daemon `ensure_running` reused *despite* a protocol mismatch: killing -/// it would end every persisted session, and the mismatch may well be benign -/// for the messages actually exercised — so that call is the user's to make, -/// not startup's. Recorded here and consumed by the first window -/// ([`take_mismatched_daemon`]), which raises a keep-or-restart prompt. pub struct MismatchedDaemon { - /// What the daemon answered, or `None` for one so old it predates the - /// `Version` request entirely. pub version: Option<DaemonVersion>, } static MISMATCHED_DAEMON: std::sync::Mutex<Option<MismatchedDaemon>> = std::sync::Mutex::new(None); -/// The protocol mismatch recorded by [`ensure_running`] this launch, if any. -/// Take-semantics so the prompt fires once per launch, not per window. pub fn take_mismatched_daemon() -> Option<MismatchedDaemon> { MISMATCHED_DAEMON.lock().ok()?.take() } -/// What the version handshake learned about the daemon currently serving this -/// process's endpoint. Refreshed by every [`ensure_running`] — including the -/// one `RemoteTerminal`'s spawn retry runs after a daemon death — and cleared -/// when the daemon predates the handshake, so a reader never acts on the -/// identity of a daemon that is no longer the one answering. static LOCAL_DAEMON: std::sync::Mutex<Option<DaemonVersion>> = std::sync::Mutex::new(None); -/// Whether the serving daemon advertises `feature` -/// (e.g. [`crate::daemon::protocol::FEATURE_PANE_OWNER`]). `false` when -/// nothing is known — the safe answer, because every capability gated on this -/// has a legacy fallback. pub fn local_daemon_supports(feature: &str) -> bool { LOCAL_DAEMON .lock() @@ -91,34 +42,14 @@ fn note_local_daemon(version: Option<DaemonVersion>) { } } -/// How a live daemon answered the version handshake. #[derive(Debug, PartialEq, Eq)] enum VersionProbe { - /// It replied: it knows the handshake, at this dialect. Speaks(DaemonVersion), - /// It hung up (or answered garbage) — a daemon from before the `Version` - /// request existed errors on the unknown kind and drops the connection. - /// Alive and serving its panes fine; just an older dialect. Legacy, - /// It kept the connection open but never answered within - /// [`HANDSHAKE_TIMEOUT`] (or the write itself failed): wedged. Unlike - /// `Legacy`, this daemon can't serve anything — replace it outright. Unresponsive, } -/// Ensure a daemon is running for this process's config dir, spawning a detached -/// one if needed. Returns `Ok(())` once the endpoint is connectable; `Err` if the -/// endpoint can't be resolved or the daemon never came up within -/// [`STARTUP_TIMEOUT`]. pub fn ensure_running() -> anyhow::Result<()> { - // Fast path: a live daemon answers `connect`. The daemon outlives the GUI - // binary, so after an app upgrade the running daemon may be an older build - // whose wire dialect differs. That daemon still holds every persisted - // session, so we don't kill it here: reuse it, record the mismatch, and let - // the first window ask the user whether to keep it or restart clean - // (`take_mismatched_daemon`). Only a daemon that can't answer at all — - // wedged mid-handshake — is replaced outright, since it can't serve its - // panes either way. if let Ok(mut stream) = transport::connect() { match query_daemon_version(&mut stream) { VersionProbe::Speaks(v) if v.protocol == PROTOCOL_VERSION => { @@ -133,8 +64,6 @@ pub fn ensure_running() -> anyhow::Result<()> { v.protocol, PROTOCOL_VERSION ); - // Still the serving daemon: its identity and capability list - // are true regardless of the dialect gap. note_local_daemon(Some(v.clone())); if let Ok(mut slot) = MISMATCHED_DAEMON.lock() { *slot = Some(MismatchedDaemon { version: Some(v) }); @@ -155,26 +84,12 @@ pub fn ensure_running() -> anyhow::Result<()> { log::info!("daemon did not answer the version handshake; restarting it"); note_local_daemon(None); drop(stream); - // `stop` shuts the old daemon down gracefully (`Shutdown` - // predates versioning, so even the oldest daemon honors it), - // escalating to a pid-based reap if it won't go, and clears the - // endpoint marker. stop(); } } } else { - // Nobody answers — but "unreachable" is not "gone". If the pidfile - // records a daemon that is still alive (wedged, or one whose endpoint - // was lost), its panes are already beyond reach; reap it before - // claiming the endpoint so it can't linger forever holding every - // pane's PTY and children. reap_recorded_daemon(); - // If an endpoint marker is sitting there, it's a stale leftover from a - // crashed daemon (a *live* one would have answered the connect above), - // so clear it. The daemon's own `run()` clears stale endpoints too, but - // doing it here means our post-spawn polling connects on the first try - // instead of racing the daemon's cleanup. if transport::endpoint_exists() { transport::remove_stale_endpoint(); } @@ -182,15 +97,9 @@ pub fn ensure_running() -> anyhow::Result<()> { spawn_detached()?; - // Wait for the daemon to bind + start accepting. We re-probe with `connect` - // rather than just checking for the endpoint marker, since the marker appears - // (via `bind`) slightly before the accept loop is ready. let deadline = Instant::now() + STARTUP_TIMEOUT; loop { if let Ok(mut stream) = transport::connect() { - // Capture the fresh daemon's identity (instance + features). It is - // our own build, but asking beats assuming — and this is the only - // handshake a cold start ever runs. match query_daemon_version(&mut stream) { VersionProbe::Speaks(v) => note_local_daemon(Some(v)), _ => note_local_daemon(None), @@ -208,11 +117,6 @@ pub fn ensure_running() -> anyhow::Result<()> { } } -/// Ask a freshly connected daemon which protocol version it speaks, and -/// classify every way that can go (see [`VersionProbe`]). The split that -/// matters: a *hangup* is how a pre-versioning daemon reacts to the unknown -/// kind — it's healthy, keep it; a *timeout* is a daemon that can't process -/// messages at all — replace it. fn query_daemon_version(stream: &mut transport::Stream) -> VersionProbe { use std::io::Write as _; @@ -234,48 +138,21 @@ fn query_daemon_version(stream: &mut transport::Stream) -> VersionProbe { { VersionProbe::Unresponsive } - // EOF/reset (the pre-versioning hangup) — and, conservatively, any - // other well-formed-but-unexpected reply: the daemon is alive enough - // to answer, so it stays the user's call. _ => VersionProbe::Legacy, } } -/// Restart the daemon: ask the running one to shut down — which hangs up every -/// live shell — wait for it to exit, then spawn a fresh one. Returns once the new -/// daemon is listening. -/// -/// The GUI exposes this as "Restart Background Service": a long-lived daemon -/// process keeps whatever environment it started with, so a change it can't pick -/// up live only takes effect on restart — a macOS permission granted after launch -/// (e.g. Full Disk Access), or an updated PATH / env on any platform — and -/// quitting/reopening the GUI alone doesn't touch the detached daemon. Safe with -/// no daemon running — it just spawns a fresh one. pub fn restart() -> anyhow::Result<()> { stop(); ensure_running() } -/// Stop the running daemon and leave nothing running: ask it to shut down — -/// which hangs up every live shell — wait for it to exit, escalate to a -/// pid-based reap if it won't, and clear its endpoint marker. A no-op when no -/// daemon is running. Unlike [`restart`], this does not spawn a replacement. -/// -/// This backs both the GUI's restart (which calls it, then respawns) and the -/// `--stop-daemon` CLI entry point the Windows installer/uninstaller runs before -/// replacing or deleting `tty7.exe`: the detached daemon is the running image of -/// that same file, so Windows locks it until the daemon exits. Stopping it here -/// releases the lock so the install/uninstall can overwrite/remove the binary. pub fn stop() { use std::io::Write as _; - // Ask a running daemon to stop. Best effort: a failed connect/write means - // nothing is listening, so we fall through to the reap/clear below. if let Ok(mut stream) = transport::connect() { if ClientMsg::Shutdown.encode(&mut stream).is_ok() { let _ = stream.flush(); - // The old daemon is gone once the endpoint stops answering (its - // process exited and the listener closed). Poll until then, bounded. let deadline = Instant::now() + SHUTDOWN_TIMEOUT; while Instant::now() < deadline && transport::connect().is_ok() { std::thread::sleep(POLL_INTERVAL); @@ -283,36 +160,17 @@ pub fn stop() { } } - // If the old daemon is still alive here, `Shutdown` didn't stop it — a - // binary that predates the message, or a wedged teardown. Stopping *means* - // the old daemon must go: quietly claiming its endpoint while it lives is - // how sessions got stranded (unreachable daemon, panes and children still - // running — issue #42). Escalate by recorded pid. reap_recorded_daemon(); - // The daemon removes its own endpoint marker on shutdown, but clear - // defensively in case it was killed mid-teardown. if transport::endpoint_exists() { transport::remove_stale_endpoint(); } } -/// Reap the daemon recorded in the pidfile, if it is still alive: the caller -/// has decided that daemon must go (it stopped answering, or a restart was -/// ordered and `Shutdown` didn't stop it), and leaving it running while a new -/// daemon claims the endpoint would strand it — alive, unreachable, and -/// holding every pane's PTY and children. -/// -/// Never trusts the pidfile blindly: the pid must still be alive *and* its -/// executable basename must match our own (the daemon is this same binary), -/// or the pid was recycled and the file is just stale — cleared, not killed. -/// Always ends with the pidfile removed; the daemon we spawn next writes its -/// own. #[cfg(any(target_os = "macos", target_os = "linux"))] fn reap_recorded_daemon() { let Some(pid) = pidfile::read() else { return }; if pid <= 1 || pid == std::process::id() { - // A pidfile naming init or ourselves is corrupt, not a daemon. pidfile::remove(); return; } @@ -323,10 +181,6 @@ fn reap_recorded_daemon() { pidfile::remove(); } -/// Whether `pid` is alive and runs an executable with the same basename as our -/// own (GUI and daemon are the same `tty7` binary). This is the guard that -/// keeps a stale pidfile — daemon crashed, pid recycled by some unrelated -/// process — from getting an innocent process killed. #[cfg(any(target_os = "macos", target_os = "linux"))] fn process_matches_own_exe(pid: libc::pid_t) -> bool { let ours = std::env::current_exe() @@ -336,11 +190,6 @@ fn process_matches_own_exe(pid: libc::pid_t) -> bool { matches!((ours, theirs), (Some(a), Some(b)) if a == b) } -/// Terminate `pid` with escalation: SIGTERM first — a current daemon tears -/// down like `Shutdown`, giving every pane's child its SIGHUP grace (see -/// `server::serve_sigterm`) — then SIGKILL if it outlives the grace window. -/// Best effort: if it still won't die (unkillable, e.g. stuck in the kernel), -/// log and move on; the new daemon binds a fresh endpoint regardless. #[cfg(any(target_os = "macos", target_os = "linux"))] fn reap_process(pid: libc::pid_t) { if signal_and_await_exit(pid, libc::SIGTERM, REAP_TERM_TIMEOUT) { @@ -351,11 +200,8 @@ fn reap_process(pid: libc::pid_t) { } } -/// Send `sig` to `pid` and poll until it exits or `timeout` elapses. Returns -/// whether the process is gone. #[cfg(any(target_os = "macos", target_os = "linux"))] fn signal_and_await_exit(pid: libc::pid_t, sig: libc::c_int, timeout: Duration) -> bool { - // SAFETY: plain kill(2); a dead/foreign pid just returns an error. unsafe { libc::kill(pid, sig) }; let deadline = Instant::now() + timeout; while process_alive(pid) { @@ -367,27 +213,17 @@ fn signal_and_await_exit(pid: libc::pid_t, sig: libc::c_int, timeout: Duration) true } -/// Whether `pid` exists and is ours to signal (`kill(pid, 0)`). A pid held by -/// another user's process reads as "not alive" (EPERM) — correct for the reap -/// paths, which must then leave it alone. #[cfg(any(target_os = "macos", target_os = "linux"))] fn process_alive(pid: libc::pid_t) -> bool { - // SAFETY: signal 0 probes deliverability without delivering anything. unsafe { libc::kill(pid, 0) == 0 } } -/// Windows reap: same contract as the Unix version, built on the `winproc` -/// process-table helpers the panes already use for hangup. There is no signal -/// to ask for a graceful teardown, so this mirrors `DaemonPane`'s Windows -/// hangup order instead: terminate the daemon's descendants deepest-first -/// (while their parent links are still live), then the daemon itself. #[cfg(windows)] fn reap_recorded_daemon() { use crate::daemon::winproc; let Some(pid) = pidfile::read() else { return }; if pid <= 4 || pid == std::process::id() { - // System idle/System pids or ourselves: corrupt, not a daemon. pidfile::remove(); return; } @@ -410,14 +246,9 @@ fn reap_recorded_daemon() { pidfile::remove(); } -/// No process-table access on other platforms: the reap is a best-effort -/// rescue, so takeover there just keeps the pre-pidfile behavior. #[cfg(not(any(target_os = "macos", target_os = "linux", windows)))] fn reap_recorded_daemon() {} -/// Re-exec our own binary as a detached `--daemon`, inheriting the resolved -/// config dir. The child is fully severed from the GUI: its own session/process -/// group (so a GUI quit can't signal it) and null std streams (no console). fn spawn_detached() -> anyhow::Result<()> { let exe = std::env::current_exe() .map_err(|e| anyhow::anyhow!("could not locate own executable: {e}"))?; @@ -425,31 +256,20 @@ fn spawn_detached() -> anyhow::Result<()> { let mut cmd = Command::new(exe); cmd.arg("--daemon"); - // Forward the *resolved* config dir so the daemon uses the same endpoint we - // just probed. If nothing resolves we omit the flag and let the child apply - // its own default resolution (env var / home dir). if let Some(dir) = config::config_dir_path() { cmd.arg("--config-dir").arg(dir); } if let Some(shell) = detect_parent_shell() { - // The detached daemon's parent becomes launchd/systemd, so capture the - // shell that launched the GUI before detaching and let the pane builder - // prefer it over a stale `$SHELL` / passwd login-shell value. cmd.env(crate::daemon::DETECTED_SHELL_ENV, shell); } - // A daemon has no controlling terminal or console: send all three std streams - // to the null device so nothing inherits the GUI's handles. cmd.stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()); detach(&mut cmd); - // Spawn and intentionally drop the handle without waiting: the daemon is a - // long-lived process, not a child we reap. Dropping the `Child` doesn't kill - // it (Rust never auto-kills on drop), and the detach above reparents it. match cmd.spawn() { Ok(_child) => Ok(()), Err(e) => Err(anyhow::anyhow!("failed to spawn daemon process: {e}")), @@ -476,16 +296,12 @@ fn is_supported_shell(path: &Path) -> bool { ) } -/// The executable path of an arbitrary live process, used both to recognize -/// the shell that launched the GUI and to verify a pidfile's pid is still a -/// tty7 daemon before reaping it. #[cfg(target_os = "macos")] fn process_path(pid: libc::pid_t) -> Option<PathBuf> { if pid <= 0 { return None; } let mut buf = [0u8; libc::PROC_PIDPATHINFO_MAXSIZE as usize]; - // SAFETY: valid buffer, and `proc_pidpath` writes at most `buf.len()` bytes. let len = unsafe { libc::proc_pidpath(pid, buf.as_mut_ptr() as *mut libc::c_void, buf.len() as u32) }; if len <= 0 { @@ -504,19 +320,10 @@ fn process_path(pid: libc::pid_t) -> Option<PathBuf> { std::fs::read_link(format!("/proc/{pid}/exe")).ok() } -/// Detach the child into its own session/process group so a GUI teardown can't -/// take the daemon down with it. #[cfg(unix)] fn detach(cmd: &mut Command) { use std::os::unix::process::CommandExt; - // `setsid()` in the child (post-fork, pre-exec) detaches it into a brand-new - // session + process group. Without this the daemon stays in the GUI's process - // group and a session teardown (GUI quit, terminal close) could take it down - // with us — exactly what a persistent daemon must avoid. - // - // SAFETY: `pre_exec` runs in the forked child before `exec`. `setsid` is - // async-signal-safe and we touch no shared state here, so this is sound. unsafe { cmd.pre_exec(|| { if libc::setsid() == -1 { @@ -527,12 +334,6 @@ fn detach(cmd: &mut Command) { } } -/// Windows analogue of the Unix `setsid` detach. `DETACHED_PROCESS` severs the -/// child from the GUI's console, `CREATE_NEW_PROCESS_GROUP` puts it in its own -/// group (so a Ctrl-C / group signal to the GUI doesn't reach it), and -/// `CREATE_NO_WINDOW` stops a console window from flashing up for the headless -/// daemon. These are the raw `CreateProcess` flag values (no `windows-sys` -/// dependency needed for three constants). #[cfg(windows)] fn detach(cmd: &mut Command) { use std::os::windows::process::CommandExt; @@ -544,8 +345,6 @@ fn detach(cmd: &mut Command) { cmd.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW); } -// The stale-endpoint assertion is Unix-socket specific (Windows uses a loopback -// port file with different semantics), so this test only runs on Unix. #[cfg(all(test, unix))] mod tests { use super::*; @@ -564,14 +363,6 @@ mod tests { assert!(!is_supported_shell(Path::new("/usr/bin/omp"))); } - /// The reap guard: a live process whose executable is *not* ours must never - /// match — this is what keeps a stale pidfile with a recycled pid from - /// getting an innocent process killed. Driven with a real `sleep` child: - /// alive, path readable, basename `sleep` ≠ the test binary's. - /// - /// `spawn` returns after the fork, possibly before the child has exec'd — - /// until then its executable path still reads as *this* test binary — so - /// the path assertions poll until the exec is visible. #[cfg(any(target_os = "macos", target_os = "linux"))] #[test] fn reap_guard_rejects_a_live_process_of_another_executable() { @@ -604,11 +395,6 @@ mod tests { let _ = child.wait(); } - /// Escalation actually terminates a process that ignores the polite signal: - /// `sleep` dies to the SIGTERM leg already, and the poll must observe the - /// exit and report it. The child is reaped concurrently because a zombie - /// still answers `kill(pid, 0)` — in production the daemon is launchd's - /// child and vanishes on death, which is what the wait thread simulates. #[cfg(any(target_os = "macos", target_os = "linux"))] #[test] fn signal_and_await_exit_observes_the_death_it_caused() { @@ -629,8 +415,6 @@ mod tests { reaper.join().unwrap(); } - /// A dead pid reads as not-alive, so the reap paths treat its pidfile as - /// stale and clear it without signalling anything. #[cfg(any(target_os = "macos", target_os = "linux"))] #[test] fn process_alive_is_false_once_the_process_is_gone() { @@ -644,9 +428,6 @@ mod tests { assert!(!process_alive(pid)); } - /// The handshake against a current daemon: the peer answers `Version` and - /// the client reads it back. Driven over a socketpair so no real daemon is - /// needed — `query_daemon_version` only sees a `Stream`. #[test] fn version_handshake_reads_a_matching_reply() { use crate::daemon::protocol::{ClientMsg, DaemonMsg, DaemonVersion, PROTOCOL_VERSION}; @@ -675,17 +456,12 @@ mod tests { server.join().unwrap(); } - /// The handshake against a pre-versioning daemon: it reads an unknown kind - /// and drops the connection without replying. That must classify as - /// `Legacy` — a healthy daemon on an older dialect, the user's call to - /// keep or replace — not hang, panic, or read as wedged. #[test] fn version_handshake_treats_a_hangup_as_legacy() { use crate::daemon::protocol::ClientMsg; let (mut client, mut daemon) = UnixStream::pair().unwrap(); let server = std::thread::spawn(move || { - // An old daemon errors on the unknown kind and closes the socket. let _ = ClientMsg::read(&mut daemon); drop(daemon); }); @@ -694,15 +470,9 @@ mod tests { server.join().unwrap(); } - /// The handshake against a wedged daemon: the peer accepts the request but - /// never answers. The read must time out ([`HANDSHAKE_TIMEOUT`]) and - /// classify as `Unresponsive` — the one case `ensure_running` replaces the - /// daemon without asking, since it can't serve its panes anyway. #[test] fn version_handshake_treats_silence_as_unresponsive() { let (mut client, daemon) = UnixStream::pair().unwrap(); - // Keep the daemon end open (no reply, no hangup) until the client - // gives up. let start = Instant::now(); assert_eq!( query_daemon_version(&mut client), @@ -712,19 +482,11 @@ mod tests { drop(daemon); } - /// A stale socket file (one nothing is listening on) must be treated as "not - /// running": connecting to it fails, which is our trigger to clean up + spawn. - /// We assert the failure kind so the stale-cleanup branch stays exercised even - /// without actually launching a process. #[test] fn connect_to_stale_socket_path_fails() { let dir = std::env::temp_dir().join(format!("tty7-spawn-test-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("daemon.sock"); - // No listener was ever bound here, so the file doesn't exist and connect - // must fail (NotFound). If a leftover file existed with no listener it'd be - // ConnectionRefused — both are non-`Ok`, which is all `ensure_running` - // relies on to decide "spawn a fresh daemon". let err = UnixStream::connect(&path).unwrap_err(); assert!(matches!( err.kind(), diff --git a/crates/tty7-core/src/daemon/ssh/auth.rs b/crates/tty7-core/src/daemon/ssh/auth.rs index 3406a65d..03ee577d 100644 --- a/crates/tty7-core/src/daemon/ssh/auth.rs +++ b/crates/tty7-core/src/daemon/ssh/auth.rs @@ -1,15 +1,3 @@ -//! The authentication flow for a native SSH connection. -//! -//! Ordering follows the Tabby reference (brief §2): a leading `none` probe (which -//! also learns the server's remaining methods), then — for `Auto` — gssapi-with-mic -//! (matching OpenSSH's default preference), publickey, agent, password, -//! keyboard-interactive; a non-`Auto` mode restricts attempts to -//! that one family. The server's advertised remaining-methods set gates which -//! families are worth trying and is refreshed after each failure (only when the -//! server actually sends a non-empty set). Passwords/passphrases come from the -//! spec (pre-resolved from the keychain by the GUI) or, failing that, from the -//! [`PromptBroker`]. Secrets are never logged. - #[cfg(unix)] use std::net::IpAddr; use std::sync::Arc; @@ -27,8 +15,6 @@ use crate::daemon::protocol::{AuthPromptKind, AuthResponse, KiPrompt, NativeSshS use super::broker::PromptBroker; use super::handler::ClientHandler; -/// Attempt authentication. `Ok(())` = authenticated; `Err(reason)` carries a -/// user-facing reason for `SshStatus::Failed` (never a secret). pub async fn authenticate( handle: &mut Handle<ClientHandler>, spec: &NativeSshSpec, @@ -36,8 +22,6 @@ pub async fn authenticate( ) -> Result<(), String> { let user = spec.user.clone(); - // A `none` probe: some servers accept it, and either way it learns the - // server's advertised remaining methods. let mut remaining = match handle .authenticate_none(&user) .await @@ -52,8 +36,6 @@ pub async fn authenticate( let mut last_reason = "authentication failed".to_string(); for family in method_order(spec.auth_mode) { - // Respect the server's advertised set when it told us one: skip families - // it won't accept. An empty set means "unknown" — try anyway. if !remaining.is_empty() && !remaining.contains(&family) { continue; } @@ -62,8 +44,6 @@ pub async fn authenticate( MethodKind::PublicKey => try_publickeys(handle, spec, broker).await, MethodKind::KeyboardInteractive => try_keyboard_interactive(handle, spec, broker).await, MethodKind::Password => try_password(handle, spec, broker).await, - // Agent is folded into the publickey pass below via a distinct marker; - // handled in `method_order` expansion. _ => Outcome::Skipped, }; match outcome { @@ -88,10 +68,6 @@ pub async fn authenticate( Err(last_reason) } -/// The ordered families to try for a given auth mode. `Agent` is represented as a -/// publickey attempt (it *is* publickey, signed by the agent), so it isn't a -/// separate `MethodKind`; `try_publickeys` covers both files and agent for `Auto` -/// and for the explicit `Agent`/`PublicKey` modes via `spec.auth_mode`. fn method_order(mode: SshAuthMode) -> Vec<MethodKind> { match mode { SshAuthMode::Auto => vec![ @@ -189,10 +165,6 @@ impl GssapiAuthenticator for GssapiClient { mic: Some(mic.to_vec()), }) } else { - // An incomplete context that produced no token to send is a stalled - // exchange; claiming completion here would send the server a MIC-less - // exchange-complete it will reject with an opaque failure. Error out - // instead so the real cause reaches the user. let Some(token) = output else { return Err(GssapiAuthError::Other( "gssapi context stalled: incomplete with no output token".to_string(), @@ -297,16 +269,6 @@ fn gssapi_service_hosts_blocking(host: &str) -> Vec<String> { gssapi_service_hosts_with_lookup(host, reverse_lookup_addr) } -/// Which host names to request a `host/<name>` Kerberos service ticket for: the -/// host as typed, plus its reverse-DNS name when it was typed as a bare IP. -/// -/// Deliberately gated on `unix` alone, **not** on `feature = "gssapi"`. It needs -/// nothing from libgssapi — the caller injects the resolver — and gating it also -/// gated its two unit tests, which then only ran because the GUI package enables -/// `gssapi` and cargo unifies features across a `--workspace` test run. Narrowing -/// to `cargo test -p tty7-core` (a bisect, a single-crate iteration) silently -/// dropped them: green run, test never compiled. Without the feature the only -/// caller is the test module below, hence the `allow`. #[cfg(unix)] #[cfg_attr(not(feature = "gssapi"), allow(dead_code))] fn gssapi_service_hosts_with_lookup( @@ -433,8 +395,6 @@ fn set_sockaddr_in6_len(addr: &mut libc::sockaddr_in6) { #[cfg(all(unix, feature = "gssapi"))] fn set_sockaddr_in6_len(_addr: &mut libc::sockaddr_in6) {} -/// Try identity files (unless mode is `Agent`) then the ssh-agent (unless mode is -/// `PublicKey`), in that order. async fn try_publickeys( handle: &mut Handle<ClientHandler>, spec: &NativeSshSpec, @@ -490,8 +450,6 @@ async fn try_identity_file( Err(e) => return failed(format!("cannot read identity file {path}: {e}")), }; - // `.pub` misconfiguration: if the file parses as a *public* key, the user - // pointed us at the public half. Skip it with a warning rather than fail. if PublicKey::from_openssh(contents.trim()).is_ok() { log::warn!("identity file {path} is a public key; skipping"); return Outcome::Skipped; @@ -500,8 +458,6 @@ async fn try_identity_file( let key = match russh::keys::decode_secret_key(&contents, None) { Ok(k) => k, Err(russh::keys::Error::KeyIsEncrypted) => { - // Prefer a GUI-provided passphrase (keyed by the path as listed), else - // prompt for one. let provided = spec .key_passphrases .as_ref() @@ -551,28 +507,20 @@ async fn try_identity_file( } async fn try_agent(handle: &mut Handle<ClientHandler>, spec: &NativeSshSpec) -> Outcome { - // Agent transport is per-platform: a Unix-domain socket named by - // SSH_AUTH_SOCK, or Windows OpenSSH's named pipe. The identity loop below - // is shared via `try_agent_identities`, generic over the stream. #[cfg(unix)] { let agent = match AgentClient::connect_env().await { Ok(a) => a, - // No agent available (SSH_AUTH_SOCK unset / unreachable): just skip. Err(_) => return Outcome::Skipped, }; try_agent_identities(handle, spec, agent).await } #[cfg(windows)] { - // Windows OpenSSH's agent listens on a fixed named pipe; honor - // SSH_AUTH_SOCK as an override for nonstandard setups. (A Cygwin/MSYS - // socket *file* in that variable simply fails to open → skip.) let pipe = std::env::var("SSH_AUTH_SOCK") .unwrap_or_else(|_| r"\\.\pipe\openssh-ssh-agent".to_string()); let agent = match AgentClient::connect_named_pipe(&pipe).await { Ok(a) => a, - // No agent available: just skip. Err(_) => return Outcome::Skipped, }; try_agent_identities(handle, spec, agent).await @@ -595,7 +543,6 @@ where for identity in identities { let pubkey: PublicKey = match &identity { AgentIdentity::PublicKey { key, .. } => key.clone(), - // Certificate identities aren't handled in v1's agent path. AgentIdentity::Certificate { .. } => continue, }; let hash_alg = rsa_hash_alg(&pubkey.algorithm()); @@ -607,7 +554,6 @@ where Ok(AuthResult::Failure { remaining_methods, .. }) => last = Some(remaining_methods), - // A signing error with this identity — try the next one. Err(_) => continue, } } @@ -622,20 +568,14 @@ async fn try_password( spec: &NativeSshSpec, broker: &Arc<PromptBroker>, ) -> Outcome { - // Try a spec-provided (keychain-resolved) password first. if let Some(pw) = &spec.password { match handle.authenticate_password(&spec.user, pw.clone()).await { Ok(AuthResult::Success) => return Outcome::Authenticated, - Ok(AuthResult::Failure { .. }) => { - // The stored password was explicitly rejected (FR-A6): re-prompt. - // The GUI can treat a fresh prompt after a provided password as - // "stored password rejected" and offer to overwrite it. - } + Ok(AuthResult::Failure { .. }) => {} Err(e) => return failed(format!("password auth error: {e}")), } } - // Prompt the user (possibly after a rejected stored password). let resp = broker .prompt(AuthPromptKind::Password { user: spec.user.clone(), @@ -671,11 +611,6 @@ async fn try_keyboard_interactive( Err(e) => return failed(format!("keyboard-interactive start error: {e}")), }; - // Cap the round count (OpenSSH keeps a similar client-side device cap): a - // hostile or looping server must not be able to spin this task forever with - // zero-prompt or auto-filled requests. The stored password is auto-filled - // once only — a server re-asking means it was rejected (PAM retries), so - // later rounds fall through to prompting the user for the real one. const MAX_ROUNDS: u32 = 16; let mut rounds = 0u32; let mut stored_password_used = false; @@ -699,7 +634,6 @@ async fn try_keyboard_interactive( instructions, prompts, } => { - // Zero-prompt request (OpenSSH quirk): reply with an empty answer. if prompts.is_empty() { resp = match handle .authenticate_keyboard_interactive_respond(Vec::new()) @@ -738,11 +672,6 @@ async fn try_keyboard_interactive( } } -/// Answer a keyboard-interactive info-request. When *every* prompt is a -/// password-type field and a spec password is available (and this round may -/// still use it — the first only; a re-ask means the server rejected it), -/// auto-fill without bothering the GUI; otherwise surface the whole prompt set -/// to the GUI. async fn collect_ki_answers( spec: &NativeSshSpec, broker: &Arc<PromptBroker>, @@ -776,15 +705,11 @@ async fn collect_ki_answers( .await; match resp { AuthResponse::Secrets(v) if v.len() == prompts.len() => Some(v), - // A single-secret reply to a single prompt is also accepted. AuthResponse::Secret(s) if prompts.len() == 1 => Some(vec![s]), _ => None, } } -/// RSA keys must be offered with a modern signature hash; russh maps `None` to -/// legacy SHA-1 for RSA, so pick SHA-256. For all other key types `hash_alg` is -/// ignored, so `None` is correct. fn rsa_hash_alg(algorithm: &Algorithm) -> Option<HashAlg> { if matches!(algorithm, Algorithm::Rsa { .. }) { Some(HashAlg::Sha256) @@ -793,7 +718,6 @@ fn rsa_hash_alg(algorithm: &Algorithm) -> Option<HashAlg> { } } -/// Expand an identity-file path: `%h`→host, `%r`→user, and a leading `~/` → home. fn expand_identity_path(path: &str, host: &str, user: &str) -> String { let substituted = path.replace("%h", host).replace("%r", user); if let Some(rest) = substituted.strip_prefix("~/") { @@ -820,7 +744,6 @@ mod tests { #[test] fn identity_path_expands_tokens_and_tilde() { - // Tokens expand regardless of home resolution. let p = expand_identity_path("/keys/%r@%h/id", "example.com", "deploy"); assert_eq!(p, "/keys/deploy@example.com/id"); } @@ -850,9 +773,6 @@ mod tests { ); } - // `#[cfg(unix)]`, not `#[cfg(all(unix, feature = "gssapi"))]`: these exercise - // pure host-list logic, so they must run under a plain - // `cargo test -p tty7-core` too. See `gssapi_service_hosts_with_lookup`. #[cfg(unix)] #[test] fn gssapi_service_hosts_keep_original_host_before_reverse_dns() { diff --git a/crates/tty7-core/src/daemon/ssh/broker.rs b/crates/tty7-core/src/daemon/ssh/broker.rs index d9e8193a..1bd80c81 100644 --- a/crates/tty7-core/src/daemon/ssh/broker.rs +++ b/crates/tty7-core/src/daemon/ssh/broker.rs @@ -1,18 +1,3 @@ -//! The interactive prompt broker: how the async russh auth/host-key flow reaches -//! the GUI and blocks for an answer. -//! -//! During a native-SSH spawn the connect task needs decisions only the user can -//! make (a password, a key passphrase, keyboard-interactive answers, a host-key -//! confirmation). It emits a `DaemonMsg::AuthPrompt` over the pane's own -//! connection and `.await`s a `oneshot` that `run_stream` fulfils when the -//! matching `ClientMsg::AuthResponse` arrives (routed here through -//! `DaemonPane::deliver_auth_response`). Status/banner frames are fire-and-forget. -//! -//! The broker is constructed by `DaemonPane` (which owns the subscriber the frames -//! must reach) and handed an `emit` closure; keeping the type here puts it beside -//! the auth code that drives it. Secrets returned in `AuthResponse` are never -//! logged (its `Debug` redacts) and live only for the auth attempt. - use std::collections::HashMap; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; @@ -22,19 +7,11 @@ use tokio::sync::oneshot; use crate::daemon::protocol::{AuthPromptKind, AuthResponse, DaemonMsg, SshPhase}; -/// How long an auth step waits for the user before failing cleanly. const PROMPT_TIMEOUT: Duration = Duration::from_secs(120); -/// How long we keep re-offering a prompt frame while waiting for a subscriber to -/// attach (the spawn's socket may not have finished attaching the instant the -/// first prompt is ready). The frame is only actually sent once a subscriber -/// exists, so this never duplicates a prompt in the GUI. const DELIVERY_WINDOW: Duration = Duration::from_secs(15); const DELIVERY_POLL: Duration = Duration::from_millis(100); pub struct PromptBroker { - /// Sends a `DaemonMsg` to the pane's *current* subscriber, returning whether - /// one was present (and thus whether the frame actually went out). Provided by - /// `DaemonPane`, which owns the subscriber behind its state lock. emit: Box<dyn Fn(DaemonMsg) -> bool + Send + Sync>, pending: Mutex<HashMap<u64, oneshot::Sender<AuthResponse>>>, next_id: AtomicU64, @@ -49,23 +26,15 @@ impl PromptBroker { }) } - /// Whether an interactive prompt is currently awaiting the user's reply. - /// The connect watchdog reads this to stop billing the connect timeout - /// while the user is thinking (e.g. reading a host-key fingerprint). pub fn has_pending(&self) -> bool { !self.pending.lock().unwrap().is_empty() } - /// Send an interactive prompt to the GUI and block (async) for its reply. - /// Returns [`AuthResponse::Cancelled`] on user cancel, timeout, or if no GUI - /// ever attaches to receive it — every one of which fails the auth step - /// cleanly rather than hanging the connection. pub async fn prompt(&self, kind: AuthPromptKind) -> AuthResponse { let id = self.next_id.fetch_add(1, Ordering::Relaxed); let (tx, rx) = oneshot::channel(); self.pending.lock().unwrap().insert(id, tx); - // Deliver the frame, retrying only while no subscriber is attached yet. let frame = DaemonMsg::AuthPrompt { request_id: id, prompt: kind, @@ -97,7 +66,6 @@ impl PromptBroker { } } - /// Fire-and-forget server banner. No response is awaited. pub fn banner(&self, text: String) { let _ = (self.emit)(DaemonMsg::AuthPrompt { request_id: 0, @@ -105,13 +73,10 @@ impl PromptBroker { }); } - /// Fire-and-forget spawn-progress update. pub fn status(&self, phase: SshPhase) { let _ = (self.emit)(DaemonMsg::SshStatus { phase }); } - /// Fulfil a pending prompt with the GUI's reply. Unknown ids are ignored (a - /// late reply to a step that already timed out). pub fn deliver(&self, request_id: u64, response: AuthResponse) { if let Some(tx) = self.pending.lock().unwrap().remove(&request_id) { let _ = tx.send(response); @@ -129,7 +94,6 @@ mod tests { .enable_time() .build() .unwrap(); - // A always-succeeding emit sink; we ignore the frame and reply out of band. let broker = PromptBroker::new(Box::new(|_| true)); rt.block_on(async { let b = broker.clone(); @@ -137,7 +101,6 @@ mod tests { user: "u".into(), host: "h".into(), }); - // Reply to request id 1 (the first allocated) concurrently. let b2 = broker.clone(); let replier = async move { tokio::time::sleep(Duration::from_millis(20)).await; @@ -155,7 +118,6 @@ mod tests { .start_paused(true) .build() .unwrap(); - // An emit sink that never has a subscriber → never delivers. let broker = PromptBroker::new(Box::new(|_| false)); rt.block_on(async { let resp = broker diff --git a/crates/tty7-core/src/daemon/ssh/connect.rs b/crates/tty7-core/src/daemon/ssh/connect.rs index a0675607..b0957fd7 100644 --- a/crates/tty7-core/src/daemon/ssh/connect.rs +++ b/crates/tty7-core/src/daemon/ssh/connect.rs @@ -1,17 +1,3 @@ -//! Transport construction and russh `Config` for the native SSH engine. -//! -//! Every transport is reduced to a single [`Transport`] value implementing -//! `AsyncRead + AsyncWrite`, which `russh::client::connect_stream` accepts: -//! -//! - **Direct** — a plain `TcpStream`. -//! - **ProxyCommand** — spawn the command; its stdio is the transport. tty7 -//! substitutes `%h`/`%p`/`%r` itself (the gap Tabby left, PRD FR-C1 / #11058). -//! - **SOCKS5 / HTTP CONNECT** — a `TcpStream` to the proxy, handshaked up to the -//! target (no-auth SOCKS5; bare HTTP `CONNECT`), then used directly. -//! - **Jump host** — a `direct-tcpip` channel opened on an already-authenticated -//! jump [`SshConnection`], turned into a stream. Multi-level chains fall out of -//! the manager establishing the jump connection recursively before calling here. - use std::borrow::Cow; use std::pin::Pin; use std::sync::Arc; @@ -25,8 +11,6 @@ use crate::daemon::protocol::{NativeSshSpec, SshAlgorithms, SshProxy}; use super::session::SshConnection; -/// A concrete transport stream for `connect_stream`. An enum (rather than a boxed -/// trait object) so each variant's `AsyncRead`/`AsyncWrite` is a direct delegate. pub enum Transport { Tcp(TcpStream), Process(ProcessStream), @@ -77,30 +61,13 @@ impl AsyncWrite for Transport { } } -/// A spawned `ProxyCommand`'s stdio as one duplex stream. `kill_on_drop` reaps the -/// process when the transport is dropped. pub struct ProcessStream { - // Held so the child is reaped on drop; not otherwise read. _child: tokio::process::Child, - /// `Option` so `poll_shutdown` can *drop* it. - /// - /// This is the only way to half-close a pipe. `ChildStdin`'s own - /// `poll_shutdown` returns `Ready(Ok(()))` without touching the file - /// descriptor, so the child never sees EOF and keeps waiting for input that - /// will never come — a `tty7-server --stdio` bridge would hang there - /// forever instead of exiting. Closing the write half is a real operation - /// and has to be modelled as one. stdin: Option<tokio::process::ChildStdin>, stdout: tokio::process::ChildStdout, } impl ProcessStream { - /// Assemble one from an already-spawned child and its taken pipes. - /// - /// The fields stay private — a `ProcessStream` whose `_child` did not - /// produce its own `stdin`/`stdout` would reap the wrong process on drop. - /// `daemon::remote_link` needs this to wrap a `tty7-server --stdio` child - /// the same way the `ProxyCommand` path wraps its own. pub fn from_parts( child: tokio::process::Child, stdin: tokio::process::ChildStdin, @@ -113,7 +80,6 @@ impl ProcessStream { } } - /// The write half, or a "already closed" error once it has been shut down. fn stdin_mut(&mut self) -> std::io::Result<&mut tokio::process::ChildStdin> { self.stdin.as_mut().ok_or_else(|| { std::io::Error::new( @@ -148,16 +114,10 @@ impl AsyncWrite for ProcessStream { fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> { match self.get_mut().stdin_mut() { Ok(stdin) => Pin::new(stdin).poll_flush(cx), - // Nothing buffered can remain once the half is closed. Err(_) => Poll::Ready(Ok(())), } } - /// Flush, then **close** the write half by dropping the pipe. - /// - /// Delegating to `ChildStdin::poll_shutdown` would be a no-op — it does not - /// close the descriptor — so the peer would never reach EOF. Dropping is - /// what actually closes it, which is why `stdin` is an `Option`. fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> { let this = self.get_mut(); let Some(stdin) = this.stdin.as_mut() else { @@ -177,9 +137,6 @@ impl AsyncWrite for ProcessStream { } } -/// Build the transport for `spec`, given an already-established `jump` connection -/// when the spec chains through one. Precedence mirrors OpenSSH/Tabby: -/// ProxyCommand > jump host > SOCKS5 > HTTP > direct. pub async fn build_transport( spec: &NativeSshSpec, jump: Option<Arc<SshConnection>>, @@ -209,7 +166,6 @@ pub async fn build_transport( let stream = http_connect(host, *port, &spec.host, spec.port).await?; Ok(Transport::Tcp(stream)) } - // None (or Command, handled above): direct. _ => { let stream = TcpStream::connect((spec.host.as_str(), spec.port)) .await @@ -238,9 +194,6 @@ fn spawn_proxy_command( .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::inherit()) .kill_on_drop(true); - // The daemon is detached and has no console to lend this child, so without - // the flag a `ProxyCommand` (`ssh -W`, `connect.exe`, `cloudflared`) gets a - // console of its own that stays up for the whole session. crate::core::proc::hide_console_tokio(&mut cmd); let mut child = cmd .spawn() @@ -258,9 +211,6 @@ fn spawn_proxy_command( ))) } -/// Split a ProxyCommand template into argv and substitute the OpenSSH tokens -/// `%h` (host), `%p` (port), `%r` (remote user), and `%%` (a literal `%`). Public -/// for unit testing. pub fn proxy_command_argv(template: &str, host: &str, port: u16, user: &str) -> Vec<String> { shell_split(template) .into_iter() @@ -278,7 +228,6 @@ fn substitute_tokens(tok: &str, host: &str, port: u16, user: &str) -> String { Some('p') => out.push_str(&port.to_string()), Some('r') => out.push_str(user), Some('%') => out.push('%'), - // Unknown token: keep both characters verbatim. Some(other) => { out.push('%'); out.push(other); @@ -292,8 +241,6 @@ fn substitute_tokens(tok: &str, host: &str, port: u16, user: &str) -> String { out } -/// A minimal POSIX-ish word splitter for ProxyCommand: honors single quotes, -/// double quotes, and backslash escaping; splits on unquoted whitespace. fn shell_split(s: &str) -> Vec<String> { let mut out = Vec::new(); let mut cur = String::new(); @@ -336,7 +283,6 @@ fn shell_split(s: &str) -> Vec<String> { out } -/// SOCKS5 CONNECT (no authentication) to `target:target_port` via `proxy`. async fn socks5_connect( proxy_host: &str, proxy_port: u16, @@ -348,14 +294,12 @@ async fn socks5_connect( .map_err(|e| { anyhow::anyhow!("connect to SOCKS proxy {proxy_host}:{proxy_port} failed: {e}") })?; - // Greeting: VER=5, one method, 0x00 = no auth. s.write_all(&[0x05, 0x01, 0x00]).await?; let mut reply = [0u8; 2]; s.read_exact(&mut reply).await?; if reply[0] != 0x05 || reply[1] != 0x00 { anyhow::bail!("SOCKS5 proxy refused no-auth (got {reply:?})"); } - // CONNECT request with a domain-name address (ATYP=3). let host_bytes = target.as_bytes(); if host_bytes.len() > 255 { anyhow::bail!("SOCKS5 target host too long"); @@ -364,7 +308,6 @@ async fn socks5_connect( req.extend_from_slice(host_bytes); req.extend_from_slice(&target_port.to_be_bytes()); s.write_all(&req).await?; - // Reply: VER, REP, RSV, ATYP, BND.ADDR, BND.PORT. let mut head = [0u8; 4]; s.read_exact(&mut head).await?; if head[1] != 0x00 { @@ -380,12 +323,11 @@ async fn socks5_connect( } other => anyhow::bail!("SOCKS5 unexpected bound ATYP {other}"), }; - let mut discard = vec![0u8; addr_len + 2]; // address + port + let mut discard = vec![0u8; addr_len + 2]; s.read_exact(&mut discard).await?; Ok(s) } -/// HTTP `CONNECT` tunnel to `target:target_port` via `proxy`. async fn http_connect( proxy_host: &str, proxy_port: u16, @@ -401,8 +343,6 @@ async fn http_connect( "CONNECT {target}:{target_port} HTTP/1.1\r\nHost: {target}:{target_port}\r\nProxy-Connection: keep-alive\r\n\r\n" ); s.write_all(req.as_bytes()).await?; - // Read until the end of headers (\r\n\r\n). Bounded so a hostile proxy can't - // make us buffer without limit. let mut buf = Vec::with_capacity(256); let mut byte = [0u8; 1]; loop { @@ -428,8 +368,6 @@ async fn http_connect( Ok(s) } -/// Build the russh client config from the spec: keepalive, and algorithm -/// preferences (empty list per family = russh's secure default for that family). pub fn build_config(spec: &NativeSshSpec) -> Arc<russh::client::Config> { let mut cfg = russh::client::Config { preferred: build_preferred(&spec.algorithms), @@ -444,10 +382,6 @@ pub fn build_config(spec: &NativeSshSpec) -> Arc<russh::client::Config> { Arc::new(cfg) } -/// Start from russh's default preference and override only the families the user -/// specified. Unparseable entries are dropped; if a user list parses to nothing, -/// that family keeps the default rather than becoming empty (which would offer no -/// algorithms and fail negotiation). fn build_preferred(a: &SshAlgorithms) -> russh::Preferred { let mut p = russh::Preferred::DEFAULT; if !a.kex.is_empty() { @@ -540,7 +474,6 @@ mod tests { fn build_preferred_keeps_defaults_for_empty_lists() { let a = SshAlgorithms::default(); let p = build_preferred(&a); - // Empty spec → unchanged russh default. assert_eq!(p.kex, russh::Preferred::DEFAULT.kex); assert_eq!(p.cipher, russh::Preferred::DEFAULT.cipher); } @@ -552,7 +485,6 @@ mod tests { ..Default::default() }; let p = build_preferred(&a); - // The unknown entry is filtered; only the known one is applied. let aes = russh::cipher::Name::try_from("aes256-ctr").unwrap(); assert_eq!(p.cipher.as_ref(), &[aes]); } diff --git a/crates/tty7-core/src/daemon/ssh/forward.rs b/crates/tty7-core/src/daemon/ssh/forward.rs index d555a6a3..4996fd5e 100644 --- a/crates/tty7-core/src/daemon/ssh/forward.rs +++ b/crates/tty7-core/src/daemon/ssh/forward.rs @@ -1,47 +1,3 @@ -//! Port forwarding for native-SSH panes (Workstream 4). -//! -//! Three forward types ride the pane's shared [`SshConnection`] (russh channels, -//! never a control socket — every forward is native): -//! -//! - **Local** (FR-F1): a TCP listener on `bind_host:bind_port`; each accepted -//! connection opens a `direct-tcpip` channel to `target_host:target_port` on the -//! connection and [`bridge`]s the two with exact EOF/close propagation. -//! - **Dynamic / SOCKS5** (FR-F1): a local listener speaking a minimal, hand-rolled -//! SOCKS5 (no-auth greeting, CONNECT for IPv4/IPv6/domain; BIND/UDP rejected). -//! Each request opens a `direct-tcpip` to the negotiated target and bridges. -//! - **Remote** (FR-F1): a `tcpip-forward` global request on the connection; -//! incoming `forwarded-tcpip` channels (via the [`super::handler::ClientHandler`]) -//! are matched against [`RemoteForwardTable`] and bridged to a fresh local TCP -//! connection to the registered target. Unmatched channels are rejected. -//! -//! **Registry keying & blast radius.** [`SshForwardRegistry`] keys active forwards -//! by [`ForwardOwner`] — *what has to die for this forward to die* — but each -//! forward task holds an `Arc<SshConnection>`, so a forward keeps the shared -//! connection alive exactly like `ssh -N`. -//! -//! There are two owners, because tty7 has two unrelated features that both open -//! forwards: -//! -//! | | SSH pane ("连一下") | remote workspace ("在上面开发") | -//! |---|---|---| -//! | owner | [`ForwardOwner::Pane`] | [`ForwardOwner::Workspace`] | -//! | unit | one pane | one window's workspace | -//! | pane dies | forward dies with it | **forward survives** | -//! | torn down by | [`SshForwardRegistry::teardown_pane`] | [`SshForwardRegistry::teardown_workspace`] | -//! -//! The two are exclusive by construction rather than by convention: an owner is -//! one variant or the other, and `teardown_pane(id)` can only ever reach -//! `Pane(id)`. A remote workspace's panes come and go — a tab closed, a pane -//! respawned after a reconnect — and the `localhost:3000` forward the user -//! ⌘-clicked has to outlive all of that, while an SSH pane's forwards must still -//! vanish the moment the pane does. -//! -//! When a pane dies the daemon calls [`SshForwardRegistry::teardown_pane`], -//! which aborts its listener tasks and cancels its remote bindings; dropping the -//! last `Arc` then tears the connection down. When the *transport* drops, every -//! pane sharing the connection dies as a unit (FR-C2), so every forward -//! attributed to those panes is torn down together. - use std::collections::HashMap; use std::io; use std::net::Ipv4Addr; @@ -60,11 +16,6 @@ use crate::daemon::protocol::{ use super::session::SshConnection; use super::{ConnectionKey, SshManager}; -/// Accept a connection, retrying transient errors instead of killing the -/// listener: ECONNABORTED (client gave up mid-handshake) and EMFILE/ENFILE -/// (fd pressure) are momentary, and exiting the accept loop on them would -/// leave the forward dead while its status still says "listening". `None` -/// only on errors that persist after a backoff (listener genuinely broken). async fn accept_retrying(listener: &TcpListener) -> Option<(TcpStream, std::net::SocketAddr)> { let mut failures = 0u32; loop { @@ -79,16 +30,6 @@ async fn accept_retrying(listener: &TcpListener) -> Option<(TcpStream, std::net: } } -// --------------------------------------------------------------------------- -// Bidirectional socket<->channel bridge (Tabby brief §5). -// --------------------------------------------------------------------------- - -/// Bridge two duplex streams, propagating EOF and close in both directions: when -/// one side's read half hits EOF, the other side's write half is shut down (a -/// half-close), and once both directions have closed the bridge returns. This -/// mirrors Tabby's `setupSocketChannelEvents` (channel.eof→socket.end, -/// socket.end→channel.eof, close→destroy) so neither a socket nor a russh channel -/// is left half-open. pub(super) async fn bridge<A, B>(a: A, b: B) -> io::Result<()> where A: AsyncRead + AsyncWrite + Unpin, @@ -99,8 +40,6 @@ where let a_to_b = async { tokio::io::copy(&mut ar, &mut bw).await?; - // Source EOF'd: signal it downstream so the peer sees a clean close - // rather than a stall. bw.shutdown().await }; let b_to_a = async { @@ -108,32 +47,17 @@ where aw.shutdown().await }; - // Run both directions until each has hit EOF (or one errors). `try_join` - // surfaces the first error and drops the other future, which closes its - // half — the connection cannot be left half-open. tokio::try_join!(a_to_b, b_to_a)?; Ok(()) } -// --------------------------------------------------------------------------- -// Minimal SOCKS5 (RFC 1928) for Dynamic forwards. -// --------------------------------------------------------------------------- - -/// Negotiate a SOCKS5 CONNECT request on `s`: read the (no-auth) greeting, reply -/// with the no-auth method, read the CONNECT request, and return the requested -/// `(host, port)`. Rejects SOCKS4 (version byte `0x04`), any command other than -/// CONNECT (so BIND/UDP-ASSOCIATE are refused), and unknown address types. The -/// caller opens the upstream channel and then writes the final reply with -/// [`socks5_reply`]. pub(super) async fn socks5_negotiate<S>(s: &mut S) -> io::Result<(String, u16)> where S: AsyncRead + AsyncWrite + Unpin, { - // Greeting: VER, NMETHODS, METHODS... let mut head = [0u8; 2]; s.read_exact(&mut head).await?; if head[0] != 0x05 { - // A SOCKS4 client sends 0x04 here; anything but 0x05 is unsupported. return Err(io::Error::new( io::ErrorKind::InvalidData, "unsupported SOCKS version (only SOCKS5 is accepted)", @@ -143,7 +67,6 @@ where let mut methods = vec![0u8; nmethods]; s.read_exact(&mut methods).await?; if !methods.contains(&0x00) { - // No acceptable methods (0xFF) — we only implement no-auth. let _ = s.write_all(&[0x05, 0xFF]).await; return Err(io::Error::new( io::ErrorKind::InvalidData, @@ -152,7 +75,6 @@ where } s.write_all(&[0x05, 0x00]).await?; - // Request: VER, CMD, RSV, ATYP, ADDR, PORT. let mut req = [0u8; 4]; s.read_exact(&mut req).await?; if req[0] != 0x05 { @@ -162,8 +84,7 @@ where )); } if req[1] != 0x01 { - // Only CONNECT (0x01); reject BIND (0x02) / UDP-ASSOCIATE (0x03). - socks5_reply(s, 0x07).await?; // command not supported + socks5_reply(s, 0x07).await?; return Err(io::Error::new( io::ErrorKind::InvalidData, "SOCKS5 command not supported (only CONNECT)", @@ -190,7 +111,7 @@ where })? } other => { - socks5_reply(s, 0x08).await?; // address type not supported + socks5_reply(s, 0x08).await?; return Err(io::Error::new( io::ErrorKind::InvalidData, format!("SOCKS5 unsupported address type {other}"), @@ -202,8 +123,6 @@ where Ok((host, u16::from_be_bytes(port))) } -/// Write a SOCKS5 reply with reply code `rep` (0x00 = success), a fixed -/// `0.0.0.0:0` bound address (clients ignore it for CONNECT). pub(super) async fn socks5_reply<S>(s: &mut S, rep: u8) -> io::Result<()> where S: AsyncWrite + Unpin, @@ -212,25 +131,12 @@ where .await } -// --------------------------------------------------------------------------- -// Remote-forward table (consulted by the connection's Handler). -// --------------------------------------------------------------------------- - -/// The set of `tcpip-forward` bindings registered on one connection, mapping a -/// remote bind address/port to the local target to connect incoming -/// `forwarded-tcpip` channels to. Shared (cheaply cloned `Arc`) between the -/// [`SshConnection`] and its [`super::handler::ClientHandler`]; a reused -/// connection keeps its bindings across panes. #[derive(Clone, Default)] pub struct RemoteForwardTable { inner: Arc<Mutex<HashMap<(String, u16), (String, u16)>>>, } impl RemoteForwardTable { - /// Register a binding, refusing a duplicate: overwriting would hijack the - /// existing forward's routing, and the caller's on-failure rollback would - /// then delete the *original* entry, leaving its live server binding - /// unroutable. Returns whether the key was free. pub(super) fn register( &self, bind_host: &str, @@ -259,8 +165,6 @@ impl RemoteForwardTable { .remove(&(bind_host.to_string(), bind_port)); } - /// Move a binding to a new (server-assigned) port when the client requested - /// port 0. pub(super) fn rekey(&self, bind_host: &str, from_port: u16, to_port: u16) { let mut map = self.inner.lock().unwrap(); if let Some(target) = map.remove(&(bind_host.to_string(), from_port)) { @@ -268,12 +172,6 @@ impl RemoteForwardTable { } } - /// Resolve an incoming `forwarded-tcpip` channel's connected address/port to a - /// local target. Tries the exact `(address, port)` first, then a port-only - /// match (the server may report `127.0.0.1` for a `localhost` bind, or - /// `0.0.0.0` for an empty bind address) — but only when the port match is - /// unambiguous: with two bindings on the same port and different addresses, - /// guessing could bridge traffic to the wrong local target. pub(super) fn lookup( &self, connected_address: &str, @@ -291,25 +189,13 @@ impl RemoteForwardTable { } } -// --------------------------------------------------------------------------- -// Managed-forward registry. -// --------------------------------------------------------------------------- - -/// A live forward's teardown handle. enum ForwardCancel { - /// A Local/Dynamic accept loop. Held as the full `JoinHandle` (not just an - /// `AbortHandle`) so [`SshForwardRegistry::cancel_entry`] can `abort()` *and* - /// `await` it: `abort()` only *requests* cancellation, so awaiting is what - /// guarantees the task — and the `TcpListener` it owns — is fully dropped, - /// freeing the bound port before `remove`/`teardown_pane` returns. Task(JoinHandle<()>), - /// A Remote binding to cancel via `cancel_tcpip_forward` on teardown. Remote { conn: Weak<SshConnection>, bind_host: String, bind_port: u16, }, - /// The forward never came up (bind/request failed); nothing to cancel. None, } @@ -323,23 +209,12 @@ struct ForwardEntry { description: Option<String>, status: ForwardStatus, cancel: ForwardCancel, - /// True for a forward auto-created by a Cmd-clicked `localhost:PORT` link - /// (FR-F4). Such entries are eligible for reuse when the same target is - /// clicked again, and read as a plain Local row in the unified forwards list. auto_local: bool, } -/// What a managed forward belongs to — the thing whose death takes it down. -/// -/// The registry is keyed on this rather than on a bare `pane_id` so that the two -/// features that open forwards can coexist without either one's teardown being -/// able to reach the other's entries (see the module docs). #[derive(Clone, PartialEq, Eq, Hash, Debug)] pub enum ForwardOwner { - /// A native-SSH pane. Its forwards die with it, via [`SshForwardRegistry::teardown_pane`]. Pane(u64), - /// A remote workspace. Its forwards outlive every individual pane and die - /// only with the workspace, via [`SshForwardRegistry::teardown_workspace`]. Workspace(WorkspaceId), } @@ -359,7 +234,6 @@ impl ForwardEntry { } } -/// The per-process registry of managed forwards, owned by [`super::SshManager`]. #[derive(Default)] pub struct SshForwardRegistry { owners: Mutex<HashMap<ForwardOwner, Vec<ForwardEntry>>>, @@ -367,16 +241,6 @@ pub struct SshForwardRegistry { } impl SshForwardRegistry { - // ---- Pane-owned forwards (native-SSH panes) ----------------------------- - // - // These signatures are exactly what they were before workspaces existed, and - // every one of them pins its owner to `ForwardOwner::Pane`. A workspace - // forward is unreachable from here, which is the compatibility guarantee. - - /// Establish a managed forward for `rule` on `conn`, attribute it to `pane_id`, - /// and return the resulting [`ManagedForward`] (with a resolved bind port and a - /// live status). Failures are reported as `ForwardStatus::Error`, never a hard - /// error — a preconfigured forward that fails must not kill the session. pub async fn establish( &self, pane_id: u64, @@ -387,34 +251,19 @@ impl SshForwardRegistry { .await } - /// The managed forwards attributed to `pane_id`, sorted by id (creation order). pub fn list(&self, pane_id: u64) -> Vec<ManagedForward> { self.list_owned(&ForwardOwner::Pane(pane_id), pane_id) } - /// Remove one managed forward by id from `pane_id`, tearing down its listener - /// or remote binding. Returns the pane's remaining forwards. pub async fn remove(&self, pane_id: u64, forward_id: u64) -> Vec<ManagedForward> { self.remove_owned(&ForwardOwner::Pane(pane_id), pane_id, forward_id) .await } - /// Tear down every forward attributed to `pane_id` (called when the pane dies — - /// on explicit kill, reclaim, or connection loss). Local/Dynamic listeners are - /// aborted synchronously; remote bindings are cancelled best-effort. - /// - /// A *remote workspace's* forwards are untouched by this even when the dying - /// pane belonged to that workspace: they are filed under - /// [`ForwardOwner::Workspace`], which this key can never name. pub async fn teardown_pane(&self, pane_id: u64) { self.teardown_owned(&ForwardOwner::Pane(pane_id)).await; } - // ---- Workspace-owned forwards (remote workspaces) ---------------------- - - /// [`establish`](Self::establish) for a remote workspace. `view_pane` is only - /// stamped into the returned row for the GUI's per-pane list; ownership — and - /// therefore lifetime — is the workspace's. pub async fn establish_workspace( &self, workspace: WorkspaceId, @@ -426,12 +275,10 @@ impl SshForwardRegistry { .await } - /// The forwards a workspace owns, stamped with `view_pane` for display. pub fn list_workspace(&self, workspace: WorkspaceId, view_pane: u64) -> Vec<ManagedForward> { self.list_owned(&ForwardOwner::Workspace(workspace), view_pane) } - /// Remove one of a workspace's forwards by id; returns the rest. pub async fn remove_workspace( &self, workspace: WorkspaceId, @@ -442,16 +289,11 @@ impl SshForwardRegistry { .await } - /// Tear down every forward a workspace owns — the workspace was closed. The - /// counterpart of [`teardown_pane`](Self::teardown_pane), and the *only* thing - /// that collects a workspace forward. pub async fn teardown_workspace(&self, workspace: WorkspaceId) { self.teardown_owned(&ForwardOwner::Workspace(workspace)) .await; } - // ---- Owner-generic core ------------------------------------------------- - async fn establish_owned( &self, owner: &ForwardOwner, @@ -528,10 +370,6 @@ impl SshForwardRegistry { async fn cancel_entry(entry: ForwardEntry) { match entry.cancel { ForwardCancel::Task(handle) => { - // `abort()` only *schedules* cancellation; awaiting the handle - // drives the task to completion so its `TcpListener` is dropped - // (socket closed) before we return. The task was cancelled, so - // the `JoinError` is expected and ignored. handle.abort(); let _ = handle.await; } @@ -589,8 +427,6 @@ impl SshForwardRegistry { Ok(channel) => { let _ = bridge(sock, channel.into_stream()).await; } - // Remote refused (or the connection died): drop the client - // socket. No secrets in the log. Err(e) => { log::info!("local forward to {target_host}:{target_port} rejected: {e}") } @@ -651,7 +487,6 @@ impl SshForwardRegistry { let _ = bridge(sock, channel.into_stream()).await; } Err(e) => { - // 0x05 = connection refused by destination host. let _ = socks5_reply(&mut sock, 0x05).await; log::info!("dynamic forward to {host}:{port} rejected: {e}"); } @@ -693,18 +528,6 @@ impl SshForwardRegistry { } } - // ---- Native loopback (FR-F4) -------------------------------------------- - - /// Ensure a native-SSH loopback forward `127.0.0.1:<ephemeral> → host:port` - /// exists for `pane_id`, reusing an existing auto-created one for the same - /// target. The forward is registered in the *same* managed registry as - /// [`Self::establish`], so it shows up as a plain Local row in `list(pane_id)` - /// — there is no separate loopback bookkeeping. Returns the `LoopbackForward` - /// reply shape the GUI's Cmd-click flow consumes (just the local port), so the - /// wire reply is unchanged. - /// - /// `_target` (the pane's remote hostname) is retained for call-site - /// compatibility; dedup keys on the concrete `remote_host:remote_port`. pub async fn ensure_loopback( &self, pane_id: u64, @@ -717,11 +540,6 @@ impl SshForwardRegistry { .await } - /// [`ensure_loopback`](Self::ensure_loopback) for a remote workspace: the - /// ⌘-clicked `localhost:PORT` in a remote-workspace pane. - /// - /// The forward is owned by the workspace, so clicking the link in one pane - /// and then closing that pane leaves the browser tab working. pub async fn ensure_loopback_workspace( &self, workspace: WorkspaceId, @@ -745,10 +563,6 @@ impl SshForwardRegistry { remote_host: &str, remote_port: u16, ) -> io::Result<LoopbackForward> { - // Dedup: a live auto-forward to the same target is reused rather than - // duplicated (preserving the old `ensure_loopback` behavior). Scoped to - // the owner, so two workspaces on one machine don't share — and can't - // break — each other's forward. if let Some(local_port) = self.find_auto_local(owner, remote_host, remote_port) { return Ok(LoopbackForward { local_port }); } @@ -762,8 +576,6 @@ impl SshForwardRegistry { }; let id = self.next_id.fetch_add(1, Ordering::Relaxed); let (bind_port, status, cancel) = self.start_local(&conn, &rule).await; - // A bind failure must surface to the Cmd-click caller (it previously - // propagated via `?`), and no dead entry is registered. if let ForwardStatus::Error(e) = &status { return Err(io::Error::other(e.clone())); } @@ -790,8 +602,6 @@ impl SshForwardRegistry { }) } - /// The local port of a live auto-created loopback forward owned by `owner` - /// targeting `remote_host:remote_port`, if one exists (dedup for Cmd-click). fn find_auto_local( &self, owner: &ForwardOwner, @@ -813,39 +623,15 @@ impl SshForwardRegistry { } } -// --------------------------------------------------------------------------- -// Workspace-scoped entry points on the manager. -// --------------------------------------------------------------------------- - -/// The blocking, workspace-scoped half of [`SshManager`]'s forward API. -/// -/// Written here rather than in `ssh/mod.rs` deliberately: these are the sync -/// wrappers for *this* file's registry, and keeping them beside it means the -/// pane-scoped wrappers next door stay untouched — a workspace forward cannot -/// be reached by editing one of them by mistake. Private fields of `SshManager` -/// are in scope because this module is a descendant of the one that defines it. impl SshManager { - /// The already-authenticated connection for `spec`'s host, if this daemon - /// has one — **never** connecting. - /// - /// A workspace-scoped request rides the connection the workspace itself - /// opened, so the right answer to "no connection" is an error the user can - /// act on ("the workspace is not connected"), not a silent second connect - /// that would prompt for credentials from a context with nowhere to put a - /// dialog. That is also why `spec` may be — and from the GUI always is — - /// secret-free: [`ConnectionKey::from_spec`] reads only host, user, port, - /// proxy and jump chain, so a stripped spec hashes to the same slot. pub fn existing_connection(&self, spec: &NativeSshSpec) -> Option<Arc<SshConnection>> { let key = ConnectionKey::from_spec(spec); let slot = self.conns.lock().unwrap().get(&key).cloned()?; - // `blocking_lock` would panic on a runtime worker; `try_lock` failing - // just means a connect for this key is in flight, which is "not ready". let guard = slot.try_lock().ok()?; let conn = guard.upgrade()?; conn.is_alive().then_some(conn) } - /// Establish a workspace-owned managed forward; returns the workspace's list. pub fn add_workspace_forward( &self, workspace: WorkspaceId, @@ -861,7 +647,6 @@ impl SshManager { }) } - /// Remove one workspace-owned forward; returns the rest. pub fn remove_workspace_forward( &self, workspace: WorkspaceId, @@ -874,7 +659,6 @@ impl SshManager { ) } - /// A workspace's managed forwards. pub fn list_workspace_forwards( &self, workspace: WorkspaceId, @@ -883,14 +667,11 @@ impl SshManager { self.forwards.list_workspace(workspace, view_pane) } - /// Drop every forward a workspace owns (the workspace was closed). pub fn teardown_workspace_forwards(&self, workspace: WorkspaceId) { self.runtime .block_on(self.forwards.teardown_workspace(workspace)); } - /// Ensure the on-demand loopback forward behind a ⌘-clicked `localhost:PORT` - /// in a remote-workspace pane. pub fn ensure_workspace_loopback( &self, workspace: WorkspaceId, @@ -913,7 +694,6 @@ mod tests { use super::*; use tokio::io::{AsyncReadExt, AsyncWriteExt}; - /// A SOCKS4 client (version byte `0x04`) is rejected outright. #[tokio::test] async fn socks5_rejects_v4() { let (mut client, mut server) = tokio::io::duplex(64); @@ -922,12 +702,9 @@ mod tests { assert_eq!(err.kind(), io::ErrorKind::InvalidData); } - /// A well-formed v5 CONNECT to an IPv4 address is parsed and the method reply is - /// the no-auth selection. #[tokio::test] async fn socks5_v5_connect_ipv4() { let (mut client, mut server) = tokio::io::duplex(64); - // Greeting (1 method: no-auth) + CONNECT to 1.2.3.4:80. client.write_all(&[0x05, 0x01, 0x00]).await.unwrap(); client .write_all(&[0x05, 0x01, 0x00, 0x01, 1, 2, 3, 4, 0x00, 0x50]) @@ -936,13 +713,11 @@ mod tests { let (host, port) = socks5_negotiate(&mut server).await.unwrap(); assert_eq!(host, "1.2.3.4"); assert_eq!(port, 80); - // Method-selection reply is VER=5, METHOD=0 (no auth). let mut reply = [0u8; 2]; client.read_exact(&mut reply).await.unwrap(); assert_eq!(reply, [0x05, 0x00]); } - /// A v5 CONNECT with a domain-name address (ATYP=3). #[tokio::test] async fn socks5_v5_connect_domain() { let (mut client, mut server) = tokio::io::duplex(64); @@ -952,8 +727,6 @@ mod tests { req.extend_from_slice(host); req.extend_from_slice(&443u16.to_be_bytes()); client.write_all(&req).await.unwrap(); - // Negotiate before draining the reply: on a single-threaded test runtime - // the writer must run first, or the reply read would deadlock. let (host, port) = socks5_negotiate(&mut server).await.unwrap(); assert_eq!(host, "example.com"); assert_eq!(port, 443); @@ -962,7 +735,6 @@ mod tests { assert_eq!(reply, [0x05, 0x00]); } - /// A v5 CONNECT with an IPv6 address (ATYP=4). #[tokio::test] async fn socks5_v5_connect_ipv6() { let (mut client, mut server) = tokio::io::duplex(64); @@ -971,7 +743,6 @@ mod tests { req.extend_from_slice(&std::net::Ipv6Addr::LOCALHOST.octets()); req.extend_from_slice(&22u16.to_be_bytes()); client.write_all(&req).await.unwrap(); - // Negotiate before draining the reply (see the domain test). let (host, port) = socks5_negotiate(&mut server).await.unwrap(); assert_eq!(host, "::1"); assert_eq!(port, 22); @@ -980,7 +751,6 @@ mod tests { assert_eq!(reply, [0x05, 0x00]); } - /// A v5 BIND command (0x02) is rejected with a "command not supported" reply. #[tokio::test] async fn socks5_rejects_bind_command() { let (mut client, mut server) = tokio::io::duplex(64); @@ -991,7 +761,6 @@ mod tests { .unwrap(); let err = socks5_negotiate(&mut server).await.unwrap_err(); assert_eq!(err.kind(), io::ErrorKind::InvalidData); - // Method reply then a 0x07 (command not supported) reply. let mut method = [0u8; 2]; client.read_exact(&mut method).await.unwrap(); assert_eq!(method, [0x05, 0x00]); @@ -1000,16 +769,12 @@ mod tests { assert_eq!(rep[1], 0x07); } - /// The bridge forwards bytes A→B and propagates the A-side EOF as a clean close - /// on the B side (and streams a reply back B→A). #[tokio::test] async fn bridge_propagates_data_and_eof_both_directions() { - // client_a <-> a ...bridge... b <-> server_b let (mut client_a, a) = tokio::io::duplex(64); let (b, mut server_b) = tokio::io::duplex(64); let bridged = tokio::spawn(async move { bridge(a, b).await }); - // A→B data, then close A's write half. client_a.write_all(b"ping").await.unwrap(); client_a.shutdown().await.unwrap(); @@ -1020,7 +785,6 @@ mod tests { "A→B data delivered and A-side EOF closed B read" ); - // B→A reply after the far side EOF'd — must still flow, then close. server_b.write_all(b"pong").await.unwrap(); server_b.shutdown().await.unwrap(); let mut back = Vec::new(); @@ -1033,8 +797,6 @@ mod tests { bridged.await.unwrap().unwrap(); } - /// The remote-forward table resolves exact matches and falls back to any binding - /// on the same port (server may report a different bind address). #[test] fn remote_forward_table_lookup() { let table = RemoteForwardTable::default(); @@ -1043,7 +805,6 @@ mod tests { table.lookup("localhost", 9000), Some(("127.0.0.1".to_string(), 3000)) ); - // The server reported 127.0.0.1 for a localhost bind → port fallback. assert_eq!( table.lookup("127.0.0.1", 9000), Some(("127.0.0.1".to_string(), 3000)) @@ -1053,10 +814,6 @@ mod tests { assert_eq!(table.lookup("localhost", 9000), None); } - /// The registry's add/list/remove/teardown bookkeeping, independent of a live - /// connection (entries are inserted directly, bypassing `establish` which needs - /// an authenticated `SshConnection`). Aborting the cancel task on remove/teardown - /// is what a real listener teardown does. #[tokio::test] async fn registry_add_list_remove_teardown_bookkeeping() { let reg = SshForwardRegistry::default(); @@ -1081,43 +838,24 @@ mod tests { entries.push(make(0, 8000)); entries.push(make(1, 8001)); } - // list is per-pane and sorted by id. let list = reg.list(7); assert_eq!(list.iter().map(|m| m.id).collect::<Vec<_>>(), vec![0, 1]); assert!(reg.list(99).is_empty(), "other panes see nothing"); - // remove drops just the one forward and returns the remainder. let remaining = reg.remove(7, 0).await; assert_eq!(remaining.len(), 1); assert_eq!(remaining[0].id, 1); - // teardown clears the pane entirely (blast-radius on death). reg.teardown_pane(7).await; assert!(reg.list(7).is_empty()); } - /// Removing (or tearing down) a Local/Dynamic forward must fully drop its - /// accept-loop task — and the `TcpListener` it owns — *before* the call - /// returns, so the bound port is freed synchronously. A plain - /// `AbortHandle::abort()` only *requests* cancellation, so the task (and its - /// socket) can outlive the call and leak the port; `cancel_entry` must abort - /// *and* await. - /// - /// The assertion is race-free: the accept task owns both a real bound - /// `TcpListener` (fidelity with `start_local`) and a clone of an `Arc` guard. - /// Once the task's future is dropped, the guard clone is dropped, so the - /// registry-side `Arc` becomes uniquely owned. With only `abort()` (no await) - /// the task has not been polled on this current-thread runtime when the call - /// returns, so the guard is still held (`strong_count == 2`) — the bug. #[tokio::test] async fn remove_frees_listening_socket_synchronously() { async fn spawn_listener_entry(id: u64, guard: &Arc<()>) -> ForwardEntry { let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); let port = listener.local_addr().unwrap().port(); let guard = guard.clone(); - // Mirror start_local's accept loop: the task owns the listener, so - // only fully dropping the task closes the socket. `guard` rides along - // and is dropped exactly when the task's future is dropped. let handle = tokio::spawn(async move { let _guard = guard; loop { @@ -1142,7 +880,6 @@ mod tests { let reg = SshForwardRegistry::default(); - // remove() path: the task's future (holding the listener) must be gone. let guard = Arc::new(()); let entry = spawn_listener_entry(0, &guard).await; reg.owners @@ -1163,7 +900,6 @@ mod tests { "remove() must drop the accept task (and its TcpListener) synchronously" ); - // teardown_pane() path (pane death / connection loss) frees it too. let guard2 = Arc::new(()); let entry2 = spawn_listener_entry(1, &guard2).await; reg.owners @@ -1180,10 +916,6 @@ mod tests { ); } - /// A live listener entry filed under `owner`, mirroring what `start_local` - /// registers. Returns a guard whose strong count drops to 1 once the accept - /// task (and its `TcpListener`) is fully torn down — the same race-free trick - /// `remove_frees_listening_socket_synchronously` uses. async fn push_listener(reg: &SshForwardRegistry, owner: ForwardOwner, id: u64) -> Arc<()> { let guard = Arc::new(()); let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); @@ -1214,9 +946,6 @@ mod tests { guard } - /// **SSH pane ownership (existing behaviour, must not regress).** A forward - /// opened by a native-SSH pane dies with the pane: `teardown_pane` empties the - /// list *and* frees the listening socket. #[tokio::test] async fn ssh_pane_forwards_die_with_the_pane() { let reg = SshForwardRegistry::default(); @@ -1233,25 +962,13 @@ mod tests { ); } - /// **Remote-workspace ownership.** The panes of a remote - /// workspace are transient — a tab closed, a pane respawned after a reconnect - /// — so a forward the user ⌘-clicked into existence must outlive them. Only - /// closing the *workspace* collects it. - /// - /// The two teardowns are exercised against one registry on purpose: this is - /// the exact case where a single `pane_id`-keyed map would have taken the - /// workspace's forward down with the pane. #[tokio::test] async fn remote_workspace_forwards_survive_their_panes() { let reg = SshForwardRegistry::default(); let ws = WorkspaceId::new(); - // A pane of the workspace, id 7, and a same-numbered SSH-pane forward: - // the ids collide deliberately, since a bare u64 key could not tell them - // apart. let pane_guard = push_listener(®, ForwardOwner::Pane(7), 0).await; let ws_guard = push_listener(®, ForwardOwner::Workspace(ws), 1).await; - // Pane 7 dies. reg.teardown_pane(7).await; assert!(reg.list(7).is_empty(), "the SSH pane's forward went away"); @@ -1267,15 +984,11 @@ mod tests { "…and its listener is still bound" ); - // Only closing the workspace collects it. reg.teardown_workspace(ws).await; assert!(reg.list_workspace(ws, 7).is_empty()); assert_eq!(Arc::strong_count(&ws_guard), 1); } - /// Two workspaces on the *same machine* share one `SshConnection` but own - /// their forwards separately: closing one leaves the other's alone, and the - /// ⌘-click dedup does not hand one workspace the other's local port. #[tokio::test] async fn workspaces_on_one_host_do_not_share_forwards() { let reg = SshForwardRegistry::default(); @@ -1283,7 +996,6 @@ mod tests { push_listener(®, ForwardOwner::Workspace(a), 0).await; push_listener(®, ForwardOwner::Workspace(b), 1).await; - // Dedup is owner-scoped: A's forward to 127.0.0.1:3000 is invisible to B. assert!( reg.find_auto_local(&ForwardOwner::Workspace(a), "127.0.0.1", 3000) .is_some() @@ -1303,7 +1015,6 @@ mod tests { ); } - /// `rekey` moves a binding to the server-assigned port (bind_port 0 case). #[test] fn remote_forward_table_rekey() { let table = RemoteForwardTable::default(); diff --git a/crates/tty7-core/src/daemon/ssh/handler.rs b/crates/tty7-core/src/daemon/ssh/handler.rs index 3b5cf40c..56a2063f 100644 --- a/crates/tty7-core/src/daemon/ssh/handler.rs +++ b/crates/tty7-core/src/daemon/ssh/handler.rs @@ -1,24 +1,3 @@ -//! The russh client [`Handler`]: host-key verification, auth banners, and -//! incoming forwarded channels. -//! -//! russh invokes `check_server_key` during the handshake (once per connection — -//! reused connections never re-run it) and `auth_banner` if the server sends one. -//! Both route through the [`PromptBroker`] so the *GUI* makes the trust decision -//! and sees the banner; the daemon owns the `known_hosts` storage per PRD §3.4. -//! -//! `server_channel_open_forwarded_tcpip` implements the Remote-forward -//! (`tcpip-forward`) receive side (WS4): incoming channels are matched against the -//! connection's [`RemoteForwardTable`] and bridged to a local socket. -//! -//! **X11 seam (P1, FR-X2 — deferred).** WS2 carries `NativeSshSpec.x11` but never -//! requests `x11-req` on the shell channel, so no X11 channels arrive and the -//! default `server_channel_open_x11` (auto-reject on drop) is correct. Wiring X11 -//! would add: `channel.request_x11(..)` at shell start (with a MIT-MAGIC-COOKIE-1 -//! cookie), a `server_channel_open_x11` override here that resolves the local -//! display (`$DISPLAY` → `/tmp/.X11-unix/X<n>` unix socket or `localhost:6000+n`), -//! and `forward::bridge` to that socket — mirroring the forwarded-tcpip path below. -//! Left unimplemented deliberately (macOS needs XQuartz; low priority). - use std::sync::Arc; use russh::Channel; @@ -38,17 +17,10 @@ pub struct ClientHandler { pub verify_host_keys: bool, pub skip_banner: bool, pub broker: Arc<PromptBroker>, - /// The connection's Remote-forward bindings (WS4). Shared with its - /// [`super::session::SshConnection`]; incoming `forwarded-tcpip` channels are - /// matched against it and bridged to the registered local target. pub remote_forwards: RemoteForwardTable, } impl ClientHandler { - /// Turn a GUI host-key decision into an accept/reject, appending to - /// `known_hosts` when the user chose to remember it. A remember-append failure - /// is logged but does not veto the (already-granted) session — the user - /// approved this key for this connection either way. fn apply_decision(&self, resp: AuthResponse, key: &PublicKey) -> bool { match resp { AuthResponse::HostKeyDecision { @@ -62,7 +34,6 @@ impl ClientHandler { } true } - // Explicit reject, a cancel, or a mismatched response kind: refuse. _ => false, } } @@ -75,10 +46,6 @@ impl russh::client::Handler for ClientHandler { &mut self, server_public_key: &PublicKey, ) -> Result<bool, Self::Error> { - // A per-profile / global opt-out (FR-S4): trust without prompting — but - // still honor `@revoked` markers, like OpenSSH under - // `StrictHostKeyChecking no`: an explicitly revoked key is never - // acceptable, opt-out or not. if !self.verify_host_keys { let revoked = matches!( known_hosts::check(&self.host, self.port, server_public_key), @@ -99,7 +66,6 @@ impl russh::client::Handler for ClientHandler { match known_hosts::check(&self.host, self.port, server_public_key) { HostKeyStatus::Known => Ok(true), - // A revoked key is a hard reject — never even offer to trust it. HostKeyStatus::Revoked => Ok(false), HostKeyStatus::Unknown => { let resp = self @@ -142,11 +108,6 @@ impl russh::client::Handler for ClientHandler { Ok(()) } - /// An incoming connection on a Remote (`tcpip-forward`) binding. Match it - /// against this connection's registered forwards; on a hit, accept the channel - /// and bridge it to a fresh local TCP connection to the target. An unmatched - /// channel is rejected (dropping `reply` rejects) — a remote forward we don't - /// own must not be tunneled anywhere. async fn server_channel_open_forwarded_tcpip( &mut self, channel: Channel<Msg>, @@ -164,7 +125,6 @@ impl russh::client::Handler for ClientHandler { log::info!( "rejecting unmatched forwarded-tcpip channel on {connected_address}:{connected_port}" ); - // Dropping `reply` rejects the channel. return Ok(()); }; reply.accept().await; diff --git a/crates/tty7-core/src/daemon/ssh/known_hosts.rs b/crates/tty7-core/src/daemon/ssh/known_hosts.rs index c36c2646..0eb7c066 100644 --- a/crates/tty7-core/src/daemon/ssh/known_hosts.rs +++ b/crates/tty7-core/src/daemon/ssh/known_hosts.rs @@ -1,42 +1,16 @@ -//! OpenSSH `known_hosts` reading + trust decisions for the native russh path. -//! -//! Scope (v1, per PRD §3.4 — WS3 hardens this later): read `~/.ssh/known_hosts`, -//! decide trust for **plaintext** hosts, **hashed** hosts (`|1|salt|hash`, HMAC- -//! SHA1), and `@revoked` lines (hard reject). `@cert-authority` lines are skipped -//! (treated as no-match) so a CA entry never produces a false "changed key" -//! warning — the connection just falls through to the unknown-host confirmation. -//! -//! The parser **never rewrites** the file: [`append_trusted`] only appends a -//! single new line, preserving every existing line (comments, hashed entries, CA -//! and revoked markers) byte-for-byte. -//! -//! SHA-1 / HMAC-SHA1 / base64 are hand-rolled here rather than pulled in as -//! dependencies: it keeps host-key matching self-contained and unit-testable -//! against RFC vectors, and the volume (one HMAC per known_hosts line at connect -//! time) is trivial. - use std::io::Write as _; use std::path::{Path, PathBuf}; use russh::keys::ssh_key::{HashAlg, PublicKey}; -/// The outcome of checking a presented host key against `known_hosts`. #[derive(Debug, Clone, PartialEq, Eq)] pub enum HostKeyStatus { - /// An entry for this host + key type matches this exact key: trusted. Known, - /// No entry for this host + key type: a first connection (confirm + maybe add). Unknown, - /// An entry for this host + key type exists but the key differs: possible MITM. - Changed { - /// SHA256 fingerprint of the stored (old) key, for the warning UI. - old_fingerprint_sha256: String, - }, - /// A matching `@revoked` line: reject hard, never offer to trust. + Changed { old_fingerprint_sha256: String }, Revoked, } -/// The default OpenSSH user known_hosts path, `~/.ssh/known_hosts`. pub fn default_path() -> Option<PathBuf> { home_dir().map(|h| h.join(".ssh").join("known_hosts")) } @@ -55,8 +29,6 @@ fn home_dir() -> Option<PathBuf> { .map(PathBuf::from) } -/// The host token OpenSSH keys a `known_hosts` entry under: the bare host for the -/// default port 22, else the bracketed `[host]:port` form. pub fn host_token(host: &str, port: u16) -> String { if port == 22 { host.to_string() @@ -65,7 +37,6 @@ pub fn host_token(host: &str, port: u16) -> String { } } -/// Check `host:port`'s presented `key` against the default known_hosts file. pub fn check(host: &str, port: u16, key: &PublicKey) -> HostKeyStatus { match default_path() { Some(path) => check_in_file(&path, host, port, key), @@ -73,8 +44,6 @@ pub fn check(host: &str, port: u16, key: &PublicKey) -> HostKeyStatus { } } -/// Check against a specific file (the testable core of [`check`]). A missing or -/// unreadable file means "no entries" → `Unknown`. pub fn check_in_file(path: &Path, host: &str, port: u16, key: &PublicKey) -> HostKeyStatus { let contents = match std::fs::read_to_string(path) { Ok(c) => c, @@ -83,15 +52,10 @@ pub fn check_in_file(path: &Path, host: &str, port: u16, key: &PublicKey) -> Hos check_in_str(&contents, host, port, key) } -/// Trust decision over the text of a known_hosts file. Split out so the matcher -/// is unit-testable against fixture strings without touching the filesystem. pub fn check_in_str(contents: &str, host: &str, port: u16, key: &PublicKey) -> HostKeyStatus { let token = host_token(host, port); let our_alg = key.algorithm(); - // First pass — revocation wins outright. A `@revoked` line matching this exact - // key anywhere in the file rejects it, even if a trusted line for the same - // host+key appears earlier: a revoked key must never read as trusted. for line in contents.lines() { let Some(entry) = KnownHostsLine::parse(line) else { continue; @@ -106,7 +70,6 @@ pub fn check_in_str(contents: &str, host: &str, port: u16, key: &PublicKey) -> H } } - // Second pass — normal known/changed resolution (revocation already handled). let mut changed: Option<String> = None; let mut changed_other_alg: Option<String> = None; for line in contents.lines() { @@ -117,20 +80,11 @@ pub fn check_in_str(contents: &str, host: &str, port: u16, key: &PublicKey) -> H continue; } match entry.marker { - // A host-CA line certifies keys signed by this CA; russh doesn't do - // host-cert verification here, so skip rather than mis-flag it as a - // changed key (PRD §3.4). Falls through to Unknown → confirm. Some(Marker::CertAuthority) => continue, - // Revocation was resolved in the first pass; ignore here. Some(Marker::Revoked) => continue, None => { let Some(stored) = entry.key() else { continue }; if stored.algorithm() != our_alg { - // The host is known, just via a different key type. If no - // same-type line resolves this below, report Changed, like - // OpenSSH: a MITM can present a key of an algorithm absent - // from the file precisely to downgrade the changed-key - // warning to a benign first-connect prompt. if changed_other_alg.is_none() { changed_other_alg = Some(fingerprint_sha256(&stored)); } @@ -139,9 +93,6 @@ pub fn check_in_str(contents: &str, host: &str, port: u16, key: &PublicKey) -> H if &stored == key { return HostKeyStatus::Known; } - // Same host + key type, different key: a candidate "changed" - // result — but keep scanning in case a later line matches - // exactly (a host can list several keys of the same type). if changed.is_none() { changed = Some(fingerprint_sha256(&stored)); } @@ -157,9 +108,6 @@ pub fn check_in_str(contents: &str, host: &str, port: u16, key: &PublicKey) -> H } } -/// Append a trust line for `host:port` + `key` to the default known_hosts file, -/// creating `~/.ssh` (mode 0700) and the file (0600) if needed. Never rewrites -/// existing lines — a plain append. pub fn append_trusted(host: &str, port: u16, key: &PublicKey) -> std::io::Result<()> { let path = default_path().ok_or_else(|| { std::io::Error::new(std::io::ErrorKind::NotFound, "no home dir for known_hosts") @@ -167,7 +115,6 @@ pub fn append_trusted(host: &str, port: u16, key: &PublicKey) -> std::io::Result append_trusted_to(&path, host, port, key) } -/// The testable core of [`append_trusted`]: append to a specific path. pub fn append_trusted_to( path: &Path, host: &str, @@ -185,16 +132,12 @@ pub fn append_trusted_to( let key_openssh = key .to_openssh() .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?; - // `to_openssh` yields `<algo> <base64>` (with the key's comment, if any). Take - // just the algo + base64 so the appended line is a clean host entry. let mut parts = key_openssh.split_whitespace(); let algo = parts.next().unwrap_or_default(); let b64 = parts.next().unwrap_or_default(); let token = host_token(host, port); let line = format!("{token} {algo} {b64}\n"); - // Make sure we start on a fresh line so we never join onto a file that lacks a - // trailing newline (which would corrupt the last existing entry). let needs_leading_newline = match std::fs::read(path) { Ok(bytes) => !bytes.is_empty() && bytes.last() != Some(&b'\n'), Err(_) => false, @@ -215,15 +158,12 @@ pub fn append_trusted_to( Ok(()) } -/// SHA256 fingerprint of a public key in OpenSSH `SHA256:base64` form. pub fn fingerprint_sha256(key: &PublicKey) -> String { key.fingerprint(HashAlg::Sha256).to_string() } pub use crate::daemon::protocol::{KnownHostEntry, KnownHostId}; -/// List every parseable entry in the default `known_hosts` file, in file order. -/// A missing/unreadable file lists as empty. pub fn list() -> Vec<KnownHostEntry> { match default_path() { Some(path) => match std::fs::read_to_string(&path) { @@ -234,7 +174,6 @@ pub fn list() -> Vec<KnownHostEntry> { } } -/// The testable core of [`list`]: parse entries out of file text. pub fn list_in_str(contents: &str) -> Vec<KnownHostEntry> { let mut out = Vec::new(); for line in contents.lines() { @@ -263,10 +202,6 @@ pub fn list_in_str(contents: &str) -> Vec<KnownHostEntry> { out } -/// Delete the entry matching `id` from the default `known_hosts` file. Every -/// other line — comments, blanks, unrelated entries, and the file's exact line -/// endings — is preserved verbatim. A no-op (Ok) when the file is absent or the -/// entry isn't found. pub fn delete(id: &KnownHostId) -> std::io::Result<()> { let Some(path) = default_path() else { return Ok(()); @@ -274,7 +209,6 @@ pub fn delete(id: &KnownHostId) -> std::io::Result<()> { delete_in_file(&path, id) } -/// The testable core of [`delete`]: rewrite `path` without the matching entry. pub fn delete_in_file(path: &Path, id: &KnownHostId) -> std::io::Result<()> { let contents = match std::fs::read_to_string(path) { Ok(c) => c, @@ -285,11 +219,6 @@ pub fn delete_in_file(path: &Path, id: &KnownHostId) -> std::io::Result<()> { if !removed { return Ok(()); } - // Write a sibling temp then rename over the original: an in-place truncating - // write would leave a truncated known_hosts behind a crash mid-write. (A - // concurrent O_APPEND from another connection's TOFU accept can still be - // lost to the read-modify-write window — data-loss only; a lost entry fails - // toward re-prompting, never toward trusting.) let tmp = path.with_extension("tty7-tmp"); std::fs::write(&tmp, new_contents)?; #[cfg(unix)] @@ -302,18 +231,11 @@ pub fn delete_in_file(path: &Path, id: &KnownHostId) -> std::io::Result<()> { }) } -/// Remove the line matching `id` from `contents`, preserving all other lines and -/// their exact terminators byte-for-byte. Returns the new text and whether a line -/// was removed. Only the first matching line is dropped (ids are unique in -/// practice). pub fn delete_in_str(contents: &str, id: &KnownHostId) -> (String, bool) { let mut out = String::with_capacity(contents.len()); let mut removed = false; - // Split keeping terminators so we never alter unrelated bytes (CRLF, a - // missing final newline, blank lines, comment spacing). for segment in split_keep_terminators(contents) { if !removed { - // Match against the line's text without its terminator/leading space. let line = segment.trim_end_matches(['\n', '\r']); if let Some(entry) = KnownHostsLine::parse(line) { if entry.hosts == id.host @@ -330,9 +252,6 @@ pub fn delete_in_str(contents: &str, id: &KnownHostId) -> (String, bool) { (out, removed) } -/// Split text into segments that each still carry their trailing `\n` (and any -/// `\r`), so rejoining is byte-identical to the input. The final segment has no -/// terminator when the file doesn't end in a newline. fn split_keep_terminators(text: &str) -> Vec<&str> { let mut segments = Vec::new(); let mut start = 0; @@ -355,8 +274,6 @@ enum Marker { Revoked, } -/// One parsed known_hosts line: an optional marker, the host field (raw), and the -/// key type + base64 blob. Comment/whitespace/blank lines parse to `None`. struct KnownHostsLine<'a> { marker: Option<Marker>, hosts: &'a str, @@ -377,7 +294,6 @@ impl<'a> KnownHostsLine<'a> { marker = Some(match m { "cert-authority" => Marker::CertAuthority, "revoked" => Marker::Revoked, - // Unknown marker: skip the whole line rather than misinterpret it. _ => return None, }); rest = tail.trim_start(); @@ -385,7 +301,6 @@ impl<'a> KnownHostsLine<'a> { let (hosts, tail) = rest.split_once(char::is_whitespace)?; let tail = tail.trim_start(); let (keytype, keyblob) = tail.split_once(char::is_whitespace)?; - // The blob may carry a trailing comment; keep only the base64 token. let keyblob = keyblob.split_whitespace().next().unwrap_or(keyblob); Some(Self { marker, @@ -395,22 +310,10 @@ impl<'a> KnownHostsLine<'a> { }) } - /// Reconstruct the stored public key (`<type> <base64>`), or `None` if it - /// doesn't parse (an entry we can't compare against). fn key(&self) -> Option<PublicKey> { PublicKey::from_openssh(&format!("{} {}", self.keytype, self.keyblob)).ok() } - /// Does this line's host field cover `token`? Handles plaintext host lists - /// (comma-separated), OpenSSH glob patterns (`*` / `?`), `!` negations, and - /// the `|1|salt|hash` hashed form. - /// - /// OpenSSH semantics: the field is a comma-separated pattern list; a leading - /// `!` negates. If *any* negated pattern matches the host, the line does not - /// apply at all (even when a positive pattern also matches); otherwise the - /// line applies iff at least one positive pattern matches. Hostname matching - /// is case-insensitive. A hashed entry carries exactly one host and never - /// globs. fn matches_host(&self, token: &str) -> bool { let mut matched = false; for pattern in self.hosts.split(',') { @@ -429,8 +332,6 @@ impl<'a> KnownHostsLine<'a> { }; if hit { if negated { - // A negated match disqualifies the whole line, regardless of - // any positive match elsewhere on it. return false; } matched = true; @@ -440,10 +341,6 @@ impl<'a> KnownHostsLine<'a> { } } -/// Match a single OpenSSH host pattern (which may contain `*` / `?` wildcards) -/// against a host token, case-insensitively. `*` matches any run of characters -/// (including empty), `?` matches exactly one character — OpenSSH's `match_pattern` -/// glob, not a regex. Wildcard-free patterns are a plain case-insensitive compare. fn host_glob_matches(pattern: &str, token: &str) -> bool { if !pattern.as_bytes().iter().any(|&b| b == b'*' || b == b'?') { return pattern.eq_ignore_ascii_case(token); @@ -451,8 +348,6 @@ fn host_glob_matches(pattern: &str, token: &str) -> bool { glob_match(pattern.as_bytes(), token.as_bytes()) } -/// Iterative backtracking glob for `*`/`?`, ASCII-case-insensitive (host names -/// fold case in OpenSSH). Linear-ish with a single backtrack pointer for `*`. fn glob_match(pattern: &[u8], text: &[u8]) -> bool { let (mut p, mut t) = (0usize, 0usize); let mut star: Option<usize> = None; @@ -466,7 +361,6 @@ fn glob_match(pattern: &[u8], text: &[u8]) -> bool { star_t = t; p += 1; } else if let Some(sp) = star { - // Backtrack: let the last `*` swallow one more character. p = sp + 1; star_t += 1; t = star_t; @@ -480,8 +374,6 @@ fn glob_match(pattern: &[u8], text: &[u8]) -> bool { p == pattern.len() } -/// Check a `|1|salt|hash` hashed-host field (base64 salt + base64 HMAC-SHA1) -/// against a host token: OpenSSH stores `HMAC-SHA1(key=salt, msg=token)`. fn hashed_host_matches(hashed: &str, token: &str) -> bool { let Some((salt_b64, hash_b64)) = hashed.split_once('|') else { return false; @@ -492,8 +384,6 @@ fn hashed_host_matches(hashed: &str, token: &str) -> bool { hmac_sha1(&salt, token.as_bytes()).as_slice() == hash.as_slice() } -// --- SHA-1 (FIPS 180-1) -------------------------------------------------- - fn sha1(data: &[u8]) -> [u8; 20] { let mut h: [u32; 5] = [0x67452301, 0xEFCDAB89, 0x98BADCFE, 0x10325476, 0xC3D2E1F0]; let ml = (data.len() as u64).wrapping_mul(8); @@ -571,8 +461,6 @@ fn hmac_sha1(key: &[u8], msg: &[u8]) -> [u8; 20] { sha1(&outer) } -// --- standard base64 decode (for the hashed-host salt/hash fields) ------- - fn base64_decode(s: &str) -> Option<Vec<u8>> { fn val(c: u8) -> Option<u8> { match c { @@ -619,7 +507,6 @@ mod tests { #[test] fn hmac_sha1_matches_rfc2202_vector() { - // RFC 2202 test case 1: key = 0x0b*20, data = "Hi There". let key = [0x0bu8; 20]; assert_eq!( hex(&hmac_sha1(&key, b"Hi There")), @@ -629,7 +516,6 @@ mod tests { #[test] fn base64_decode_round_trips_openssh_salt() { - // "hello" -> aGVsbG8= assert_eq!(base64_decode("aGVsbG8=").unwrap(), b"hello"); assert_eq!(base64_decode("").unwrap(), b""); } @@ -640,7 +526,6 @@ mod tests { assert_eq!(host_token("example.com", 2222), "[example.com]:2222"); } - // A fixed ed25519 public key and a second, different one, both valid OpenSSH. const KEY_A: &str = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPXO/kBX63iuiTczoR6uNdl3wAFK7tGWz70jCKkKlw5r"; const KEY_B: &str = @@ -672,16 +557,12 @@ mod tests { #[test] fn different_key_type_for_a_known_host_reports_changed_not_unknown() { - // The host is known via ed25519 only; a presented ECDSA key must raise - // the changed-key warning, not the benign first-connect prompt — a MITM - // can pick an algorithm absent from the file to get the softer dialog. const KEY_ECDSA: &str = "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBCdv5xfuuCGyVbYZSTqcFjQWE7YtIsx8fqlXF1+v728j1RUnELLVrmgsC6gZ0zObXAzJ39JEynaQv9tf/v16V58="; let file = format!("example.com {KEY_A}\n"); match check_in_str(&file, "example.com", 22, &key(KEY_ECDSA)) { HostKeyStatus::Changed { .. } => {} other => panic!("expected Changed, got {other:?}"), } - // A same-type exact match elsewhere still wins over the mismatch. let file = format!("example.com {KEY_A}\nexample.com {KEY_ECDSA}\n"); assert_eq!( check_in_str(&file, "example.com", 22, &key(KEY_ECDSA)), @@ -697,7 +578,6 @@ mod tests { check_in_str(&file, "example.com", 2222, &ka), HostKeyStatus::Known ); - // Same host on the default port is a different token → unknown. assert_eq!( check_in_str(&file, "example.com", 22, &ka), HostKeyStatus::Unknown @@ -716,8 +596,6 @@ mod tests { #[test] fn revoked_takes_precedence_over_an_earlier_trusted_line() { - // A trusted line for the exact key appears FIRST, then a `@revoked` line - // for the same host+key. Revocation must win — the key is never trusted. let ka = key(KEY_A); let file = format!("example.com {KEY_A}\n@revoked example.com {KEY_A}\n"); assert_eq!( @@ -729,8 +607,6 @@ mod tests { #[test] fn cert_authority_line_is_skipped_not_flagged_as_changed() { let ka = key(KEY_A); - // A CA line whose key differs from the presented key must NOT read as - // "changed" — it should fall through to Unknown. let file = format!("@cert-authority example.com {KEY_B}\n"); assert_eq!( check_in_str(&file, "example.com", 22, &ka), @@ -750,10 +626,8 @@ mod tests { #[test] fn hashed_host_matches_via_hmac_sha1() { - // Build a hashed entry the way OpenSSH would: salt is arbitrary bytes, - // hash = HMAC-SHA1(salt, token). Encode both with our base64. let token = "example.com"; - let salt = b"0123456789abcdef1234"; // 20 bytes + let salt = b"0123456789abcdef1234"; let hash = hmac_sha1(salt, token.as_bytes()); let line = format!("|1|{}|{} {KEY_A}\n", b64(salt), b64(&hash),); let ka = key(KEY_A); @@ -772,16 +646,13 @@ mod tests { let dir = std::env::temp_dir().join(format!("tty7-kh-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("known_hosts"); - // Pre-seed a file WITHOUT a trailing newline to prove we don't corrupt it. std::fs::write(&path, format!("first.com {KEY_B}")).unwrap(); let ka = key(KEY_A); append_trusted_to(&path, "example.com", 2222, &ka).unwrap(); let contents = std::fs::read_to_string(&path).unwrap(); - // The original line is intact... assert!(contents.contains(&format!("first.com {KEY_B}"))); - // ...and the new host is trusted at its bracketed token. assert_eq!( check_in_str(&contents, "example.com", 2222, &ka), HostKeyStatus::Known @@ -801,7 +672,6 @@ mod tests { check_in_str(&file, "a.b.example.com", 22, &ka), HostKeyStatus::Known ); - // `*` does not cross into a different domain suffix. assert_eq!( check_in_str(&file, "web1.example.org", 22, &ka), HostKeyStatus::Unknown @@ -813,7 +683,6 @@ mod tests { let ka = key(KEY_A); let file = format!("host? {KEY_A}\n"); assert_eq!(check_in_str(&file, "host1", 22, &ka), HostKeyStatus::Known); - // `?` is exactly one char — "host" (zero) and "host12" (two) don't match. assert_eq!(check_in_str(&file, "host", 22, &ka), HostKeyStatus::Unknown); assert_eq!( check_in_str(&file, "host12", 22, &ka), @@ -824,8 +693,6 @@ mod tests { #[test] fn negated_pattern_disqualifies_the_line() { let ka = key(KEY_A); - // Matches the whole domain except the negated host — even though the - // positive `*.example.com` would otherwise cover it. let file = format!("*.example.com,!secret.example.com {KEY_A}\n"); assert_eq!( check_in_str(&file, "web.example.com", 22, &ka), @@ -871,8 +738,6 @@ mod tests { #[test] fn delete_removes_only_the_matching_entry_byte_for_byte() { - // A file with CRLF, a comment, a blank line, and no trailing newline on - // the last entry — deletion must preserve every unrelated byte. let contents = format!("# my hosts\r\nkeep.example.com {KEY_B}\n\ndrop.example.com {KEY_A}"); let entries = list_in_str(&contents); @@ -884,10 +749,8 @@ mod tests { .clone(); let (after, removed) = delete_in_str(&contents, &target); assert!(removed); - // Everything except the dropped line is preserved exactly. let expected = format!("# my hosts\r\nkeep.example.com {KEY_B}\n\n"); assert_eq!(after, expected); - // And the dropped host is now unknown. let ka = key(KEY_A); assert_eq!( check_in_str(&after, "drop.example.com", 22, &ka), @@ -923,7 +786,6 @@ mod tests { bytes.iter().map(|b| format!("{b:02x}")).collect() } - // A minimal standard-base64 encoder for the test fixtures only. fn b64(data: &[u8]) -> String { const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; let mut out = String::new(); diff --git a/crates/tty7-core/src/daemon/ssh/mod.rs b/crates/tty7-core/src/daemon/ssh/mod.rs index a63281e4..88e73ca5 100644 --- a/crates/tty7-core/src/daemon/ssh/mod.rs +++ b/crates/tty7-core/src/daemon/ssh/mod.rs @@ -1,36 +1,14 @@ -//! Native SSH session engine (Workstream 2). -//! -//! A single [`SshManager`] owns one tokio runtime and the registry of live -//! [`SshConnection`]s. The rest of the daemon is std-threads and never enters this -//! runtime; a native-SSH pane crosses the boundary only through the blocking -//! `Read`/`Write` adapters in [`session`] (fed by the async channel driver) and -//! the [`PromptBroker`] (auth/host-key round-trips). -//! -//! ## Connection reuse & the API WS4/WS5 build on (FR-C2) -//! Connections are keyed by [`ConnectionKey`] (host/port/user/proxy/jump chain). -//! A spawn for a key with a live connection reuses it — a new tab opens a fresh -//! *channel*, never a fresh authentication. Port-forwards (WS4) and SFTP (WS5) -//! reach a pane's connection through the same registry and open their own channels -//! on it: [`SshConnection::open_direct_tcpip`] (Local/Dynamic forwards, and the -//! jump transport) and [`SshConnection::open_session_channel`] (SFTP subsystem). -//! `DaemonPane::ssh_connection` (in `daemon::pane`) exposes a pane's connection. - pub mod broker; pub mod forward; pub mod known_hosts; pub mod session; pub mod sftp; -/// Workspace-scoped control requests — see `workspace::handle`. pub mod workspace; mod auth; mod connect; mod handler; -/// A child process's stdio as one duplex stream. Re-exported (rather than -/// opening `connect` as a whole) because `daemon::remote_link` wraps a -/// `tty7-server --stdio` child in exactly the shape the `ProxyCommand` path -/// already uses. pub use connect::ProcessStream; pub use broker::PromptBroker; @@ -57,25 +35,12 @@ use forward::RemoteForwardTable; use handler::ClientHandler; use session::drive_channel; -/// Default connect+auth budget when the spec doesn't set one. const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(30); -/// Identifies a reusable connection: same key ⇒ same authenticated transport. -/// Includes the full proxy configuration and (recursively) the jump chain, so two -/// specs that differ only in how they *reach* the host don't collide. #[derive(Clone, PartialEq, Eq, Hash, Debug)] pub struct ConnectionKey(String); impl ConnectionKey { - /// The key as a string, for callers that need to *name* a connection — - /// a log line, an error message, an installer's "which host am I writing - /// to". Exposed because the alternative callers reach for is peeling the - /// derived `Debug` output apart, which silently breaks the day anything - /// about the formatting changes. - /// - /// It is a connection identity, not a display name: it carries the proxy - /// and jump chain, and no user-facing label. Where the user has their own - /// name for a host, prefer that and keep this for disambiguation. pub fn as_str(&self) -> &str { &self.0 } @@ -97,26 +62,16 @@ impl ConnectionKey { } } -/// Per-key reuse slot: a `Weak` behind an async mutex, so establishing a new -/// connection for a key serializes (no duplicate connects) without serializing -/// *different* keys. type ConnSlot = Arc<tokio::sync::Mutex<Weak<SshConnection>>>; pub struct SshManager { runtime: tokio::runtime::Runtime, conns: Mutex<HashMap<ConnectionKey, ConnSlot>>, - /// The WS4 managed-forward registry (Local/Remote/Dynamic + native loopback), - /// driven on this manager's runtime. forwards: SshForwardRegistry, - /// Memoized remote shell-integration probes, keyed like connections. A - /// present `None` means "probed, nothing to inject" — cached just as firmly - /// as a hit so an unintegrable host isn't re-probed on every new tab. See - /// [`SshManager::remote_bootstrap`]. probes: Mutex<HashMap<ConnectionKey, Option<(remote::RemoteShell, String)>>>, } impl SshManager { - /// The process-wide engine. Built lazily on first native-SSH spawn. pub fn global() -> &'static SshManager { static MANAGER: OnceLock<SshManager> = OnceLock::new(); MANAGER.get_or_init(|| { @@ -135,23 +90,10 @@ impl SshManager { }) } - /// A handle to the engine's tokio runtime. The SFTP layer (`ssh::sftp`) uses - /// it to `block_on` one-shot operations and `spawn` background transfer jobs - /// from the daemon's std threads (the server connection threads) without owning - /// a second runtime. Safe to call from a non-async thread; `block_on` on the - /// returned handle drives the future on the caller and panics only if called - /// from *within* a runtime worker (the server threads never are). pub fn handle(&self) -> tokio::runtime::Handle { self.runtime.handle().clone() } - // ---- Synchronous forward API for the (std-thread) daemon server ---------- - // - // The server dispatch runs on plain std threads; these block on the runtime - // for the async establishment/teardown while returning results synchronously. - - /// Establish a managed forward on `conn` for `pane_id`; returns the pane's - /// forwards after the add. pub fn add_forward( &self, pane_id: u64, @@ -164,27 +106,21 @@ impl SshManager { }) } - /// Remove a managed forward by id; returns the pane's remaining forwards. pub fn remove_forward(&self, pane_id: u64, forward_id: u64) -> Vec<ManagedForward> { self.runtime .block_on(self.forwards.remove(pane_id, forward_id)) } - /// List a pane's managed forwards. pub fn list_forwards(&self, pane_id: u64) -> Vec<ManagedForward> { self.forwards.list(pane_id) } - /// Tear down every forward attributed to `pane_id` (pane death / blast radius). - /// Detached on the runtime so a pane's `Drop` (which runs on a connection - /// thread) never blocks on a remote `cancel_tcpip_forward` round-trip. pub fn teardown_pane_forwards(&'static self, pane_id: u64) { self.runtime.spawn(async move { self.forwards.teardown_pane(pane_id).await; }); } - /// Ensure a native-SSH loopback forward for a Cmd-clicked `localhost` URL (FR-F4). pub fn ensure_loopback_forward( &self, pane_id: u64, @@ -202,28 +138,14 @@ impl SshManager { )) } - /// Loopback forwards are no longer tracked separately — a Cmd-clicked - /// `localhost` link registers a plain Local managed forward (see - /// [`SshForwardRegistry::ensure_loopback`]), surfaced through `list_forwards`. - /// This wire endpoint is kept for protocol compatibility and always empty. pub fn list_loopback_forwards(&self) -> Vec<LoopbackForwardInfo> { Vec::new() } - /// No-op: there is no separate loopback registry to close from (kept for - /// protocol compatibility). Auto forwards are removed via the managed list. pub fn close_loopback_forward(&self, _id: &LoopbackForwardId) -> bool { false } - /// Kick off a native-SSH shell for a pane. Returns immediately; the connect → - /// auth → shell sequence runs on the runtime and drives the pane through the - /// provided bridge ends. All progress/prompt frames go via `broker`. - /// - /// On any failure the task emits `SshStatus::Failed`, writes a one-line - /// diagnostic into the output stream, and drops `data_tx` — which EOFs the - /// pane's reader and surfaces as the usual `Exited`, so a failed connect looks - /// to the rest of the daemon exactly like a shell that exited. pub fn spawn_native_session( &'static self, pane_id: u64, @@ -250,11 +172,8 @@ impl SshManager { broker.status(SshPhase::Failed { reason: reason.clone(), }); - // A visible, human-readable line so the pane isn't just a blank - // that vanishes — even before WS3 renders SshStatus. let line = format!("\r\n\x1b[31mtty7: SSH connection failed: {reason}\x1b[0m\r\n"); let _ = data_tx.send(line.into_bytes()).await; - // Dropping data_tx (and cmd_rx already moved) EOFs the reader. } }); } @@ -271,28 +190,15 @@ impl SshManager { ) -> Result<(), String> { broker.status(SshPhase::Connecting); - // Note: the connect timeout is applied *inside* `open_connection`, around - // the transport + SSH handshake only — never around interactive auth, - // which the user may reasonably take a while to complete (the broker - // enforces its own per-prompt timeout). let (mut conn, reused) = self .open_connection(spec, broker) .await .map_err(|e| format!("{e}"))?; - // Publish the connection so the pane (and WS4/WS5) can open further - // channels on it. A `Weak`, so this never keeps the connection alive past - // the strong `Arc` the driver holds below for the shell's lifetime. *conn_slot.lock().unwrap() = Arc::downgrade(&conn); broker.status(SshPhase::Connected); - // Open the shell channel on the (possibly shared) connection. This is also - // the first liveness probe of a *reused* connection: if its transport died - // silently — a parked forward/loopback accept loop holds an `Arc`, so the - // dead connection's `Drop` (and `mark_dead`) never ran — the first channel - // open errors. Self-heal: mark it dead, evict its registry slot, and - // reconnect fresh once. A fresh connection that fails here is a real error. let channel = match conn.open_session_channel().await { Ok(channel) => channel, Err(e) if reused => { @@ -316,10 +222,6 @@ impl SshManager { Err(e) => return Err(format!("open shell channel failed: {e}")), }; - // Establish the profile's preconfigured forwards (FR-F2) now that the - // connection is authenticated *and* confirmed live. Failures are non-fatal — - // each surfaces as a `ForwardStatus::Error` on the forward row, never a - // killed session. for rule in &spec.forwards { self.forwards.establish(pane_id, conn.clone(), rule).await; } @@ -342,19 +244,9 @@ impl SshManager { .map_err(|e| format!("pty-req failed: {e}"))?; if spec.agent_forward { - // Best effort: some servers refuse; a refusal shouldn't abort the shell. let _ = channel.agent_forward(false).await; } - // Shell integration (OSC 133 + cwd reporting) for the remote shell. When - // the remote is one we know how to bootstrap, the shell is started by an - // `exec` request carrying a setup script that ends in `exec <shell>`, - // rather than by a bare `shell` request; see `shell_integration::remote`. - // Anything unrecognized — or a probe that couldn't be run — falls through - // to the plain shell request, which is exactly what every session did - // before this existed. - // Opting out short-circuits the probe too, not just the bootstrap: a - // profile with the switch off should cost nothing and touch nothing. let bootstrap = match spec.shell_integration { true => self.remote_bootstrap(&conn).await, false => None, @@ -370,40 +262,16 @@ impl SshManager { .map_err(|e| format!("shell request failed: {e}"))?, } - // Login script: each line verbatim + newline, in order, no expect-logic. for line in &spec.login_script { let mut bytes = line.clone().into_bytes(); bytes.push(b'\n'); let _ = channel.data(&bytes[..]).await; } - // Hand the channel to the pump. `conn` moves in so the shared connection - // stays alive for this shell's lifetime (and remains reusable meanwhile). drive_channel(channel, data_tx, cmd_rx, conn).await; Ok(()) } - // ---- Remote workspaces: one logical stream to a remote `tty7-server` ---- - - /// Open one logical stream from this daemon to the `tty7-server` on `spec`'s - /// host, reusing (or establishing) the machine's single authenticated - /// connection. - /// - /// **One authentication per machine.** The connection comes from the same - /// [`ConnectionKey`] registry the SSH panes use, so a workspace opened - /// against a host the user already has a pane on costs no prompt at all, and - /// a second workspace on the same host costs no second prompt — each stream - /// is a new *channel*, never a new authentication. One channel - /// per pane, one per workspace control stream; no multiplexing of our own on - /// top of SSH's. - /// - /// The returned `Arc<SshConnection>` must be held for as long as the link is - /// used: it is the last strong reference that keeps the shared connection - /// (and therefore the channel) alive. - /// **What `setup` buys.** Everything below this line may need a user: the - /// authentication, the consent to write a binary onto the machine, the - /// discovery that the daemon already there is a different build. `setup` - /// carries the one client that can answer — see [`RouteSetup`]. pub async fn open_remote_link( &self, spec: &NativeSshSpec, @@ -412,21 +280,6 @@ impl SshManager { ) -> anyhow::Result<(RemoteLink, Arc<SshConnection>)> { let (conn, _reused) = self.open_connection(spec, &setup.broker).await?; - // Before the first stream to a host, make sure the remote is actually - // serving: the right version of `tty7-server`, installed and running. - // Idempotent and cheap on the common path (two commands and one SFTP - // stat, no download, no prompt), which is what makes it safe to call - // before *every* link rather than once per connection. - // - // A `?` here means no link is opened at all, so "this machine has no - // tty7-server" arrives as a route ack with a reason. B1 deliberately - // left this un-stubbed rather than always-Ok for exactly that: an empty - // implementation would turn a missing server into an opaque channel - // failure much later. - // - // On a blocking thread because `Installer` is blocking start to finish - // and one step of it waits on a human; running it on a runtime worker - // would park the reactor that has to carry the answer back. let installed = { let install_conn = conn.clone(); setup @@ -434,13 +287,6 @@ impl SshManager { .await?? }; - // The installed binary's **absolute** path, not the bare name. Nothing - // puts `~/.local/share/tty7/bin` on a non-interactive `PATH`, and the - // file there is `tty7-server-c<control>p<protocol>` — so - // `exec tty7-server --stdio` is a `command not found` on a machine the - // install just succeeded on. - // The install pass we just ran is what knows the path, so it hands it - // over rather than leaving the transport to guess. let base = match server_command { Some(explicit) => explicit.to_string(), None => format!( @@ -450,9 +296,6 @@ impl SshManager { }; let command = setup.channel.bridge_command(&base); - // A pane connection never takes the cached `direct-streamlocal` entry: - // that entry names the *control* socket, and the pane dialect is served - // on a different one. See `RouteChannel::bridge_command`. let entry = match setup.channel { RouteChannel::Pane => RemoteEntry::SessionExec { command: command.clone(), @@ -461,10 +304,6 @@ impl SshManager { conn.remote_entry_or_init(|| async { let env = probe_remote_env(&conn).await; let socket = env.as_ref().and_then(remote_link::remote_control_socket); - // Optimistic: `AllowStreamLocalForwarding` defaults to `yes` - // and the only way to learn otherwise is to be refused, - // which the demotion below turns into a permanent, - // connection-wide answer. remote_link::choose_entry(socket.as_deref(), true, &command) }) .await @@ -475,8 +314,6 @@ impl SshManager { match conn.open_direct_streamlocal(socket).await { Ok(channel) => return Ok((RemoteLink::stream_local(channel), conn)), Err(e) => { - // The refusal every later stream on this connection must not - // repeat: cache the fallback before taking it. log::info!( "ssh {:?}: direct-streamlocal to {socket} refused ({e}); \ falling back to `{command}`", @@ -499,45 +336,18 @@ impl SshManager { Ok((RemoteLink::session_exec(channel), conn)) } - /// Replace the `tty7-server` running on `spec`'s host with this client's - /// build — "Restart Server", and **it drops every pane - /// that server is hosting**. - /// - /// Only ever reached from a [`RouteAction::RestartServer`](crate::daemon::router::RouteAction) - /// header, which a client only writes after a user has answered the - /// keep-or-restart prompt with "Restart Server". Nothing in the connect path - /// calls this: an older daemon on the far side keeps serving, because it owns - /// live work and only its owner can decide to throw that away. - /// - /// Deliberately **not** an `ensure_remote_server` first. The mismatch that - /// raises the prompt is discovered by an install pass that has already put - /// this build's binary in place, so there is nothing left to install — and a - /// second pass would rediscover the very mismatch the user is answering and - /// relay a fresh prompt for it the moment the restart finished. pub async fn restart_remote_server( &self, spec: &NativeSshSpec, setup: &RouteSetup, ) -> anyhow::Result<()> { let (conn, _reused) = self.open_connection(spec, &setup.broker).await?; - // Blocking start to finish (SIGTERM, poll for the socket to go, launch, - // poll for it to answer) and it may stop to ask the user for a password - // on the way in — the same reason `open_remote_link` keeps the installer - // off the runtime's workers. setup .blocking(move || crate::daemon::install::restart_remote_daemon(&conn)) .await??; Ok(()) } - /// Reinstall this client's `tty7-server` on `spec`'s host over whatever is at - /// its path, then restart the daemon onto it — "Replace Server", and **it - /// drops every pane that server is hosting**. - /// - /// Unlike [`restart_remote_server`](Self::restart_remote_server) this *does* - /// write: it is the answer to a handshake that failed against a binary whose - /// name promised a dialect it does not speak, so the file itself is what has - /// to change. See [`crate::daemon::install::Installer::replace`]. pub async fn replace_remote_server( &self, spec: &NativeSshSpec, @@ -550,9 +360,6 @@ impl SshManager { Ok(()) } - /// [`open_remote_link`](Self::open_remote_link) for the daemon's std threads - /// (the router runs on one). Safe from any thread that is not itself a - /// runtime worker — the server's connection threads never are. pub fn open_remote_link_blocking( &self, spec: &NativeSshSpec, @@ -563,28 +370,10 @@ impl SshManager { .block_on(self.open_remote_link(spec, setup, server_command)) } - /// Drop a connection key's registry slot so the next `open_connection` for it - /// establishes a fresh connection instead of upgrading a stale `Weak`. Called - /// by the self-healing reuse path when a reused connection turns out dead. fn evict_connection(&self, key: &ConnectionKey) { self.conns.lock().unwrap().remove(key); } - /// The shell-integration bootstrap script for `conn`'s next shell, or `None` - /// to start that shell bare. - /// - /// Deciding costs one `exec` round-trip against the remote (see - /// [`probe_remote_shell`]), so the answer is memoized on the connection key — - /// the same identity connections are reused under. Opening a second tab to a - /// host therefore pays nothing, and a *reconnect* to a host probed earlier - /// pays nothing either: which shell a login lands in doesn't change between - /// connections, so the cache deliberately outlives them. - /// - /// Two panes racing to a not-yet-probed host may both probe. That is a - /// duplicated round-trip on a cold connection, not a correctness problem — - /// the probe has no side effects and both arrive at the same answer — so it - /// isn't worth serializing every spawn behind a per-key lock the way - /// connection establishment is. async fn remote_bootstrap(&self, conn: &Arc<SshConnection>) -> Option<String> { let key = conn.key().clone(); let cached = { self.probes.lock().unwrap().get(&key).cloned() }; @@ -605,12 +394,6 @@ impl SshManager { probed.map(|(shell, path)| remote::bootstrap_command(shell, &path)) } - /// Establish (or reuse) the connection for `spec`, recursing through the jump - /// chain. Boxed because it is `async`-recursive. The returned `bool` is `true` - /// when an existing connection was reused (no fresh authentication) — the - /// caller uses it to self-heal: a reused connection whose transport silently - /// died errors on its first channel open, and only then is it worth evicting - /// and reconnecting. fn open_connection<'a>( &'a self, spec: &'a NativeSshSpec, @@ -627,17 +410,10 @@ impl SshManager { let mut guard = slot.lock().await; if let Some(conn) = guard.upgrade() { if conn.is_alive() { - // Reuse: a new channel on the existing authenticated connection. return Ok((conn, true)); } } - // Establish the jump connection first (recursively) so its - // `direct-tcpip` channel can be this connection's transport — unless - // a ProxyCommand is also configured: it outranks the jump in - // `build_transport`, and establishing (and interactively - // authenticating) a jump connection that would then be discarded - // wastes the user's prompts. let has_proxy_command = matches!(&spec.proxy, crate::daemon::protocol::SshProxy::Command(_)); let jump = match &spec.jump { @@ -647,15 +423,11 @@ impl SshManager { _ => None, }; - // Transport + SSH handshake under the connect-timeout budget. Auth is - // deliberately outside it (see `run_session`). let budget = spec .connect_timeout_s .filter(|v| *v > 0) .map(|v| Duration::from_secs(u64::from(v))) .unwrap_or(DEFAULT_CONNECT_TIMEOUT); - // The connection's Remote-forward table, shared with its handler so - // incoming `forwarded-tcpip` channels resolve to a local target (WS4). let remote_forwards = RemoteForwardTable::default(); let handler = ClientHandler { host: spec.host.clone(), @@ -672,12 +444,6 @@ impl SshManager { .await .map_err(|e| anyhow::anyhow!("ssh handshake failed: {e}")) }; - // Watchdog rather than a flat `timeout(budget, ...)`: russh raises the - // host-key confirmation *inside* connect_stream (via - // `check_server_key`), and the user reading a fingerprint must not - // race the network timeout. Ticks are only billed against the budget - // while no broker prompt is pending; the broker's own per-prompt - // timeout still bounds an unanswered dialog. let mut handshake = std::pin::pin!(handshake); let mut remaining = budget; const TICK: Duration = Duration::from_millis(200); @@ -707,27 +473,10 @@ impl SshManager { } } -/// How long to wait for the shell probe before giving up on integrating a -/// remote. Generous, because the probe runs under the remote's login shell and -/// therefore behind whatever its `.zshenv` does; short enough that a host which -/// never answers costs a pause, not a hang. Expiring is not an error — the -/// session continues with a plain shell. const PROBE_TIMEOUT: Duration = Duration::from_secs(5); -/// Cap on probe output, in case the remote's startup files are chatty. Far more -/// than the two lines we asked for; a remote that exceeds it has already told us -/// everything [`remote::parse_probe`] could use. const PROBE_OUTPUT_LIMIT: usize = 8 * 1024; -/// Ask the remote which login shell it would start, on a throwaway channel. -/// -/// This is a non-PTY `exec`, so it runs and exits without touching the session -/// the user is about to get; nothing here can break that session, and every -/// failure path returns `None`, meaning "start the shell bare". -/// -/// stderr is folded in with stdout because the marker-based parse tolerates -/// noise, and a remote whose startup files complain on stderr would otherwise -/// have its (perfectly good) answer thrown away. async fn probe_remote_shell(conn: &SshConnection) -> Option<(remote::RemoteShell, String)> { let mut channel = conn.open_session_channel().await.ok()?; channel.exec(true, remote::PROBE_COMMAND).await.ok()?; @@ -747,24 +496,11 @@ async fn probe_remote_shell(conn: &SshConnection) -> Option<(remote::RemoteShell } } }; - // A timeout doesn't discard what did arrive: the answer is on the second - // line, so a remote that printed it and then stalled before closing the - // channel is still perfectly readable. let _ = tokio::time::timeout(PROBE_TIMEOUT, collect).await; remote::parse_probe(&String::from_utf8_lossy(&out)) } -/// Read the four environment variables the remote's control socket path is -/// derived from, on a throwaway `exec` channel. -/// -/// `None` when the remote said nothing usable — the caller then takes the -/// `--stdio` bridge, which resolves the path in the process that binds it, so a -/// failed probe costs a slower transport and never a failed connection. -/// -/// stderr is folded in for the same reason the shell probe does it: the parse is -/// marker-based and tolerates noise, and discarding a good answer because the -/// remote's startup files complained would be gratuitous. async fn probe_remote_env(conn: &SshConnection) -> Option<remote_link::RemoteEnv> { let mut channel = conn.open_session_channel().await.ok()?; channel @@ -793,9 +529,6 @@ async fn probe_remote_env(conn: &SshConnection) -> Option<remote_link::RemoteEnv (env != remote_link::RemoteEnv::default()).then_some(env) } -/// A conservative set of PTY modes for the shell channel — an interactive TTY -/// with canonical input, echo, and signal handling on, and standard baud codes. -/// The remote line discipline uses these as its starting point. fn sane_terminal_modes() -> Vec<(Pty, u32)> { vec![ (Pty::ISIG, 1), @@ -858,14 +591,9 @@ mod tests { }; assert_ne!(a, ConnectionKey::from_spec(&c)); - // Identical connection params → identical key (reuse). assert_eq!(a, ConnectionKey::from_spec(&base_spec())); } - /// `as_str` is what names a connection in prompts, logs and the installer's - /// "which host am I writing to". It must carry the jump chain: two hosts - /// reached through different bastions are different connections, and a - /// label that collapsed them would put an install prompt on the wrong box. #[test] fn the_key_string_names_the_whole_chain() { assert_eq!(ConnectionKey::from_spec(&base_spec()).as_str(), "u@h:22"); @@ -882,9 +610,6 @@ mod tests { #[test] fn evict_connection_clears_the_registry_slot() { - // The self-heal path evicts a dead connection's key so the next - // `open_connection` establishes fresh instead of upgrading a stale `Weak`. - // Exercise just the registry map — no live server needed. let runtime = tokio::runtime::Builder::new_current_thread() .build() .expect("build test runtime"); @@ -934,8 +659,6 @@ mod tests { spec.port = port; spec.auth_mode = SshAuthMode::Gssapi; spec.connect_timeout_s = Some(10); - // Prove GSSAPI itself without requiring a GUI host-key prompt or mutating - // the user's known_hosts from this live test. spec.verify_host_keys = false; let manager = SshManager::global(); diff --git a/crates/tty7-core/src/daemon/ssh/session.rs b/crates/tty7-core/src/daemon/ssh/session.rs index 0e9ce72e..82483c57 100644 --- a/crates/tty7-core/src/daemon/ssh/session.rs +++ b/crates/tty7-core/src/daemon/ssh/session.rs @@ -1,27 +1,3 @@ -//! The async↔blocking bridge for a native-SSH pane, plus the connection wrapper. -//! -//! The daemon's pane reader/writer threads are plain std threads doing *blocking* -//! `Read`/`Write` (see `daemon::pane`). russh is async. This module is the seam: -//! -//! - [`SshReader`] is a blocking `Read` over a **bounded** channel fed by the -//! channel driver. A full channel makes the driver's `data_tx.send().await` -//! pause, which stops it draining `channel.wait()`, which lets russh's own -//! window management apply backpressure to the SSH channel — so a slow client -//! (via `OutputGate`) throttles the remote exactly like a full PTY throttles a -//! local child, with no unbounded spool in between. Channel EOF/close drops -//! `data_tx`, so `blocking_recv()` returns `None` and the read returns `Ok(0)` -//! — the same liveness signal a PTY hangup gives, feeding the existing death -//! path. -//! - [`SshWriter`] is a blocking `Write` that forwards bytes to the driver over an -//! unbounded command channel (keystrokes are low-volume; never block input). -//! - [`ChannelCmd`] carries input / resize / close from the pane's std threads to -//! the async driver. -//! - [`drive_channel`] is the per-pane async task pumping the shell channel. -//! - [`SshConnection`] wraps one authenticated `russh::client::Handle`. It is the -//! unit of reuse and of the FR-C2 blast radius (see the doc comment there), and -//! the API surface WS4 (port-forwards) and WS5 (SFTP) reuse to open further -//! channels on a pane's existing connection. - use std::io::{self, Read, Write}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, Weak}; @@ -35,46 +11,25 @@ use crate::daemon::remote_link::RemoteEntry; use super::ConnectionKey; use super::forward::RemoteForwardTable; -/// Bounded depth (in messages) of the driver→reader data channel. Each message is -/// one russh data chunk (≤ the channel's max packet size, ~32 KiB), so this caps -/// the in-flight spool at a few hundred KiB before backpressure engages — small -/// enough to keep memory bounded, large enough not to stall a healthy client. const DATA_CHANNEL_DEPTH: usize = 16; -/// A slot the connect task publishes the pane's established connection into, as a -/// `Weak` so it never keeps the connection alive past the shell's own strong -/// `Arc` (held by the channel driver). WS4 (forwards) and WS5 (SFTP) upgrade it — -/// via `DaemonPane::ssh_connection()` — to open further channels on the pane's -/// shared connection. Empty until the connection authenticates. pub type SharedConnection = Arc<Mutex<Weak<SshConnection>>>; -/// A command from the pane's std threads to the async channel driver. pub enum ChannelCmd { - /// Bytes to write to the shell channel (keyboard input / paste / login script). Data(Vec<u8>), - /// A terminal resize → `window-change` request. Resize(WinSize), - /// Close the channel (kill/hangup). The driver then exits and its EOF reaches - /// the reader. Close, } -/// The pane-facing handle for a native-SSH session: where resize/close/input -/// commands are sent. Cloned into the [`SshWriter`] and held by the pane's -/// backend so `resize`/`kill` reach the driver. pub struct SshSessionHandle { cmd_tx: tokio::sync::mpsc::UnboundedSender<ChannelCmd>, } impl SshSessionHandle { pub fn resize(&self, size: WinSize) { - // A closed channel just means the driver already exited (the pane is - // dying); dropping the resize is correct. let _ = self.cmd_tx.send(ChannelCmd::Resize(size)); } - /// Ask the driver to close the shell channel. Idempotent — a second send after - /// the driver exited is a harmless no-op. pub fn close(&self) { let _ = self.cmd_tx.send(ChannelCmd::Close); } @@ -86,7 +41,6 @@ impl SshSessionHandle { } } -/// Blocking `Read` half of the bridge — see the module comment. pub struct SshReader { rx: tokio::sync::mpsc::Receiver<Vec<u8>>, leftover: Vec<u8>, @@ -95,18 +49,13 @@ pub struct SshReader { impl Read for SshReader { fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> { - // Drain any partial chunk left from a previous read first. while self.pos >= self.leftover.len() { match self.rx.blocking_recv() { Some(data) if !data.is_empty() => { self.leftover = data; self.pos = 0; } - // An empty chunk shouldn't occur (the driver only forwards - // non-empty data), but if it did, just wait for the next. Some(_) => continue, - // Sender dropped: channel EOF/close. Report clean EOF so the - // pane's death path fires exactly as on a PTY hangup. None => return Ok(0), } } @@ -117,7 +66,6 @@ impl Read for SshReader { } } -/// Blocking `Write` half of the bridge — forwards input bytes to the driver. pub struct SshWriter { handle: Arc<SshSessionHandle>, } @@ -133,9 +81,6 @@ impl Write for SshWriter { } } -/// Build the paired ends of the bridge for one pane: the blocking reader/writer -/// the daemon threads use, the shared handle for resize/close, and the two channel -/// ends the async driver takes (`data_tx` to push output, `cmd_rx` to pull input). pub struct BridgeEnds { pub reader: SshReader, pub writer: SshWriter, @@ -163,7 +108,6 @@ pub fn make_bridge() -> BridgeEnds { } } -/// Pixel dims for a `window-change`, mirroring `pty_size` in `daemon::pane`. fn pixels(size: WinSize) -> (u32, u32) { ( u32::from(size.cols).saturating_mul(u32::from(size.cell_w)), @@ -171,11 +115,6 @@ fn pixels(size: WinSize) -> (u32, u32) { ) } -/// The per-pane async task: pump shell-channel output to the reader and channel -/// commands to the remote. Ends (dropping `data_tx`, EOFing the reader) on channel -/// EOF/close, an explicit `Close`, or the command sender being dropped (pane -/// gone). `_conn` is held for the session's lifetime so the shared connection -/// isn't dropped (and disconnected) while this shell is still open. pub async fn drive_channel( mut channel: Channel<Msg>, data_tx: tokio::sync::mpsc::Sender<Vec<u8>>, @@ -186,35 +125,21 @@ pub async fn drive_channel( tokio::select! { msg = channel.wait() => match msg { Some(ChannelMsg::Data { data }) => { - // Awaiting here is the backpressure point: a full bounded - // channel pauses us, which pauses `channel.wait()`, which lets - // russh throttle the SSH window. An error means the reader was - // dropped (pane gone) — stop. if data_tx.send(data.to_vec()).await.is_err() { break; } } - // Merge stderr (extended data) into the same byte stream: a shell - // channel's stderr is part of the terminal output the user expects - // to see inline, exactly as a PTY interleaves them. Some(ChannelMsg::ExtendedData { data, .. }) => { if data_tx.send(data.to_vec()).await.is_err() { break; } } - // Exit status/signal arrive before the final Eof/Close; record - // nothing special — the daemon's existing `Exited{code:None}` path - // (driven by the reader's EOF below) is what the GUI consumes, and - // it doesn't depend on the code. Keep looping for any trailing data. Some(ChannelMsg::ExitStatus { .. }) | Some(ChannelMsg::ExitSignal { .. }) => {} Some(ChannelMsg::Eof) | Some(ChannelMsg::Close) | None => break, Some(_) => {} }, cmd = cmd_rx.recv() => match cmd { Some(ChannelCmd::Data(bytes)) => { - // `&[u8]` implements tokio's AsyncRead; this writes one data - // message. A failure means the channel is gone — let the - // wait() side observe the close. let _ = channel.data(&bytes[..]).await; } Some(ChannelCmd::Resize(size)) => { @@ -223,8 +148,6 @@ pub async fn drive_channel( .window_change(u32::from(size.cols), u32::from(size.rows), pw, ph) .await; } - // Explicit close, or the pane dropped its command sender: tear the - // channel down and exit so the reader EOFs. Some(ChannelCmd::Close) | None => { let _ = channel.eof().await; let _ = channel.close().await; @@ -233,49 +156,14 @@ pub async fn drive_channel( } } } - // Falling out of the loop drops `data_tx`; the reader's next `blocking_recv` - // returns `None` → `read` returns `Ok(0)` → the pane reports `Exited`. } -/// One authenticated russh connection, shared by every pane (and later every SFTP -/// session / port-forward) that resolved to the same [`ConnectionKey`]. -/// -/// **Blast radius (FR-C2).** All shell channels for a given key share this one -/// `Handle`. If the underlying transport drops, every channel opened on it EOFs -/// at once, so *every* pane sharing the connection sees `Exited` together — the -/// PRD's documented "all shared panes go disconnected as a unit" semantics. The -/// connection stays in the registry as a `Weak`; when the last shell/SFTP/forward -/// that holds an `Arc<SshConnection>` drops, this `Drop` disconnects the session. -/// A subsequent spawn for the same key finds either a live connection (reuse, no -/// re-auth — new tabs are instant) or a dead/absent one (a fresh connect). pub struct SshConnection { - /// The authenticated russh handle. `russh::client::Handle` is `Send` but not - /// `Sync` (it owns an `UnboundedReceiver`), yet the connection registry and the - /// static [`super::SshManager`] must be `Sync`. A `tokio::Mutex` makes the - /// whole `SshConnection` `Send + Sync`; the lock is uncontended (channel opens - /// are infrequent) and holding it across the open `.await` is exactly what - /// tokio mutexes are for. handle: tokio::sync::Mutex<russh::client::Handle<super::handler::ClientHandler>>, - /// The key this connection is registered under. Retained for diagnostics and - /// as the stable identity WS4/WS5 will match against. #[allow(dead_code)] key: ConnectionKey, - /// The connection's active `tcpip-forward` bindings (WS4 Remote forwards). - /// Shared with this connection's [`super::handler::ClientHandler`] so incoming - /// `forwarded-tcpip` channels resolve to a local target. Empty for a connection - /// with no remote forwards. remote_forwards: RemoteForwardTable, alive: AtomicBool, - /// How this host's `tty7-server` is reached — probed once, then reused by - /// every remote workspace stream on this connection. - /// - /// Per *connection*, not per channel: deciding costs a round trip (an `exec` - /// to read the remote's environment, and on a host with - /// `AllowStreamLocalForwarding no` a rejected channel open on top), and the - /// answer cannot change while the connection lives. Behind a `tokio::Mutex` - /// held across the probe, so two workspaces opening at once produce one - /// probe rather than two — unlike [`super::SshManager`]'s shell-integration - /// cache, where a duplicated probe is merely wasted work. remote_entry: tokio::sync::Mutex<Option<RemoteEntry>>, } @@ -294,23 +182,11 @@ impl SshConnection { }) } - #[allow(dead_code)] // WS4/WS5 seam: identify a pane's shared connection + #[allow(dead_code)] pub fn key(&self) -> &ConnectionKey { &self.key } - /// Whether this connection is still usable for reuse. - /// - /// Two signals: the `alive` flag (cleared by [`mark_dead`](Self::mark_dead) on - /// teardown or when a reuse attempt finds the transport dead) **and** the russh - /// handle's own liveness — when russh's session task ends (transport dropped), - /// its command sender closes, so `handle.is_closed()` flips to true. The flag - /// alone is unreliable: `mark_dead` only runs from `Drop`, but a parked - /// forward/loopback accept loop holds an `Arc<SshConnection>`, so a dead - /// connection's `Drop` never runs and the flag stays true. Consulting - /// `is_closed()` (via a non-blocking `try_lock`; a contended lock means an open - /// is in flight, so assume alive) catches that case cheaply. The self-healing - /// reconnect in `SshManager::run_session` is the belt-and-suspenders backstop. pub fn is_alive(&self) -> bool { if !self.alive.load(Ordering::SeqCst) { return false; @@ -321,23 +197,14 @@ impl SshConnection { } } - /// Mark this connection unusable for reuse (teardown, or a reuse attempt that - /// found the transport dead). Idempotent. pub(super) fn mark_dead(&self) { self.alive.store(false, Ordering::SeqCst); } - /// Open a new interactive session channel on this connection. Used for shells - /// (WS2) and reused by WS5 to open the SFTP subsystem channel on a pane's - /// existing connection. pub async fn open_session_channel(&self) -> Result<Channel<Msg>, russh::Error> { self.handle.lock().await.channel_open_session().await } - /// Open a `direct-tcpip` channel to `host:port` through this connection. This - /// is both the jump-host transport primitive (WS2) and the Local/Dynamic - /// port-forward primitive WS4 will build on, opened on the pane's shared - /// connection rather than a control socket. pub async fn open_direct_tcpip( &self, host: &str, @@ -355,18 +222,6 @@ impl SshConnection { .await } - /// Open a `direct-streamlocal@openssh.com` channel to `socket_path` on the - /// remote — the preferred way into a remote `tty7-server`. - /// - /// The remote's sshd connects the channel to that Unix socket itself, so the - /// far end sees an ordinary local connection and needs no extra process. The - /// extension is OpenSSH's, and `AllowStreamLocalForwarding` defaults to - /// `yes`; an administrator who set it to `no` makes this fail at channel - /// open, which is the signal [`RemoteEntry`] caches a fallback for. - /// - /// `socket_path` is the remote's path and is sent verbatim — no `~`, no - /// variables, no client-side path arithmetic (a Windows client's - /// `PathBuf::join` would corrupt it). pub async fn open_direct_streamlocal( &self, socket_path: &str, @@ -378,13 +233,6 @@ impl SshConnection { .await } - /// This connection's cached [`RemoteEntry`], probing with `init` the first - /// time anyone asks. - /// - /// The lock is held across `init` on purpose: the point of the cache is that - /// the round trips happen once, and two workspaces opening simultaneously - /// against a cold connection is the *normal* case (a window restoring its - /// layout), not a rare race. pub async fn remote_entry_or_init<F, Fut>(&self, init: F) -> RemoteEntry where F: FnOnce() -> Fut, @@ -404,22 +252,10 @@ impl SshConnection { entry } - /// Replace the cached entry after the preferred one failed in use. - /// - /// A `direct-streamlocal` open that the server refuses is not necessarily - /// visible at probe time — a daemon can be restarted, a socket removed, an - /// administrator's `AllowStreamLocalForwarding no` applied on reload — so - /// the first failure demotes the connection for good rather than letting - /// every later stream pay the same rejected round trip. pub async fn set_remote_entry(&self, entry: RemoteEntry) { *self.remote_entry.lock().await = Some(entry); } - /// Request a `tcpip-forward` binding on `bind_host:bind_port`, routing incoming - /// `forwarded-tcpip` channels to `target_host:target_port` (WS4 Remote forward). - /// Registers the target *before* the request so an eager server channel finds - /// it. Returns the resolved bind port (the server assigns one when `bind_port` - /// is 0). On failure the registration is rolled back. pub async fn add_remote_forward( &self, bind_host: &str, @@ -460,8 +296,6 @@ impl SshConnection { } } - /// Cancel a previously requested `tcpip-forward` binding (best effort) and drop - /// its target registration. pub async fn cancel_remote_forward(&self, bind_host: &str, bind_port: u16) { self.remote_forwards.unregister(bind_host, bind_port); let _ = self @@ -478,13 +312,9 @@ mod tests { use super::*; use std::io::Read; - /// The blocking reader delivers pushed chunks in order and, once the driver - /// drops its `data_tx`, reports clean EOF (`Ok(0)`) — the liveness signal the - /// pane's death path keys off, identical to a PTY hangup. #[test] fn reader_delivers_chunks_then_eofs_on_sender_drop() { let mut bridge = make_bridge(); - // Push two chunks into the driver→reader channel (buffered; capacity 16). bridge.data_tx.try_send(b"hello ".to_vec()).unwrap(); bridge.data_tx.try_send(b"world".to_vec()).unwrap(); @@ -494,13 +324,10 @@ mod tests { let n = bridge.reader.read(&mut buf).unwrap(); assert_eq!(&buf[..n], b"world"); - // Drop the sender: the next read must EOF, not block forever. drop(bridge.data_tx); assert_eq!(bridge.reader.read(&mut buf).unwrap(), 0); } - /// A partial read keeps the chunk's tail buffered for the next read (the reader - /// must never drop bytes when `buf` is smaller than a chunk). #[test] fn reader_preserves_chunk_tail_across_reads() { let mut bridge = make_bridge(); @@ -512,10 +339,6 @@ mod tests { assert_eq!(&small[..n], b"ef"); } - /// The bounded data channel applies backpressure: once `DATA_CHANNEL_DEPTH` - /// chunks are queued, a further push is refused (`Full`) until the reader - /// drains one — this is what makes a slow client (via `OutputGate`) throttle - /// the SSH channel window instead of spooling unboundedly. #[test] fn bounded_channel_applies_backpressure_until_drained() { let mut bridge = make_bridge(); @@ -525,10 +348,8 @@ mod tests { .try_send(vec![i as u8]) .expect("within capacity"); } - // At capacity: a further push is rejected rather than buffered. assert!(bridge.data_tx.try_send(vec![0xff]).is_err()); - // Drain one chunk; capacity frees, so the next push succeeds. let mut buf = [0u8; 8]; let n = bridge.reader.read(&mut buf).unwrap(); assert_eq!(n, 1); @@ -538,12 +359,6 @@ mod tests { impl Drop for SshConnection { fn drop(&mut self) { - // The last holder of the connection is going away. Marking dead keeps a - // racing reuse from adopting it. Dropping `self.handle` (which happens - // right after this) drops the last sender to russh's session task, which - // ends the session and closes the transport — an immediate teardown. A - // *clean* protocol disconnect would need an `.await`, which `Drop` can't - // do; an abrupt close is the right behavior for teardown anyway. self.mark_dead(); } } diff --git a/crates/tty7-core/src/daemon/ssh/sftp.rs b/crates/tty7-core/src/daemon/ssh/sftp.rs index 083f9afa..656f0744 100644 --- a/crates/tty7-core/src/daemon/ssh/sftp.rs +++ b/crates/tty7-core/src/daemon/ssh/sftp.rs @@ -1,39 +1,3 @@ -//! SFTP engine for native-SSH panes (Workstream 5). -//! -//! One [`SftpManager`] (a process-wide singleton) rides the same tokio runtime the -//! [`SshManager`](super::SshManager) owns. It answers the daemon's SFTP control -//! messages (`SftpList` / `SftpOp` / transfer start/cancel/list) by opening an -//! SFTP-subsystem channel on a pane's already-authenticated `SshConnection` and -//! driving [`russh_sftp`] over it. -//! -//! ## Session lifecycle -//! - **One cached [`SftpSession`] per [`SshConnection`]** (keyed by -//! [`ConnectionKey`]), reused across every pane that shares the connection. -//! - The cache stores a `Weak<SshConnection>` beside the session; a lookup reuses -//! the session only while that weak still upgrades to the *same* live connection -//! (`Arc::ptr_eq`). A reconnect (new connection, same key) transparently gets a -//! fresh SFTP session. -//! - One-shot operations run through [`SftpManager::with_session`], which retries -//! once with a freshly re-opened session **only** on a transport/channel failure -//! — so a dead subsystem channel (while the connection itself lives) is re-opened -//! transparently, while a logical SFTP error (permission denied, no such file) -//! returns directly without a pointless retry. -//! -//! ## Threading -//! The server's std connection threads call the **sync** methods here -//! ([`list`](SftpManager::list) etc.), which `block_on` the SSH runtime handle. -//! Background transfers are `spawn`ed onto that runtime and report progress the -//! GUI polls via [`list_jobs`](SftpManager::list_jobs). -//! -//! ## Notes / limitations -//! - **posix-rename:** upload writes a `.tty7-upload-<rand>` temp then renames over -//! the target. russh-sftp 2.3.0's high-level API does not expose the -//! `posix-rename@openssh.com` extension, so the swap is a plain SFTP `rename` -//! with a remove-then-rename fallback when the server refuses an -//! overwrite-rename (FR-T2's intent: atomic-ish temp-file finish). -//! - Local filesystem access is the daemon process's own (same user) — fine per -//! the spec. - use std::collections::HashMap; use std::future::Future; use std::path::{Component, Path, PathBuf}; @@ -52,19 +16,10 @@ use crate::daemon::protocol::{ use super::{ConnectionKey, SshConnection, SshManager}; -/// Chunk size for streaming reads/writes (matches the Tabby reference). const CHUNK: usize = 256 * 1024; -/// How long a finished job's final progress lingers for the GUI to observe before -/// it is pruned from the job table. const JOB_RETENTION: Duration = Duration::from_secs(30); -// --------------------------------------------------------------------------- -// Remote path helpers (pure) — also used by the GUI panel (`ui::sftp`). -// --------------------------------------------------------------------------- - -/// Join a remote directory path with a child name, POSIX-style (`/` separator, -/// never a backslash — the remote is always POSIX regardless of the daemon's OS). pub fn remote_join(dir: &str, name: &str) -> String { if dir.is_empty() || dir == "/" { format!("/{}", name.trim_start_matches('/')) @@ -77,8 +32,6 @@ pub fn remote_join(dir: &str, name: &str) -> String { } } -/// The parent directory of a remote path. Root's parent is root. Trailing slashes -/// are ignored (so `/a/b/` → `/a`). pub fn remote_parent(path: &str) -> String { let trimmed = path.trim_end_matches('/'); if trimmed.is_empty() { @@ -90,7 +43,6 @@ pub fn remote_parent(path: &str) -> String { } } -/// The final component (basename) of a remote path (`/a/b` → `b`, `/` → `/`). pub fn remote_basename(path: &str) -> String { let trimmed = path.trim_end_matches('/'); if trimmed.is_empty() { @@ -102,11 +54,7 @@ pub fn remote_basename(path: &str) -> String { } } -/// The temp filename an upload writes to before renaming over its target: -/// `<remote>.tty7-upload-<rand>`. Kept in the *same directory* as the target so -/// the finishing rename is same-filesystem (atomic on the server). pub fn upload_temp_name(remote: &str) -> String { - // A cheap, dependency-free random suffix from the system clock + a counter. static COUNTER: AtomicU64 = AtomicU64::new(0); let n = COUNTER.fetch_add(1, Ordering::Relaxed); let nanos = std::time::SystemTime::now() @@ -116,22 +64,10 @@ pub fn upload_temp_name(remote: &str) -> String { format!("{remote}.tty7-upload-{:x}{:x}", nanos, n) } -/// Whether a server-supplied directory-entry `name` is safe to use as a *single* -/// local path component when building a download destination. -/// -/// A recursive download turns remote entry names into local path components -/// (`lpath.join(name)`). A malicious or compromised server can return names like -/// `..`, `../../etc/foo`, or an absolute `/etc/foo`; `Path::join` with an absolute -/// component discards the base, and `..` escapes upward — arbitrary local file -/// write (CVE-2019-6111-class). Accept only a name that is exactly one *normal* -/// path component: reject empty, `.`, `..`, anything containing a `/` or `\\` -/// separator, and anything that doesn't resolve to a single `Component::Normal`. pub fn safe_local_name(name: &str) -> bool { if name.is_empty() || name == "." || name == ".." { return false; } - // Reject either separator on every platform: a POSIX server name must never - // introduce a Windows path separator either. if name.contains('/') || name.contains('\\') { return false; } @@ -142,9 +78,6 @@ pub fn safe_local_name(name: &str) -> bool { ) } -/// The temp path a download writes to before renaming over its target: -/// `<local>.tty7-download-<rand>`, a sibling in the *same directory* so the -/// finishing rename is same-filesystem (atomic). Mirrors [`upload_temp_name`]. fn download_temp_path(lpath: &Path) -> PathBuf { static COUNTER: AtomicU64 = AtomicU64::new(0); let n = COUNTER.fetch_add(1, Ordering::Relaxed); @@ -152,20 +85,11 @@ fn download_temp_path(lpath: &Path) -> PathBuf { .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_nanos() as u64) .unwrap_or(0); - // Append to the full path (a sibling with a suffix) so the temp stays in the - // destination directory regardless of the file name's own extension. let mut os = lpath.as_os_str().to_os_string(); os.push(format!(".tty7-download-{:x}{:x}", nanos, n)); PathBuf::from(os) } -// --------------------------------------------------------------------------- -// Entry classification (pure). -// --------------------------------------------------------------------------- - -/// Classify a remote entry from its attributes. Symlink is checked first because -/// the SFTP type bits let a symlink also satisfy `is_regular` (S_IFLNK contains -/// the S_IFREG bit), so order matters. fn classify(attrs: &FileAttributes) -> SftpEntryKind { if attrs.is_symlink() { SftpEntryKind::Symlink @@ -187,13 +111,6 @@ fn entry_from_attrs(name: &str, attrs: &FileAttributes) -> SftpEntry { } } -// --------------------------------------------------------------------------- -// Transfer job state machine (pure) — tested without any SFTP/window. -// --------------------------------------------------------------------------- - -/// The mutable progress of one transfer job. Terminal states (`Done`/`Error`/ -/// `Cancelled`) latch: once reached, further transitions are ignored, so a late -/// `add_bytes` after cancellation can't resurrect a job or corrupt its status. #[derive(Debug, Clone, PartialEq, Eq)] pub struct JobProgress { pub state: SftpJobState, @@ -262,8 +179,6 @@ impl Default for JobProgress { } } -/// A live/finished transfer job. Progress lives behind a `Mutex` so the transfer -/// task updates it while the GUI polls it. struct Job { id: u64, pane_id: u64, @@ -323,7 +238,6 @@ impl Job { } } - /// True once terminal and past the retention window (safe to prune). fn is_expired(&self) -> bool { matches!( *self.done_at.lock().unwrap(), @@ -332,13 +246,6 @@ impl Job { } } -// --------------------------------------------------------------------------- -// The manager. -// --------------------------------------------------------------------------- - -/// A per-connection SFTP-session cache slot. The inner `tokio::Mutex` serializes -/// opening (so two panes racing to first-use a connection open one session, not -/// two) without serializing *different* connections. struct SessionSlot { inner: tokio::sync::Mutex<Option<CachedSession>>, } @@ -355,7 +262,6 @@ pub struct SftpManager { } impl SftpManager { - /// The process-wide SFTP engine. pub fn global() -> &'static SftpManager { static MANAGER: OnceLock<SftpManager> = OnceLock::new(); MANAGER.get_or_init(|| SftpManager { @@ -365,9 +271,6 @@ impl SftpManager { }) } - // --- sync entry points (called from the server's std threads) ---------- - - /// List a remote directory. Blocks the calling thread on the SSH runtime. pub fn list(&self, conn: &Arc<SshConnection>, path: &str) -> Result<Vec<SftpEntry>, String> { SshManager::global().handle().block_on(async { self.with_session(conn, |sftp| async move { list_dir(&sftp, path).await }) @@ -375,27 +278,6 @@ impl SftpManager { }) } - /// Write `bytes` to `path`, creating or truncating it. Blocks the calling - /// thread on the SSH runtime. - /// - /// For callers that have the bytes in memory and no local file to stream - /// from — the remote-server installer, which downloads a binary and pushes - /// it — so they get the cached session and its retry-once-on-transport- - /// failure behaviour instead of opening a channel of their own per write. - /// - /// Chunked rather than one giant write so a ~6 MB binary is not a single - /// SFTP message, and flushed *and* shut down before returning `Ok`: a - /// server that runs out of disk reports it on the write or the close, and - /// swallowing that would leave a truncated file for the caller to chmod and - /// rename into place as though it were whole. - /// `on_progress` is called with the running total after each chunk lands. - /// It runs on the SSH runtime between writes, so it must not block — the - /// installer's sink just stores the number. - /// - /// Counted after `write_all` rather than before, so the figure is bytes the - /// transport has accepted rather than bytes we intend to send. It still - /// reaches `len` before `flush`/`shutdown` have confirmed anything, which is - /// why a full bar is not the installer's success signal — the `Ok` is. pub fn put_bytes( &self, conn: &Arc<SshConnection>, @@ -424,7 +306,6 @@ impl SftpManager { }) } - /// Run a one-shot filesystem operation. pub fn op(&self, conn: &Arc<SshConnection>, op: &SftpOp) -> SftpOpResult { let result = SshManager::global().handle().block_on(async { self.with_session(conn, |sftp| async move { run_op(&sftp, op).await }) @@ -436,15 +317,11 @@ impl SftpManager { } } - /// Start a background transfer. Returns the new job id immediately; the - /// transfer runs on the SSH runtime and reports progress via `list_jobs`. pub fn start_transfer( &'static self, conn: &Arc<SshConnection>, spec: SftpTransferSpec, ) -> Result<u64, String> { - // Establish the session up-front so an immediate failure (no SFTP) is - // reported synchronously rather than as a phantom job. let sftp = SshManager::global() .handle() .block_on(async { self.session_for(conn).await })?; @@ -468,8 +345,6 @@ impl SftpManager { Ok(id) } - /// Cancel a running job (idempotent). Returns the current progress list for - /// the job's pane so the caller can refresh the tray in one round-trip. pub fn cancel(&self, job_id: u64) -> Vec<SftpJobProgress> { let pane = { let jobs = self.jobs.lock().unwrap(); @@ -486,8 +361,6 @@ impl SftpManager { } } - /// Snapshot the transfer jobs for a pane, pruning expired (long-finished) - /// ones as a side effect so the table stays bounded. pub fn list_jobs(&self, pane_id: u64) -> Vec<SftpJobProgress> { let mut jobs = self.jobs.lock().unwrap(); jobs.retain(|_, job| !job.is_expired()); @@ -500,15 +373,6 @@ impl SftpManager { out } - // --- session cache ----------------------------------------------------- - - /// Run `f` against the pane's cached SFTP session, retrying once with a - /// freshly re-opened session **only** when the first attempt failed for a - /// transport/channel reason (the cached subsystem channel died while the - /// connection lives). A logical SFTP failure — a server status like permission - /// denied or no-such-file — returns directly, never re-opening the session (a - /// retry would just fail identically and waste a round-trip). See - /// [`is_transport_failure`]. async fn with_session<T, F, Fut>(&self, conn: &Arc<SshConnection>, f: F) -> Result<T, String> where F: Fn(Arc<SftpSession>) -> Fut, @@ -526,7 +390,6 @@ impl SftpManager { } } - /// The cached session for `conn`, opening one if absent or stale. async fn session_for(&self, conn: &Arc<SshConnection>) -> Result<Arc<SftpSession>, String> { let slot = { let mut map = self.sessions.lock().unwrap(); @@ -558,27 +421,18 @@ impl SftpManager { } } -/// Whether a stringified SFTP op error looks like a *transport/channel* failure -/// (the subsystem channel died) rather than a logical server status (permission -/// denied, no such file, …). Only the former is worth re-opening the session for. -/// -/// `russh_sftp` renders channel/IO failures with these markers; a server status -/// code renders as `<code>: <message>` and matches none of them — so an unmatched -/// (logical) error is not retried. Conservative by design: an unrecognized error -/// is treated as logical and returned directly. fn is_transport_failure(msg: &str) -> bool { const MARKERS: &[&str] = &[ - "I/O:", // russh_sftp `Error::IO` — the channel stream failed - "Unexpected EOF", // the stream closed mid-message - "Timeout", // no response — the subsystem/channel is wedged + "I/O:", + "Unexpected EOF", + "Timeout", "Unexpected packet", - "SendError", // the channel task's receiver is gone - "RecvError", // the channel task ended before replying + "SendError", + "RecvError", ]; MARKERS.iter().any(|m| msg.contains(m)) } -/// Open a fresh SFTP subsystem channel on `conn` and hand back a session. async fn open_sftp(conn: &Arc<SshConnection>) -> Result<Arc<SftpSession>, String> { let channel = conn .open_session_channel() @@ -594,10 +448,6 @@ async fn open_sftp(conn: &Arc<SshConnection>) -> Result<Arc<SftpSession>, String Ok(Arc::new(sftp)) } -// --------------------------------------------------------------------------- -// Operations. -// --------------------------------------------------------------------------- - async fn list_dir(sftp: &SftpSession, path: &str) -> Result<Vec<SftpEntry>, String> { let read_dir = sftp.read_dir(path).await.map_err(|e| format!("{e}"))?; let mut out = Vec::new(); @@ -609,7 +459,6 @@ async fn list_dir(sftp: &SftpSession, path: &str) -> Result<Vec<SftpEntry>, Stri let attrs = entry.metadata(); let mut e = entry_from_attrs(&name, &attrs); if e.kind == SftpEntryKind::Symlink { - // Follow-stat the target so the GUI knows navigate-vs-download. if let Ok(target) = sftp.metadata(remote_join(path, &name)).await { e.target_is_dir = target.is_dir(); } @@ -635,9 +484,6 @@ async fn run_op(sftp: &SftpSession, op: &SftpOp) -> Result<SftpOpResult, String> SftpOpResult::Done } SftpOp::CreateFile { path } => { - // EXCLUDE => fail rather than clobber an existing file. The OPEN - // itself creates the (empty) file server-side; flush/shutdown closes - // the handle cleanly. let flags = OpenFlags::WRITE | OpenFlags::CREATE | OpenFlags::EXCLUDE; let mut file = sftp .open_with_flags(path.clone(), flags) @@ -658,9 +504,6 @@ async fn run_op(sftp: &SftpSession, op: &SftpOp) -> Result<SftpOpResult, String> SftpOpResult::Done } SftpOp::Rename { from, to } => { - // Plain rename, no overwrite: a user rename onto an existing name - // must fail, not silently delete the target (`rename_over` is for - // the upload temp-swap only, where we own both paths). sftp.rename(from.clone(), to.clone()) .await .map_err(|e| format!("rename failed: {e}"))?; @@ -691,9 +534,6 @@ async fn run_op(sftp: &SftpSession, op: &SftpOp) -> Result<SftpOpResult, String> }) } -/// Rename `from` over `to`, tolerating a server that refuses to overwrite an -/// existing target: remove the target first, then retry. (See the module note on -/// posix-rename.) async fn rename_over(sftp: &SftpSession, from: &str, to: &str) -> Result<(), String> { if sftp.rename(from.to_string(), to.to_string()).await.is_ok() { return Ok(()); @@ -704,14 +544,7 @@ async fn rename_over(sftp: &SftpSession, from: &str, to: &str) -> Result<(), Str .map_err(|e| format!("rename failed: {e}")) } -/// Daemon-side recursive directory delete: remove children (files and links -/// directly; subdirectories by recursion) then the directory itself. A -/// symlink child is unlinked, never followed. async fn remove_dir_recursive(sftp: &SftpSession, path: &str) -> Result<(), String> { - // Explicit worklist to avoid async recursion. Each dir is visited twice: - // first to enqueue its children, then (after them) to remove the now-empty - // directory. We push a directory's own removal marker before its children so - // that, popping LIFO, children are removed first. enum Step { Enter(String), RemoveDir(String), @@ -732,12 +565,9 @@ async fn remove_dir_recursive(sftp: &SftpSession, path: &str) -> Result<(), Stri } let child = remote_join(&dir, &name); let attrs = entry.metadata(); - // Only a real directory recurses; a symlink (even to a dir) is - // unlinked as a file so we never delete through it. if attrs.is_dir() && !attrs.is_symlink() { stack.push(Step::Enter(child)); } else { - // Best-effort: a child already gone is fine. let _ = sftp.remove_file(child).await; } } @@ -750,10 +580,6 @@ async fn remove_dir_recursive(sftp: &SftpSession, path: &str) -> Result<(), Stri Ok(()) } -// --------------------------------------------------------------------------- -// Transfers. -// --------------------------------------------------------------------------- - async fn run_transfer(sftp: Arc<SftpSession>, spec: SftpTransferSpec, job: Arc<Job>) { let result = match spec.kind { SftpTransferKind::Download => download(&sftp, &spec, &job).await, @@ -766,20 +592,14 @@ async fn run_transfer(sftp: Arc<SftpSession>, spec: SftpTransferSpec, job: Arc<J } } -/// A cancelled job surfaces as an `Err` that `run_transfer` maps to `Cancelled`. fn cancelled() -> String { "cancelled".to_string() } async fn download(sftp: &SftpSession, spec: &SftpTransferSpec, job: &Job) -> Result<(), String> { - // Size pre-pass (recursive) so the tray has a denominator. let total = remote_size(sftp, &spec.remote, spec.recursive, job).await?; job.set_total(total); - // The root is stat'ed (following a symlink deliberately — the user picked - // it); children carry their lstat-style attrs from the directory listing so - // symlinks are recognized and skipped, never followed: following them would - // loop forever on a cyclic link and copy whole trees through e.g. `-> /`. let root_attrs = sftp .metadata(spec.remote.clone()) .await @@ -805,9 +625,6 @@ async fn download(sftp: &SftpSession, spec: &SftpTransferSpec, job: &Job) -> Res if name == "." || name == ".." { continue; } - // Guard against a hostile server returning a traversing name - // (`..`, `a/b`, `/abs`): it would become a local path component - // via `lpath.join`, escaping the destination. Skip unsafe names. if !safe_local_name(&name) { log::warn!( "sftp download: skipping remote entry with unsafe name {name:?} under {rpath}" @@ -839,9 +656,6 @@ async fn download_file( if let Some(parent) = lpath.parent() { let _ = tokio::fs::create_dir_all(parent).await; } - // Download to a per-file temp in the destination dir, then rename over the - // target on success — mirroring the upload temp+rename discipline so a failed - // or cancelled download never truncates a pre-existing local file in place. let temp = download_temp_path(lpath); let result: Result<(), String> = async { let mut remote = sftp @@ -866,8 +680,6 @@ async fn download_file( .map_err(|e| format!("write local: {e}"))?; job.add_bytes(n as u64); } - // A failed flush means the temp is incomplete (e.g. disk full) — it must - // abort here, before the rename commits the temp over a good target. local .flush() .await @@ -877,16 +689,13 @@ async fn download_file( .await; if let Err(e) = result { - // Best effort: drop the partial temp, leaving any pre-existing target intact. let _ = tokio::fs::remove_file(&temp).await; return Err(e); } - // Swap the completed temp over the target. if let Err(e) = tokio::fs::rename(&temp, lpath).await { let _ = tokio::fs::remove_file(&temp).await; return Err(format!("rename into {}: {e}", lpath.display())); } - // Preserve the executable/permission bits where sane (unix only, low 12 bits). preserve_mode(lpath, mode); Ok(()) } @@ -895,9 +704,6 @@ async fn upload(sftp: &SftpSession, spec: &SftpTransferSpec, job: &Job) -> Resul let total = local_size(&spec.local, spec.recursive, job).await?; job.set_total(total); - // Mirrors the download walker's symlink policy: the root is stat'ed - // (following a symlink deliberately), children are classified by their - // lstat-style file type and symlinks are skipped, never followed. let root_is_dir = tokio::fs::metadata(&spec.local) .await .map_err(|e| format!("stat {}: {e}", spec.local.display()))? @@ -911,7 +717,6 @@ async fn upload(sftp: &SftpSession, spec: &SftpTransferSpec, job: &Job) -> Resul if !spec.recursive { return Err("local path is a directory (enable recursive)".to_string()); } - // Create the remote dir (ignore "already exists"). let _ = sftp.create_dir(rpath.clone()).await; let mut read_dir = tokio::fs::read_dir(&lpath) .await @@ -971,8 +776,6 @@ async fn upload_file( .map_err(|e| format!("write remote: {e}"))?; job.add_bytes(n as u64); } - // Surface late write errors before the rename commits the temp over the - // target; a truncated temp must fail the transfer, not replace the file. remote .flush() .await @@ -986,11 +789,9 @@ async fn upload_file( .await; if let Err(e) = result { - // Clean up the partial temp file, best effort. let _ = sftp.remove_file(temp.clone()).await; return Err(e); } - // Swap the temp over the target. if let Err(e) = rename_over(sftp, &temp, rpath).await { let _ = sftp.remove_file(temp).await; return Err(e); @@ -998,7 +799,6 @@ async fn upload_file( Ok(()) } -/// Recursively sum remote file sizes (files only). Cancellation short-circuits. async fn remote_size( sftp: &SftpSession, root: &str, @@ -1025,8 +825,6 @@ async fn remote_size( if name == "." || name == ".." { continue; } - // Skip the same unsafe names and symlinks the download walker - // skips so the size denominator matches what is transferred. if !safe_local_name(&name) { continue; } @@ -1044,7 +842,6 @@ async fn remote_size( Ok(total) } -/// Recursively sum local file sizes (files only). async fn local_size(root: &Path, recursive: bool, job: &Job) -> Result<u64, String> { let mut total = 0u64; let mut stack = vec![root.to_path_buf()]; @@ -1052,9 +849,6 @@ async fn local_size(root: &Path, recursive: bool, job: &Job) -> Result<u64, Stri if job.is_cancelled() { return Err(cancelled()); } - // Root uses stat (a root symlink is followed deliberately); children - // below use their lstat file type, so links are counted at zero and - // never followed — matching the upload walker. let meta = match tokio::fs::metadata(&path).await { Ok(m) => m, Err(_) => continue, @@ -1079,7 +873,6 @@ async fn local_size(root: &Path, recursive: bool, job: &Job) -> Result<u64, Stri Ok(total) } -/// Apply the sane low permission bits of a downloaded file locally (unix only). #[cfg(unix)] fn preserve_mode(path: &Path, mode: Option<u32>) { use std::os::unix::fs::PermissionsExt; @@ -1103,9 +896,7 @@ mod tests { assert_eq!(remote_join("/", "file"), "/file"); assert_eq!(remote_join("", "file"), "/file"); assert_eq!(remote_join("/home/deploy", "src"), "/home/deploy/src"); - // Trailing/leading slashes are normalized to a single separator. assert_eq!(remote_join("/home/deploy/", "/src"), "/home/deploy/src"); - // Unicode names survive intact. assert_eq!(remote_join("/家", "文件"), "/家/文件"); } @@ -1115,7 +906,6 @@ mod tests { assert_eq!(remote_parent("/home"), "/"); assert_eq!(remote_parent("/"), "/"); assert_eq!(remote_parent(""), "/"); - // Trailing slash ignored. assert_eq!(remote_parent("/a/b/"), "/a"); assert_eq!(remote_parent("/项目/子"), "/项目"); } @@ -1134,13 +924,11 @@ mod tests { let b = upload_temp_name("/dir/file.txt"); assert!(a.starts_with("/dir/file.txt.tty7-upload-")); assert!(b.starts_with("/dir/file.txt.tty7-upload-")); - // Two temp names for the same target must differ (counter component). assert_ne!(a, b); } #[test] fn safe_local_name_rejects_traversal_and_accepts_plain_names() { - // Rejected: empty, dot, dotdot, embedded/leading separators, absolute. assert!(!safe_local_name("")); assert!(!safe_local_name(".")); assert!(!safe_local_name("..")); @@ -1148,7 +936,6 @@ mod tests { assert!(!safe_local_name("/abs")); assert!(!safe_local_name("../../.ssh/authorized_keys")); assert!(!safe_local_name("a\\b")); - // Accepted: ordinary single components, including Unicode and dotted names. assert!(safe_local_name("file.txt")); assert!(safe_local_name("项目")); assert!(safe_local_name("a.tar.gz")); @@ -1160,7 +947,6 @@ mod tests { let target = Path::new("/dest/dir/file.bin"); let a = download_temp_path(target); let b = download_temp_path(target); - // Same directory as the target (so the finishing rename is same-filesystem). assert_eq!(a.parent(), target.parent()); assert!( a.file_name() @@ -1168,19 +954,16 @@ mod tests { .to_string_lossy() .starts_with("file.bin.tty7-download-") ); - // Two temps for the same target differ (counter component). assert_ne!(a, b); } #[test] fn is_transport_failure_distinguishes_channel_from_logical_errors() { - // Transport/channel failures → retry. assert!(is_transport_failure("I/O: broken pipe")); assert!(is_transport_failure("rename failed: I/O: connection reset")); assert!(is_transport_failure("Unexpected EOF on stream")); assert!(is_transport_failure("Timeout")); assert!(is_transport_failure("SendError: channel closed")); - // Logical server statuses → no retry. assert!(!is_transport_failure("3: Permission denied")); assert!(!is_transport_failure("2: No such file or directory")); assert!(!is_transport_failure( @@ -1190,7 +973,6 @@ mod tests { #[test] fn classify_prefers_symlink_over_regular_bit() { - // S_IFLNK carries the S_IFREG bit too; symlink must win. let mut link = FileAttributes::empty(); link.permissions = Some(0o120777); assert_eq!(classify(&link), SftpEntryKind::Symlink); @@ -1203,7 +985,6 @@ mod tests { file.permissions = Some(0o100644); assert_eq!(classify(&file), SftpEntryKind::File); - // Unknown permissions default to file. assert_eq!(classify(&FileAttributes::empty()), SftpEntryKind::File); } @@ -1238,7 +1019,6 @@ mod tests { p.finish(); assert_eq!(p.state, SftpJobState::Done); - // Terminal state latches: later transitions are ignored. p.add_bytes(999); p.fail("late error"); p.cancel(); diff --git a/crates/tty7-core/src/daemon/ssh/workspace.rs b/crates/tty7-core/src/daemon/ssh/workspace.rs index a16c94ad..e022de4e 100644 --- a/crates/tty7-core/src/daemon/ssh/workspace.rs +++ b/crates/tty7-core/src/daemon/ssh/workspace.rs @@ -1,56 +1,16 @@ -//! Workspace-scoped control requests (M7). -//! -//! A *remote workspace* has no pane on this daemon: its -//! panes live on the remote `tty7-server` and reach it through a routed byte -//! pipe. What this side owns is the [`SshConnection`] that pipe rides — the same -//! connection an SSH pane to that host would have used, deduplicated by -//! [`ConnectionKey`]. -//! -//! Everything a user wants from that connection — a port forward behind a -//! ⌘-clicked `localhost:3000`, an SFTP download dragged out to Finder — is -//! therefore addressable, just not by `pane_id`. This module is the one place -//! that translates "which workspace" into "which connection", and it is -//! deliberately the *only* new entry point: [`handle`] answers a whole -//! [`WorkspaceRequest`] with a ready-to-send [`DaemonMsg`], so the daemon's -//! dispatch grows one arm rather than nine. -//! -//! **It never connects.** [`SshManager::existing_connection`] is a lookup: a -//! workspace request arrives on a short-lived control connection with nowhere to -//! put an auth prompt, so "no connection" is reported as an error the GUI can -//! show rather than a silent connect attempt that would hang on a passphrase. - use crate::core::session::WorkspaceId; use crate::daemon::protocol::{DaemonMsg, WorkspaceOp, WorkspaceRequest}; use super::SshManager; use super::sftp::SftpManager; -/// The bucket a workspace's SFTP transfer jobs are filed under. -/// -/// [`SftpManager`] keys jobs by `pane_id`, and a remote workspace has no pane -/// here to lend it one. Deriving the key from the workspace instead of using the -/// requesting pane keeps a running download visible after the user switches the -/// Files panel to another pane — the transfer belongs to the machine, not to -/// whichever tab happened to start it. -/// -/// The top bit is set so the synthetic key cannot collide with a real pane id: -/// pane ids come from a counter that starts at 1, so a collision would need 2^63 -/// panes in one daemon. pub fn job_key(workspace: WorkspaceId) -> u64 { workspace.element_key() | (1 << 63) } -/// Answer one [`WorkspaceRequest`]. -/// -/// Every failure — an unknown/disconnected workspace, a refused bind, an SFTP -/// error — comes back as [`DaemonMsg::Error`] with a sentence the GUI can show -/// verbatim, because the caller has no other channel to explain itself on. pub fn handle(req: &WorkspaceRequest) -> DaemonMsg { let mgr = SshManager::global(); let Some(conn) = mgr.existing_connection(&req.spec) else { - // The workspace is not connected (or is mid-reconnect). Naming the host - // matters: with several windows open the user needs to know *which* one - // went away. return DaemonMsg::Error(format!( "workspace is not connected to {}@{}:{} — reconnect the window and try again", req.spec.user, req.spec.host, req.spec.port @@ -84,9 +44,6 @@ pub fn handle(req: &WorkspaceRequest) -> DaemonMsg { }, WorkspaceOp::SftpOp { op } => DaemonMsg::SftpOpResult(SftpManager::global().op(&conn, op)), WorkspaceOp::SftpTransferStart { spec } => { - // The caller's `pane_id` is overridden rather than trusted: a - // workspace's jobs must land in the workspace's bucket, or - // `SftpTransferList` below would not find them again. let mut spec = spec.clone(); spec.pane_id = job_key(ws); match SftpManager::global().start_transfer(&conn, spec) { @@ -104,27 +61,18 @@ pub fn handle(req: &WorkspaceRequest) -> DaemonMsg { mod tests { use super::*; - /// The synthetic job bucket is stable for a workspace, distinct between - /// workspaces, and out of reach of any real pane id. #[test] fn job_key_is_stable_distinct_and_out_of_pane_range() { let a = WorkspaceId::new(); let b = WorkspaceId::new(); assert_eq!(job_key(a), job_key(a), "stable across calls"); assert_ne!(job_key(a), job_key(b)); - // Real pane ids come from a counter starting at 1; none of them has the - // top bit set, so the two spaces cannot overlap. assert!(job_key(a) >= 1 << 63); assert!(job_key(b) >= 1 << 63); } - /// A request naming a host this daemon has no connection to is refused with a - /// message that names the host — never by silently connecting (which would - /// need credentials this path cannot prompt for). #[test] fn request_without_a_live_connection_is_refused_by_name() { - // Built through serde so the test states only the three fields it cares - // about; every other field of `NativeSshSpec` has a serde default. let spec: crate::daemon::protocol::NativeSshSpec = serde_json::from_str( r#"{"host":"nowhere.invalid","port":2222,"user":"someone","auth_mode":"auto"}"#, ) diff --git a/crates/tty7-core/src/daemon/transport.rs b/crates/tty7-core/src/daemon/transport.rs index 56ac024e..153f2d5b 100644 --- a/crates/tty7-core/src/daemon/transport.rs +++ b/crates/tty7-core/src/daemon/transport.rs @@ -1,37 +1,3 @@ -//! Cross-platform IPC transport for the GUI ⇄ daemon connection. -//! -//! The daemon and the GUI talk over a local, machine-private byte stream. Which -//! kind of stream depends on the platform, but both sides only ever see a type -//! that is `Read + Write + try_clone` — so `server`, `spawn`, and -//! `terminal::remote` share one code path and never mention the concrete type. -//! -//! - **Unix**: a Unix-domain socket at `<config>/daemon.sock`. This is the -//! original design, kept verbatim — the socket file's presence on disk doubles -//! as the "is a daemon here?" marker, and `bind` recreates it. -//! - **Windows**: a loopback `TcpListener` on `127.0.0.1:<port>` (an OS-assigned -//! ephemeral port). Windows has no first-class Unix sockets, and the -//! `interprocess` named-pipe route can't cleanly `try_clone` a blocking duplex -//! handle, which our thread-per-connection model needs. Loopback TCP has the -//! exact `try_clone` + blocking semantics of `UnixStream`, so the rest of the -//! daemon is unchanged. The chosen port is written to `<config>/daemon.port` -//! so the GUI can find a daemon it didn't spawn; that file is the Windows -//! analogue of the socket file (its presence is the "endpoint exists" marker). -//! Loopback is reachable by *any* local process, not just the same user — so, -//! unlike a Unix socket, the port alone isn't an access boundary. The daemon -//! closes that gap with a token: `bind` writes a random 256-bit token into the -//! (user-private) port file, `connect` presents it as a preamble, and -//! `authenticate` rejects any connection that doesn't match — so only a process -//! that could read the user-private file gets in. See [`imp_windows`]. -//! -//! One daemon serves two dialects on two listeners — panes and control — which -//! on Unix are two socket files and here are two port files, each with its own -//! ephemeral port and its own token (`bind_endpoint`). The control listener's is -//! `control.port`; [`crate::host::server`] owns it, since that is where the -//! dialect lives. -//! -//! All endpoint state lives under the (config-dir-aware) config directory, so -//! `--config-dir` / `cargo dev` isolation reaches the daemon on every platform. - use std::io; use crate::core::config; @@ -47,20 +13,11 @@ mod imp_unix { use std::os::unix::net::{UnixListener, UnixStream}; use std::path::{Path, PathBuf}; - /// The connection stream both sides read/write framed messages over. pub type Stream = UnixStream; - /// The daemon's accept side. pub type Listener = UnixListener; - /// `sockaddr_un.sun_path` caps socket paths at 104 bytes on macOS (108 on - /// Linux), NUL included — `bind`/`connect` reject anything longer, so stay - /// safely below the smaller limit. pub(super) const MAX_SOCKET_PATH_BYTES: usize = 100; - /// Deterministic 64-bit FNV-1a. Not `DefaultHasher`: the GUI and the daemon - /// can be different builds of tty7 (daemon survives app upgrades), so the - /// fallback socket path must hash identically across compiler/std versions - /// or an upgraded GUI would lose a live daemon. fn fnv1a64(bytes: &[u8]) -> u64 { let mut h: u64 = 0xcbf2_9ce4_8422_2325; for &b in bytes { @@ -70,13 +27,6 @@ mod imp_unix { h } - /// The socket path serving `config_dir`: `<config_dir>/daemon.sock` whenever - /// that fits in `sun_path`, else a short per-user path keyed by a stable - /// hash of the config dir. Without the fallback, a long `--config-dir` made - /// bind/connect fail with "path must be shorter than SUN_LEN" and the GUI - /// died at startup. Distinct config dirs still get distinct daemons (the - /// hash keys the endpoint), and both processes derive the same path because - /// the GUI forwards its *resolved* config dir to the daemon it spawns. pub(super) fn socket_path_for(config_dir: &Path) -> PathBuf { use std::os::unix::ffi::OsStrExt as _; let inline = config_dir.join("daemon.sock"); @@ -91,22 +41,6 @@ mod imp_unix { pick_fallback_socket(xdg.as_deref(), &std::env::temp_dir(), &name) } - /// The fallback path, given the two candidate bases. Split out from - /// [`socket_path_for`] so it is testable without mutating the environment - /// (which is `unsafe` in edition 2024 and races every other test). - /// - /// Preference order is unchanged — `$XDG_RUNTIME_DIR` (user-private, 0700, - /// the norm on Linux) before the OS temp dir (per-user on macOS) — so every - /// path that works today is returned byte-for-byte as before and a live - /// daemon is never orphaned. What is new is the length check: the "short" - /// hashed name is only short *relative to the config dir*, and a deep - /// `$XDG_RUNTIME_DIR` overruns `sun_path` just as readily. Without this, - /// `bind` failed with "path must be shorter than SUN_LEN" and the daemon - /// died at startup with no hint that the runtime dir was the cause. - /// - /// If neither base fits, return the preferred one anyway: `bind` then - /// reports the real path it rejected, which is a far better diagnostic than - /// silently landing somewhere the peer will not look. pub(super) fn pick_fallback_socket(xdg: Option<&Path>, temp: &Path, name: &str) -> PathBuf { use std::os::unix::ffi::OsStrExt as _; let fits = |p: &PathBuf| p.as_os_str().as_bytes().len() <= MAX_SOCKET_PATH_BYTES; @@ -121,14 +55,10 @@ mod imp_unix { preferred } - /// Path of the Unix-domain socket for this process's config dir. `None` only - /// when the config dir can't be resolved (no `$HOME`). fn socket_path() -> Option<PathBuf> { Some(socket_path_for(&config::config_dir_path()?)) } - /// Try to connect to the daemon. `Err` means "nobody home" (the caller treats - /// any error as "not running"). pub fn connect() -> io::Result<Stream> { let path = socket_path().ok_or_else(|| { io::Error::other("could not resolve daemon socket path (no config dir)") @@ -138,17 +68,10 @@ mod imp_unix { Ok(stream) } - /// Grow the kernel socket buffers to match the daemon writer's 256 KiB - /// coalesced Output frames. macOS defaults Unix-socket buffers to 8 KiB, - /// which chops a full-drain stream (100+ MB/s) into ~8 KiB reads — tens of - /// thousands of extra syscalls and cross-process wakeups per second, and a - /// stall point the PTY reader's backpressure gate then amplifies. Best - /// effort: a refused size just keeps the platform default. pub fn tune(stream: &Stream) { use std::os::unix::io::AsRawFd as _; let size: libc::c_int = 256 * 1024; for opt in [libc::SO_SNDBUF, libc::SO_RCVBUF] { - // SAFETY: plain setsockopt on a valid owned fd with a c_int payload. unsafe { libc::setsockopt( stream.as_raw_fd(), @@ -161,30 +84,21 @@ mod imp_unix { } } - /// Daemon-side connection authentication — a no-op on Unix. The socket lives in - /// the user-private config dir (or `$XDG_RUNTIME_DIR`, 0700), so filesystem - /// permissions already restrict `connect` to the same user; there's nothing to - /// verify. Mirrors the Windows signature so `server` calls it unconditionally. #[inline] pub fn authenticate(_stream: &mut Stream) -> io::Result<()> { Ok(()) } - /// Whether the endpoint marker exists on disk (a live *or* stale socket file). pub fn endpoint_exists() -> bool { socket_path().is_some_and(|p| p.exists()) } - /// Remove a stale endpoint marker so a fresh `bind` can recreate it. Best - /// effort: a missing file is fine. pub fn remove_stale_endpoint() { if let Some(path) = socket_path() { let _ = std::fs::remove_file(path); } } - /// Bind the listener (daemon side). Ensures the config dir exists first; the - /// caller is responsible for having cleared any stale endpoint. pub fn bind() -> anyhow::Result<Listener> { use std::os::unix::fs::PermissionsExt as _; let path = socket_path().ok_or_else(|| { @@ -192,12 +106,6 @@ mod imp_unix { })?; if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); - // The socket now carries `NativeSshSpec` secrets, so it must be reachable - // only by this user. Tighten the config dir to 0700 — but only when the - // socket lives *in* the config dir (tty7 owns it). The overlong-path - // fallback puts the socket directly under a shared base ($XDG_RUNTIME_DIR - // or the OS temp dir), which we must never chmod; the 0600 socket file - // below is the boundary there. Best effort: log and continue on failure. let owns_parent = config::config_dir_path().is_some_and(|c| c.as_path() == parent); if owns_parent { if let Err(e) = @@ -212,16 +120,12 @@ mod imp_unix { } let listener = UnixListener::bind(&path) .map_err(|e| anyhow::anyhow!("bind {} failed: {}", path.display(), e))?; - // Restrict the socket file to the owner: on Unix, connecting requires write - // permission on the socket node, so 0600 keeps a co-local user out — the - // access boundary now that the socket conveys cleartext SSH secrets. if let Err(e) = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)) { log::warn!("could not chmod 0600 daemon socket {}: {e}", path.display()); } Ok(listener) } - /// A human-readable description of the endpoint, for log messages. pub fn endpoint_display() -> String { socket_path() .map(|p| p.display().to_string()) @@ -234,20 +138,15 @@ mod tests { use super::*; use std::path::PathBuf; - /// Pin the process config dir so the socket lives under a temp dir, never the - /// real `~/.config`. First-call-wins; every IO test computes the same path. fn pin_config_dir() { let dir = std::env::temp_dir().join(format!("tty7-covtest-{}", std::process::id())); std::fs::create_dir_all(&dir).ok(); config::set_config_dir(dir); } - /// One test drives the whole endpoint lifecycle so the shared `daemon.sock` - /// file isn't raced by parallel tests: clean → bind → exists/connect → remove. #[test] fn endpoint_lifecycle_bind_connect_and_clear() { pin_config_dir(); - // Start from a clean slate (a prior run may have left a stale socket). remove_stale_endpoint(); assert!(!endpoint_exists(), "no endpoint before bind"); @@ -258,26 +157,19 @@ mod tests { "display names the socket file" ); - // A client can connect while the listener is alive. let _client = connect().expect("connect to the live listener"); drop(listener); - // The socket file lingers after the listener drops; clearing it makes the - // endpoint look absent again (the stale-takeover path in `run`). remove_stale_endpoint(); assert!(!endpoint_exists(), "endpoint cleared after removal"); } - /// A short config dir keeps the original `<config>/daemon.sock` layout — - /// existing daemons must stay reachable across this change. #[test] fn socket_path_stays_in_config_dir_when_it_fits() { let dir = std::path::PathBuf::from("/tmp/tty7-short"); assert_eq!(imp_unix::socket_path_for(&dir), dir.join("daemon.sock")); } - /// An overlong config dir (the SUN_LEN panic regression) falls back to a - /// short path that is deterministic and still keyed to the config dir. #[test] fn socket_path_falls_back_when_config_dir_is_too_long() { use std::os::unix::ffi::OsStrExt as _; @@ -302,12 +194,9 @@ mod tests { ); } - /// End-to-end on the OS: the fallback path actually binds and accepts a - /// connection (this is exactly what failed with SUN_LEN before). #[test] fn fallback_socket_binds_and_connects() { use std::os::unix::net::{UnixListener, UnixStream}; - // Pid-keyed so concurrent `cargo test` processes don't share a path. let long_dir = std::env::temp_dir().join(format!("{}-{}", "x".repeat(120), std::process::id())); let path = imp_unix::socket_path_for(&long_dir); @@ -324,10 +213,6 @@ mod tests { let _ = std::fs::remove_file(&path); } - /// A long `$XDG_RUNTIME_DIR` must not produce an over-long fallback. The - /// hashed name is short relative to the *config dir*, not in absolute - /// terms, so preferring the runtime dir unconditionally overran `sun_path` - /// and killed the daemon at `bind` with no hint at the cause. #[test] fn a_long_runtime_dir_falls_through_to_the_temp_dir() { let name = "tty7-0123456789abcdef.sock"; @@ -337,8 +222,6 @@ mod tests { let picked = imp_unix::pick_fallback_socket(Some(&long_xdg), &temp, name); assert_eq!(picked, temp.join(name), "falls through to the temp dir"); - // The preference itself is untouched when the runtime dir does fit — - // changing that would orphan every live daemon on a normal machine. let short_xdg = PathBuf::from("/run/user/1000"); assert_eq!( imp_unix::pick_fallback_socket(Some(&short_xdg), &temp, name), @@ -352,8 +235,6 @@ mod tests { ); } - /// Neither base fits: return the preferred one so `bind` names the path it - /// actually rejected, rather than silently landing where no peer looks. #[test] fn an_unusable_pair_of_bases_still_reports_the_preferred_path() { let name = "tty7-0123456789abcdef.sock"; @@ -374,41 +255,22 @@ mod imp_windows { use std::path::PathBuf; use std::sync::OnceLock; - /// The connection stream both sides read/write framed messages over. pub type Stream = TcpStream; - /// The daemon's accept side. pub type Listener = TcpListener; - /// Length of the per-daemon auth token, in bytes. 256 bits from the OS CSPRNG: - /// unguessable without reading the (user-private) port file, so possessing it - /// proves the connecting process runs as the same user. pub const TOKEN_LEN: usize = 32; pub type Token = [u8; TOKEN_LEN]; - /// The pane dialect's endpoint marker. - /// - /// Named, because one daemon serves two dialects on two listeners — the - /// same shape it has on Unix, where they are two socket files — and each - /// records its own port and mints its own token. See - /// [`bind_endpoint`]. const PANE_PORT_FILE: &str = "daemon.port"; - /// This daemon's auth token, minted once at [`bind`] and checked by - /// [`authenticate`] on every accepted connection. A process global because the - /// listener and the per-connection auth check live in the same daemon process - /// but don't share a handle; the client learns the token from the port file - /// instead. Set exactly once per daemon lifetime. static DAEMON_TOKEN: OnceLock<Token> = OnceLock::new(); - /// Mint a fresh 256-bit token from the OS CSPRNG. Panics only if the OS RNG is - /// unavailable, which on Windows means the system is too broken to run. fn make_token() -> Token { let mut token = [0u8; TOKEN_LEN]; getrandom::fill(&mut token).expect("OS RNG (BCryptGenRandom) unavailable"); token } - /// Lowercase-hex encode a token for the (text) port file. fn encode_token(token: &Token) -> String { let mut s = String::with_capacity(TOKEN_LEN * 2); for b in token { @@ -418,7 +280,6 @@ mod imp_windows { s } - /// Decode a hex token; `None` unless it's exactly `TOKEN_LEN` bytes of valid hex. fn decode_token(s: &str) -> Option<Token> { let s = s.trim(); if s.len() != TOKEN_LEN * 2 { @@ -434,9 +295,6 @@ mod imp_windows { Some(token) } - /// The port file records `<port>\n<token-hex>`: the loopback port the GUI - /// connects to, plus the token it must present. Parse both back; `None` if the - /// file is malformed (a truncated write, or an old single-line file). fn parse_port_file(contents: &str) -> Option<(u16, Token)> { let mut lines = contents.lines(); let port = lines.next()?.trim().parse::<u16>().ok()?; @@ -444,9 +302,6 @@ mod imp_windows { Some((port, token)) } - /// Constant-time token comparison: fold every byte's difference into one - /// accumulator so the check can't leak how many leading bytes matched. A local - /// timing side-channel is far-fetched over loopback, but the guard is free. fn tokens_match(a: &Token, b: &Token) -> bool { let mut diff = 0u8; for i in 0..TOKEN_LEN { @@ -455,20 +310,14 @@ mod imp_windows { diff == 0 } - /// Path of the port file recording the daemon's chosen loopback port + token. - /// This is the Windows analogue of the Unix socket file: its presence is the - /// "endpoint exists" marker, and — being under the user-private config dir — - /// its contents (the token) are readable only by the same user. fn port_path() -> Option<PathBuf> { port_path_named(PANE_PORT_FILE) } - /// [`port_path`] for any of this daemon's endpoints. pub fn port_path_named(file: &str) -> Option<PathBuf> { config::config_path(file) } - /// Read the recorded loopback port + token, if the port file exists and parses. fn read_port_file() -> Option<(u16, Token)> { read_port_file_named(PANE_PORT_FILE) } @@ -483,30 +332,16 @@ mod imp_windows { SocketAddr::from((Ipv4Addr::LOCALHOST, port)) } - /// Try to connect to the daemon. `Err` (including a missing/zero port or a - /// malformed file) means "nobody home" — the caller treats any error as "not - /// running". On success we send the auth token as the connection preamble, - /// before any `ClientMsg`, so the daemon accepts us. pub fn connect() -> io::Result<Stream> { let (port, token) = read_port_file() .filter(|(p, _)| *p != 0) .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no daemon port file"))?; let mut stream = TcpStream::connect(loopback(port))?; tune(&stream); - // Present the token first thing; the daemon reads exactly these bytes in - // `authenticate` before it looks for a `ClientMsg`. stream.write_all(&token)?; Ok(stream) } - /// Daemon side: read and verify the connection preamble against this daemon's - /// token before any message is processed. Any process on the machine can open - /// a loopback TCP connection, but only one that read the user-private port file - /// knows the token — so this is what makes the loopback endpoint per-user - /// private, the property a Unix socket gets for free from filesystem perms. - /// - /// A short read (peer hung up), a mismatch, or an uninitialized token all fail - /// the connection; the caller drops it. pub fn authenticate(stream: &mut Stream) -> io::Result<()> { let expected = DAEMON_TOKEN .get() @@ -514,16 +349,10 @@ mod imp_windows { authenticate_with(stream, expected) } - /// [`authenticate`] for a connection on one of this daemon's *other* - /// endpoints, whose token its listener holds rather than reading from the - /// process global. pub fn check_endpoint_token(stream: &mut Stream, expected: &Token) -> io::Result<()> { authenticate_with(stream, expected) } - /// Pure core of [`authenticate`]: read a token off `reader` and compare it to - /// `expected`. Split out so the handshake is testable without a live daemon or - /// the process-global token. fn authenticate_with(reader: &mut impl Read, expected: &Token) -> io::Result<()> { let mut got = [0u8; TOKEN_LEN]; reader.read_exact(&mut got)?; @@ -537,44 +366,26 @@ mod imp_windows { } } - /// Loopback-TCP analogue of the Unix `tune`: disable Nagle so small framed - /// messages (keystrokes, resizes) aren't held back waiting for an ACK. - /// Buffer sizes are left at the Windows defaults (already 64 KiB). Best - /// effort. pub fn tune(stream: &Stream) { let _ = stream.set_nodelay(true); } - /// Whether the endpoint marker (port file) exists on disk. pub fn endpoint_exists() -> bool { port_path().is_some_and(|p| p.exists()) } - /// Remove a stale endpoint marker (the port file). Best effort. pub fn remove_stale_endpoint() { if let Some(path) = port_path() { let _ = std::fs::remove_file(path); } } - /// Bind a loopback listener on an OS-assigned port and record that port — plus - /// this daemon's freshly-minted auth token — in the port file so the GUI can - /// find *and* authenticate to it. Ensures the config dir exists first. pub fn bind() -> anyhow::Result<Listener> { - // Mint the pane dialect's token once for this daemon's lifetime; - // `authenticate` checks against the same value. let token = *DAEMON_TOKEN.get_or_init(make_token); let (listener, _) = bind_named(PANE_PORT_FILE, token)?; Ok(listener) } - /// [`bind`] for a second dialect in this same daemon: its own ephemeral - /// port, its own token, its own marker file beside `daemon.port`. - /// - /// Answers the token as well as the listener, because a second endpoint has - /// nowhere process-global to keep it — its accept loop holds it and checks - /// each connection with [`check_endpoint_token`]. One token per endpoint, so - /// a client that learned one cannot present it to the other. pub fn bind_endpoint(file: &str) -> anyhow::Result<(Listener, Token)> { bind_named(file, make_token()) } @@ -585,25 +396,18 @@ mod imp_windows { if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); } - // Port 0 lets the OS pick a free ephemeral port; we read it back so the - // GUI connects to the actual bound port. let listener = TcpListener::bind(loopback(0)) .map_err(|e| anyhow::anyhow!("bind 127.0.0.1:0 failed: {e}"))?; let port = listener .local_addr() .map_err(|e| anyhow::anyhow!("could not read bound port: {e}"))? .port(); - // Written to the marker file so a client that can read it (same user) - // can present it back. let contents = format!("{port}\n{}", encode_token(&token)); std::fs::write(&path, contents) .map_err(|e| anyhow::anyhow!("could not write port file {}: {e}", path.display()))?; Ok((listener, token)) } - /// [`connect`] to one of the daemon's other endpoints, presenting the token - /// its marker file records. `NotFound` means nothing is listening there — the - /// same "nobody home" every caller treats as "not running". pub fn connect_endpoint(file: &str) -> io::Result<Stream> { let (port, token) = read_port_file_named(file) .filter(|(p, _)| *p != 0) @@ -614,20 +418,16 @@ mod imp_windows { Ok(stream) } - /// Remove another endpoint's marker file. Best effort, like - /// [`remove_stale_endpoint`]. pub fn remove_endpoint(file: &str) { if let Some(path) = port_path_named(file) { let _ = std::fs::remove_file(path); } } - /// A human-readable description of the endpoint, for log messages. pub fn endpoint_display() -> String { endpoint_display_named(PANE_PORT_FILE) } - /// [`endpoint_display`] for another of this daemon's endpoints. pub fn endpoint_display_named(file: &str) -> String { match read_port_file_named(file) { Some((port, _)) => format!("127.0.0.1:{port}"), @@ -640,26 +440,8 @@ mod imp_windows { use super::*; use std::time::{Duration, Instant}; - /// How long a loopback client gets to show up. Generous on purpose — the - /// client is a thread in this same process dialling 127.0.0.1 — so a trip - /// means the client is never coming, not that the runner is slow. const CLIENT_WITHIN: Duration = Duration::from_secs(10); - /// `accept()` with a deadline, and a read timeout on what it returns. - /// - /// Both halves matter, and neither is available on the blocking calls - /// these tests would otherwise make. Every client below is a thread that - /// `unwrap()`s its `connect`: when one of those panics — a transient - /// loopback refusal on a loaded runner is enough — a plain - /// `listener.accept()` has nothing left to wake it, and the handshake read - /// after it has nothing left to feed it. The test does not fail. The whole - /// test binary stops, `cargo test` never returns, and CI bills six hours - /// for a step that takes seventy-five seconds. - /// - /// That is not hypothetical: it happened three times in one day, and - /// because libtest only names a test once it *finishes*, no log ever said - /// which one. These tests are `cfg(windows)`, so a developer's macOS - /// `cargo test` never runs them and CI is the only place they execute. fn accept_within(listener: &TcpListener) -> TcpStream { listener .set_nonblocking(true) @@ -683,9 +465,6 @@ mod imp_windows { listener .set_nonblocking(false) .expect("restore the listener to blocking"); - // Winsock gives an accepted socket the listening socket's blocking - // mode, so this is a real change rather than a no-op: the handshake - // read must block, but only for a bounded time. accepted .set_nonblocking(false) .expect("the accepted socket must block"); @@ -695,25 +474,22 @@ mod imp_windows { accepted } - /// A token round-trips through hex encode → decode unchanged. #[test] fn token_hex_round_trips() { let token = make_token(); assert_eq!(decode_token(&encode_token(&token)), Some(token)); } - /// `decode_token` rejects anything that isn't exactly 32 bytes of hex. #[test] fn decode_token_rejects_malformed() { assert!(decode_token("").is_none()); assert!(decode_token("zz").is_none()); - assert!(decode_token(&"a".repeat(63)).is_none()); // odd/short - assert!(decode_token(&"a".repeat(66)).is_none()); // too long - assert!(decode_token(&"g".repeat(64)).is_none()); // non-hex digit - assert!(decode_token(&"ab".repeat(32)).is_some()); // exactly right + assert!(decode_token(&"a".repeat(63)).is_none()); + assert!(decode_token(&"a".repeat(66)).is_none()); + assert!(decode_token(&"g".repeat(64)).is_none()); + assert!(decode_token(&"ab".repeat(32)).is_some()); } - /// The port file format is `<port>\n<token-hex>`, and parsing recovers both. #[test] fn parse_port_file_recovers_port_and_token() { let token = make_token(); @@ -721,8 +497,6 @@ mod imp_windows { assert_eq!(parse_port_file(&contents), Some((54321, token))); } - /// A single-line (legacy / truncated) file has no token, so it must not - /// parse — a client can't authenticate without one. #[test] fn parse_port_file_rejects_missing_token() { assert!(parse_port_file("54321").is_none()); @@ -731,48 +505,38 @@ mod imp_windows { assert!(parse_port_file("notaport\ndeadbeef").is_none()); } - /// `tokens_match` is true only for identical tokens. #[test] fn tokens_match_is_exact() { let a = make_token(); let mut b = a; assert!(tokens_match(&a, &b)); - b[TOKEN_LEN - 1] ^= 1; // flip the last bit + b[TOKEN_LEN - 1] ^= 1; assert!(!tokens_match(&a, &b)); } - /// The handshake core accepts the matching token and rejects a wrong one - /// (and a short read), driven over an in-memory reader — no live daemon. #[test] fn authenticate_with_accepts_only_the_matching_token() { let token = make_token(); - // Correct token → Ok. let mut good = std::io::Cursor::new(token.to_vec()); assert!(authenticate_with(&mut good, &token).is_ok()); - // Wrong token → PermissionDenied. let mut wrong_bytes = token; wrong_bytes[0] ^= 0xff; let mut wrong = std::io::Cursor::new(wrong_bytes.to_vec()); let err = authenticate_with(&mut wrong, &token).unwrap_err(); assert_eq!(err.kind(), io::ErrorKind::PermissionDenied); - // Short preamble (peer hung up mid-token) → error, never a false accept. let mut short = std::io::Cursor::new(vec![0u8; TOKEN_LEN - 1]); assert!(authenticate_with(&mut short, &token).is_err()); } - /// End-to-end over a real loopback socket: a client that presents the - /// token authenticates; one that presents garbage is rejected. This is the - /// exact property the whole change exists to enforce. #[test] fn loopback_handshake_authenticates_real_connection() { let token = make_token(); let listener = TcpListener::bind(loopback(0)).expect("bind loopback"); let port = listener.local_addr().unwrap().port(); - // Good client: connect and present the correct token. let good = std::thread::spawn(move || { let mut s = TcpStream::connect(loopback(port)).unwrap(); s.write_all(&token).unwrap(); @@ -782,7 +546,6 @@ mod imp_windows { assert!(authenticate_with(&mut server_side, &token).is_ok()); let _keep = good.join().unwrap(); - // Bad client: connect and present a wrong token. let mut bad_token = token; bad_token[5] ^= 0xff; let bad = std::thread::spawn(move || { @@ -794,15 +557,8 @@ mod imp_windows { bad.join().unwrap(); } - /// The daemon's *second* endpoint — the control dialect's, bound by - /// [`crate::host::server`] — is a separate port with a separate token, - /// recorded in a separate file. Two listeners, two boundaries: a client - /// that learned the pane endpoint's token has not thereby been given the - /// one behind which the whole workspace tree lives. #[test] fn a_second_endpoint_gets_its_own_port_and_token() { - // The name `host::server` uses; spelled out rather than imported so - // the transport does not depend on the dialect above it. const CONTROL: &str = "control.port"; let dir = std::env::temp_dir().join(format!("tty7-wintok-{}", std::process::id())); @@ -821,16 +577,11 @@ mod imp_windows { "and the token the listener will check for" ); - // A client that could read the file gets in — that read is the whole - // proof of same-user, which is what filesystem permissions give the - // Unix socket for free. let good = std::thread::spawn(move || connect_endpoint(CONTROL).unwrap()); let mut server_side = accept_within(&listener); assert!(check_endpoint_token(&mut server_side, &token).is_ok()); let _keep = good.join().unwrap(); - // Anything else is refused before a frame is parsed — including the - // other endpoint's token, which is why they are minted separately. let mut foreign = token; foreign[0] ^= 0xff; let bad = std::thread::spawn(move || { @@ -849,15 +600,8 @@ mod imp_windows { remove_endpoint(CONTROL); } - /// Full wiring over the real config-dir path: `bind` writes a parseable - /// `<port>\n<token>` file and seeds the process token, and the public - /// `authenticate` (which reads that process token) then accepts a client - /// that presents the file's token. Exercises the `bind`→`connect`→ - /// `authenticate` seam the daemon actually runs, not just the pure core. #[test] fn bind_seeds_token_and_public_authenticate_accepts_a_file_token_client() { - // Pin the config dir under a temp dir so the port file never touches the - // real `%APPDATA%`. First-call-wins, matching the Unix IO tests. let dir = std::env::temp_dir().join(format!("tty7-wintok-{}", std::process::id())); std::fs::create_dir_all(&dir).ok(); config::set_config_dir(dir); @@ -866,13 +610,10 @@ mod imp_windows { let listener = bind().expect("bind under temp config dir"); let bound_port = listener.local_addr().unwrap().port(); - // The port file parses and matches the bound port. let contents = std::fs::read_to_string(port_path().unwrap()).unwrap(); let (port, token) = parse_port_file(&contents).expect("port file parses"); assert_eq!(port, bound_port, "file records the actually-bound port"); - // A client that read the file (has the token) authenticates via the - // public path, which checks against the token `bind` seeded. let good = std::thread::spawn(move || { let mut s = TcpStream::connect(loopback(port)).unwrap(); s.write_all(&token).unwrap(); diff --git a/crates/tty7-core/src/daemon/winproc.rs b/crates/tty7-core/src/daemon/winproc.rs index 4595da15..f9442ba5 100644 --- a/crates/tty7-core/src/daemon/winproc.rs +++ b/crates/tty7-core/src/daemon/winproc.rs @@ -1,28 +1,5 @@ -//! Windows-only process-table helpers. -//! -//! Windows has no ConPTY analogue of a Unix "foreground process group", so the -//! daemon can't ask the pty who's in front (that's why `pane`'s macOS/Linux -//! foreground queries have no Windows counterpart). What it *can* do is walk the -//! process table from the shell's own pid. Two pane operations need that: -//! -//! - **titling** a pane by the command running under the shell -//! ([`foreground_name`]), so Windows tabs show `git` / `node` / … instead of -//! staying blank; and -//! - **hangup** ([`descendants`]), because `portable-pty`'s Windows `kill` -//! terminates only the shell process — its children would otherwise be -//! reparented and linger, some still attached to the ConPTY, which keeps the -//! pane reader's blocking read from ever hitting EOF. -//! -//! The Win32 surface is a thin [`snapshot`]/[`terminate`] pair; all the tree -//! logic is pure over a plain [`Proc`] list and unit-tested without a live -//! process. Note that reading another process's *cwd* is deliberately not here: -//! it needs PEB traversal via `ReadProcessMemory`, which is undocumented and -//! fragile across bitness/elevation — so cwd on Windows stays sourced from OSC 7 -//! (see `pane::foreground_cwd`). - use std::collections::{HashSet, VecDeque}; -/// One process-table row: a pid, its parent's pid, and the executable basename. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct Proc { pub pid: u32, @@ -30,11 +7,6 @@ pub(crate) struct Proc { pub name: String, } -/// BFS the table from `root` by parent link, returning `(depth, pid, name)` for -/// every reachable descendant (root excluded), shallowest-first. A `seen` set -/// makes the walk robust to Windows pid reuse: a stale parent link that points -/// back into the tree (or a process that lists itself as its own parent) can't -/// create a cycle, because each pid is expanded at most once. fn walk(procs: &[Proc], root: u32) -> Vec<(u32, u32, &str)> { let mut seen = HashSet::new(); seen.insert(root); @@ -52,22 +24,12 @@ fn walk(procs: &[Proc], root: u32) -> Vec<(u32, u32, &str)> { out } -/// Descendants of `root` (children, grandchildren, …), each listed once and -/// ordered deepest-first — so a caller terminating them tears down leaf commands -/// before the shells that spawned them. `root` itself is never included. pub(crate) fn descendants(procs: &[Proc], root: u32) -> Vec<u32> { let mut walked = walk(procs, root); - // Deepest depth first; stable within a depth, so ordering is deterministic. walked.sort_by_key(|&(depth, ..)| std::cmp::Reverse(depth)); walked.into_iter().map(|(_, pid, _)| pid).collect() } -/// The foreground command's exe name for a shell rooted at `shell_pid`: the -/// deepest descendant (the thing actually running under the shell), or `None` -/// when the shell has no descendants at all — i.e. it's idle at its prompt, in -/// which case the caller keeps the pane's existing title. Ties at equal depth -/// break toward the largest pid (roughly the most recently created) so the pick -/// is stable frame to frame. pub(crate) fn foreground_name(procs: &[Proc], shell_pid: u32) -> Option<String> { walk(procs, shell_pid) .into_iter() @@ -75,9 +37,6 @@ pub(crate) fn foreground_name(procs: &[Proc], shell_pid: u32) -> Option<String> .map(|(_, _, name)| name.to_string()) } -/// Snapshot every process on the system as a [`Proc`] list, via a Toolhelp -/// snapshot. Best effort: any failure yields an empty list (the callers then -/// simply do nothing — no title, no extra kills). pub(crate) fn snapshot() -> Vec<Proc> { use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE}; use windows_sys::Win32::System::Diagnostics::ToolHelp::{ @@ -86,10 +45,6 @@ pub(crate) fn snapshot() -> Vec<Proc> { }; let mut out = Vec::new(); - // SAFETY: a textbook Toolhelp enumeration. The snapshot handle is closed on - // every exit path; `PROCESSENTRY32W` is zeroed and its `dwSize` set before the - // first call, exactly as the API requires; each `szExeFile` is a NUL-terminated - // UTF-16 buffer we read within its fixed length. unsafe { let snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); if snap == INVALID_HANDLE_VALUE { @@ -114,13 +69,9 @@ pub(crate) fn snapshot() -> Vec<Proc> { out } -/// Force-terminate `pid`. Best effort: a process we can't open (already gone, or -/// access denied) is simply skipped. pub(crate) fn terminate(pid: u32) { use windows_sys::Win32::Foundation::CloseHandle; use windows_sys::Win32::System::Threading::{OpenProcess, PROCESS_TERMINATE, TerminateProcess}; - // SAFETY: open → terminate → close on a single pid. A null handle (the process - // exited or we lack rights) is checked before use; the handle is always closed. unsafe { let handle = OpenProcess(PROCESS_TERMINATE, 0, pid); if !handle.is_null() { @@ -130,7 +81,6 @@ pub(crate) fn terminate(pid: u32) { } } -/// The executable basename from a NUL-terminated UTF-16 `szExeFile` field. fn exe_name(raw: &[u16]) -> String { let len = raw.iter().position(|&c| c == 0).unwrap_or(raw.len()); String::from_utf16_lossy(&raw[..len]) @@ -148,59 +98,45 @@ mod tests { } } - /// A realistic tree: only the shell's own descendants come back, unrelated - /// processes (and the shell's ancestors) are excluded. #[test] fn descendants_collects_only_the_shell_subtree() { let procs = vec![ p(1, 0, "System"), - p(100, 1, "powershell.exe"), // the shell - p(200, 100, "git.exe"), // child - p(300, 200, "less.exe"), // grandchild - p(201, 100, "node.exe"), // another child - p(999, 1, "explorer.exe"), // unrelated + p(100, 1, "powershell.exe"), + p(200, 100, "git.exe"), + p(300, 200, "less.exe"), + p(201, 100, "node.exe"), + p(999, 1, "explorer.exe"), ]; let mut got = descendants(&procs, 100); got.sort(); assert_eq!(got, vec![200, 201, 300]); } - /// Descendants come back deepest-first, so a terminator hits leaves before - /// the parents that spawned them. #[test] fn descendants_are_ordered_deepest_first() { let procs = vec![p(100, 1, "sh"), p(200, 100, "a"), p(300, 200, "b")]; assert_eq!(descendants(&procs, 100), vec![300, 200]); } - /// Pid reuse can make a parent link point back into the tree; the walk must - /// not loop forever on that. #[test] fn descendants_survive_a_pid_reuse_cycle() { - // 200's parent is 100 (real child); 100 *also* claims 200 as its parent - // (a reused pid). 100 is the root, so it's never re-expanded. let procs = vec![p(100, 200, "a"), p(200, 100, "b")]; assert_eq!(descendants(&procs, 100), vec![200]); } - /// A self-parenting row (pid == parent, as some system pids report) can't - /// wedge the walk either. #[test] fn descendants_survive_self_parenting() { let procs = vec![p(100, 1, "sh"), p(100, 100, "self")]; - // The only row whose parent is 100 is the self-referential one, which is - // rejected (pid == parent), so nothing descends. assert!(descendants(&procs, 100).is_empty()); } - /// A shell sitting idle at its prompt (no children) has no descendants. #[test] fn descendants_empty_without_children() { let procs = vec![p(100, 1, "sh"), p(999, 1, "other")]; assert!(descendants(&procs, 100).is_empty()); } - /// The pane title is the deepest running command, not the shell. #[test] fn foreground_name_is_the_deepest_command() { let procs = vec![ @@ -211,23 +147,18 @@ mod tests { assert_eq!(foreground_name(&procs, 100).as_deref(), Some("less.exe")); } - /// Idle at the prompt → no foreground command, so the caller keeps the - /// existing title rather than blanking it. #[test] fn foreground_name_is_none_at_idle_prompt() { let procs = vec![p(100, 1, "powershell.exe"), p(999, 1, "explorer.exe")]; assert_eq!(foreground_name(&procs, 100), None); } - /// Two equally-deep children resolve deterministically (largest pid wins) so - /// the title doesn't flicker between them. #[test] fn foreground_name_breaks_depth_ties_by_pid() { let procs = vec![p(100, 1, "sh"), p(200, 100, "a"), p(201, 100, "b")]; assert_eq!(foreground_name(&procs, 100).as_deref(), Some("b")); } - /// UTF-16 `szExeFile` decoding stops at the NUL terminator. #[test] fn exe_name_reads_up_to_the_nul() { let mut raw = [0u16; 260]; diff --git a/crates/tty7-core/src/host/conformance.rs b/crates/tty7-core/src/host/conformance.rs index 13e1b7df..b08661c6 100644 --- a/crates/tty7-core/src/host/conformance.rs +++ b/crates/tty7-core/src/host/conformance.rs @@ -1,37 +1,3 @@ -//! The suite every [`Host`] implementation has to pass, unchanged. -//! -//! A remote workspace is only worth having if "the files are over there" is -//! invisible. That invisibility is not something a design document can enforce -//! — it is a property of two implementations agreeing on several dozen small -//! behaviours: what a listing is sorted by, whether a non-zero `git` exit is an -//! error, what happens when you rename onto an existing file, how long a -//! watcher batches for. So the behaviours live here, once, as functions over -//! `&dyn Host`, and [`LocalHost`](super::local::LocalHost), `RemoteHost` and the -//! `--stdio` server all run the same list. -//! -//! # Shape -//! -//! Each case is a `pub fn(&dyn Host, &dyn Sandbox)`. `&dyn Host` rather than a generic -//! is deliberate twice over: it keeps the suite from monomorphizing per -//! implementation, and it makes the suite itself the proof that the trait stayed -//! object-safe — which the whole tree depends on, since a workspace holds -//! `Arc<dyn Host>`. -//! -//! [`for_each_host_case!`](crate::for_each_host_case) lists every case; -//! [`host_conformance_suite!`](crate::host_conformance_suite) expands that list -//! into one `#[test]` per case for a given host factory, so a failure names the -//! behaviour that broke instead of arriving as one opaque red suite. -//! -//! Adding a case means writing the `pub fn` *and* adding a line to the macro. -//! `every_case_is_registered` fails if you do only the first. -//! -//! # What a case may assume -//! -//! Only the sandbox and the `Host`. Cases build their fixtures through the host -//! being tested — `h.write_file`, `h.create_dir` — never through `std::fs`, -//! because for a remote host the sandbox is a directory on *another machine* -//! and `std::fs` would quietly test the wrong computer. - use std::io; use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; @@ -39,42 +5,23 @@ use std::time::{Duration, Instant}; use super::{Host, MTime, SearchHit}; use crate::daemon::control::WATCH_COALESCE_WINDOW; -/// One conformance case. pub type Case = fn(h: &dyn Host, sandbox: &dyn Sandbox); -/// An empty, disposable directory in the host's own namespace. -/// -/// The factory that produces one has to guarantee: it is empty; it is cleaned up -/// when dropped; its path is meaningful *to the host under test* (for a remote -/// host that means a path on the server, not on the client); and `git` can run -/// inside it. pub trait Sandbox { - /// The directory's path, in the host's own vocabulary. fn path(&self) -> &Path; - /// Create a symbolic link at `link` pointing to `target`, or `None` if this - /// sandbox cannot make symlinks (unprivileged Windows). Cases that need one - /// skip rather than fail when this is `None`, because "this platform has no - /// symlinks" is not a host bug. fn symlink(&self, target: &Path, link: &Path) -> Option<io::Result<()>> { let _ = (target, link); None } } -/// Every case, one per line. The single source of truth for what the suite is. -/// -/// The whole list goes to `$cb` in one brace-delimited invocation, plus whatever -/// extra token trees the caller passes ahead of the `@cases` marker. Two -/// callbacks use it — one builds [`CASES`], the other builds a run of `#[test]`s -/// — and neither can drift from the other, because there is only one list. #[macro_export] macro_rules! for_each_host_case { ($cb:ident $(, $extra:tt)*) => { $cb! { $($extra)* @cases - // fs: reading read_dir_lists_and_sorts, read_dir_includes_hidden, read_dir_missing_is_not_found, @@ -90,7 +37,6 @@ macro_rules! for_each_host_case { read_file_roundtrips_bytes, read_file_over_max_bytes_errors, read_file_on_a_dir_errors, - // fs: writing write_file_creates_and_overwrites, write_file_reports_its_own_metadata, write_file_to_missing_parent_errors, @@ -102,7 +48,6 @@ macro_rules! for_each_host_case { remove_file_then_missing, remove_dir_non_recursive_needs_empty, remove_dir_recursive_clears_tree, - // git repo_root_finds_nearest_git, repo_root_handles_worktree_file, git_status_porcelain_reflects_changes, @@ -110,23 +55,18 @@ macro_rules! for_each_host_case { git_that_cannot_run_is_err, git_optional_locks_env_is_set, git_stdin_is_null, - // path arithmetic join_uses_host_separator, is_absolute_matches_host_semantics, - // search search_is_breadth_first, search_skips_ignored_dirs, search_respects_limit, search_respects_max_dirs, - // machine inventory shells_are_named_and_have_a_default, - // watch watch_reports_create_and_delete, watch_is_non_recursive, watch_set_dirs_adds_and_drops, watch_coalesces_within_window, watch_drop_unsubscribes, - // connection semantics is_connected_is_true_when_healthy, id_is_stable_across_calls, separator_matches_hello, @@ -134,15 +74,11 @@ macro_rules! for_each_host_case { }; } -/// Builds [`CASES`]. Internal to [`for_each_host_case!`]. #[doc(hidden)] #[macro_export] macro_rules! __host_case_table { (@cases $($name:ident),* $(,)?) => { - /// Every case as `(name, fn)`, for a runner that cannot use the - /// `#[test]` expansion — an integration test in another crate driving a - /// real `tty7-server --stdio`, say. - pub const CASES: &[(&str, $crate::host::conformance::Case)] = &[ + pub const CASES: &[(&str, $crate::host::conformance::Case)] = &[ $((stringify!($name), $name as $crate::host::conformance::Case)),* ]; }; @@ -150,7 +86,6 @@ macro_rules! __host_case_table { crate::for_each_host_case!(__host_case_table); -/// Builds one `#[test]` per case. Internal to [`host_conformance_suite!`]. #[doc(hidden)] #[macro_export] macro_rules! __host_case_tests { @@ -165,15 +100,6 @@ macro_rules! __host_case_tests { }; } -/// Expand the whole suite into `#[test]`s for one host factory. -/// -/// `$factory` is any expression callable with no arguments returning -/// `(SharedHost, impl Sandbox)`. Each case gets a *fresh* host and sandbox, so -/// one case's leftovers can never explain another's failure. -/// -/// ```ignore -/// tty7_core::host_conformance_suite!(local, || (LocalHost::new(), TempSandbox::new())); -/// ``` #[macro_export] macro_rules! host_conformance_suite { ($modname:ident, $factory:expr) => { @@ -187,16 +113,8 @@ macro_rules! host_conformance_suite { }; } -// --------------------------------------------------------------------------- -// Shared helpers -// --------------------------------------------------------------------------- - -/// How long a case waits for a watcher event before calling it absent. Four -/// coalescing windows plus slack — long enough that a loaded CI box does not -/// flake, short enough that a genuinely dead watcher fails the run promptly. const WATCH_TIMEOUT: Duration = Duration::from_secs(4); -/// How long a case waits to be sure an event is *not* coming. const WATCH_QUIET: Duration = Duration::from_millis(1200); fn write(h: &dyn Host, p: &Path, body: &str) { @@ -204,7 +122,6 @@ fn write(h: &dyn Host, p: &Path, body: &str) { .unwrap_or_else(|e| panic!("write {}: {e}", p.display())); } -/// [`write`] for a body that is not a `&str`. fn put(h: &dyn Host, p: &Path, bytes: &[u8]) { h.write_file(p, bytes) .unwrap_or_else(|e| panic!("write {}: {e}", p.display())); @@ -223,22 +140,15 @@ fn hit_names(hits: &[SearchHit]) -> Vec<&str> { hits.iter().map(|h| h.name.as_str()).collect() } -/// A git repository in `dir`, or `None` when this host has no usable git — in -/// which case the git cases skip rather than fail, because "no git installed" -/// is an environment fact and not a conformance violation. fn git_repo(h: &dyn Host, dir: &Path) -> Option<()> { let out = h.git(dir, &["init", "--quiet"]).ok()?; out.success().then_some(()) } -/// Drain whatever the watcher already queued, so a case's assertions are about -/// the change it just made and not about the fixture it built. fn drain(sub: &super::WatchSub) { while sub.events().try_recv().is_ok() {} } -/// The next batch containing a path whose file name is `name`, or `None` if -/// none arrives within [`WATCH_TIMEOUT`]. fn await_event(sub: &super::WatchSub, name: &str) -> Option<Vec<PathBuf>> { let deadline = Instant::now() + WATCH_TIMEOUT; while Instant::now() < deadline { @@ -260,7 +170,6 @@ fn await_event(sub: &super::WatchSub, name: &str) -> Option<Vec<PathBuf>> { None } -/// Collect every batch that arrives over `window`. fn collect_batches(sub: &super::WatchSub, window: Duration) -> Vec<Vec<PathBuf>> { let deadline = Instant::now() + window; let mut out = Vec::new(); @@ -276,13 +185,6 @@ fn collect_batches(sub: &super::WatchSub, window: Duration) -> Vec<Vec<PathBuf>> out } -// --------------------------------------------------------------------------- -// fs: reading -// --------------------------------------------------------------------------- - -/// Directories first, then case-insensitively by name — the order the file tree -/// renders, computed by the host so a remote listing needs no client-side sort -/// (and so the two can never drift). pub fn read_dir_lists_and_sorts(h: &dyn Host, sb: &dyn Sandbox) { let sandbox = sb.path(); mkdir(h, &h.join(sandbox, "src")); @@ -299,9 +201,6 @@ pub fn read_dir_lists_and_sorts(h: &dyn Host, sb: &dyn Sandbox) { ); } -/// Hidden files come back. Whether to *show* them is a UI preference, and a host -/// that filtered them would make that preference unimplementable for the tree -/// while still costing a listing. pub fn read_dir_includes_hidden(h: &dyn Host, sb: &dyn Sandbox) { let sandbox = sb.path(); write(h, &h.join(sandbox, ".hidden"), ""); @@ -310,17 +209,12 @@ pub fn read_dir_includes_hidden(h: &dyn Host, sb: &dyn Sandbox) { assert!(names(&listed).contains(&".hidden"), "{:?}", names(&listed)); } -/// A directory that isn't there is `NotFound`, so the tree can tell "gone" from -/// "unreadable" and drop the row instead of showing an error. pub fn read_dir_missing_is_not_found(h: &dyn Host, sb: &dyn Sandbox) { let sandbox = sb.path(); let err = h.read_dir(&h.join(sandbox, "nope"), None).unwrap_err(); assert_eq!(err.kind(), io::ErrorKind::NotFound, "{err}"); } -/// Listing a file is an error. Which error varies by platform (`NotADirectory` -/// where it exists), so the assertion is only that it fails rather than -/// pretending to be an empty directory. pub fn read_dir_on_a_file_errors(h: &dyn Host, sb: &dyn Sandbox) { let sandbox = sb.path(); let f = h.join(sandbox, "file.txt"); @@ -328,8 +222,6 @@ pub fn read_dir_on_a_file_errors(h: &dyn Host, sb: &dyn Sandbox) { assert!(h.read_dir(&f, None).is_err()); } -/// `.git` is ignored unconditionally — no `.gitignore` mentions it, and the tree -/// has always dimmed it. pub fn read_dir_marks_dotgit_ignored(h: &dyn Host, sb: &dyn Sandbox) { let sandbox = sb.path(); mkdir(h, &h.join(sandbox, ".git")); @@ -344,10 +236,6 @@ pub fn read_dir_marks_dotgit_ignored(h: &dyn Host, sb: &dyn Sandbox) { assert!(!a.ignored); } -/// The gitignore chain, scored the way git scores it: walk from the root down, -/// deepest match wins, a nested `!pattern` un-ignores what an ancestor ignored. -/// The fixture is the file tree's own, so a regression here is a visible change -/// in what the sidebar dims. pub fn read_dir_applies_gitignore_chain(h: &dyn Host, sb: &dyn Sandbox) { let sandbox = sb.path(); mkdir(h, &h.join(sandbox, "src")); @@ -378,8 +266,6 @@ pub fn read_dir_applies_gitignore_chain(h: &dyn Host, sb: &dyn Sandbox) { assert!(!ignored(&nested, "main.rs")); } -/// Without a root there is no chain to score against, so nothing is ignored — -/// except `.git`, which is not a pattern match. pub fn read_dir_without_root_ignores_nothing(h: &dyn Host, sb: &dyn Sandbox) { let sandbox = sb.path(); mkdir(h, &h.join(sandbox, ".git")); @@ -400,13 +286,8 @@ pub fn read_dir_without_root_ignores_nothing(h: &dyn Host, sb: &dyn Sandbox) { } } -/// A symlink to a directory reads as a directory *and* as a link: the tree -/// expands it like a directory, and the sort puts it with the directories, but -/// callers that care (a delete, say) can still tell. pub fn read_dir_symlink_to_dir_is_dir(h: &dyn Host, sb: &dyn Sandbox) { let sandbox = sb.path(); - // The sandbox owns symlink creation because the host trait has no method - // for it — and on unprivileged Windows there is nothing to test. let target = h.join(sandbox, "real"); let link = h.join(sandbox, "link"); mkdir(h, &target); @@ -426,8 +307,6 @@ pub fn read_dir_symlink_to_dir_is_dir(h: &dyn Host, sb: &dyn Sandbox) { assert!(l.is_symlink, "and still reports as a link"); } -/// Size is exact and a modification time is present — the two fields the editor -/// builds its external-change detection on. pub fn stat_reports_len_and_mtime(h: &dyn Host, sb: &dyn Sandbox) { let sandbox = sb.path(); let f = h.join(sandbox, "sized.txt"); @@ -444,16 +323,12 @@ pub fn stat_reports_len_and_mtime(h: &dyn Host, sb: &dyn Sandbox) { assert!(h.stat(&d).unwrap().is_dir); } -/// A missing path is `NotFound`, not some generic failure — call sites branch on -/// it to tell "deleted" from "broken". pub fn stat_missing_is_not_found(h: &dyn Host, sb: &dyn Sandbox) { let sandbox = sb.path(); let err = h.stat(&h.join(sandbox, "ghost")).unwrap_err(); assert_eq!(err.kind(), io::ErrorKind::NotFound, "{err}"); } -/// `exists` is allowed to be a cheaper path than `stat`, but it must never be a -/// *different* answer. pub fn exists_matches_stat(h: &dyn Host, sb: &dyn Sandbox) { let sandbox = sb.path(); let f = h.join(sandbox, "there.txt"); @@ -465,14 +340,10 @@ pub fn exists_matches_stat(h: &dyn Host, sb: &dyn Sandbox) { assert_eq!(h.exists(&missing), h.stat(&missing).is_ok()); assert!(!h.exists(&missing)); - // A path *under* a file is neither a file nor a directory. let nested = h.join(&f, "child"); assert_eq!(h.exists(&nested), h.stat(&nested).is_ok()); } -/// `..` is resolved by the host, not by the client's `std::path` — which on a -/// Windows client would resolve a remote POSIX path against the wrong -/// filesystem entirely. pub fn canonicalize_resolves_dotdot(h: &dyn Host, sb: &dyn Sandbox) { let sandbox = sb.path(); let a = h.join(sandbox, "a"); @@ -486,9 +357,6 @@ pub fn canonicalize_resolves_dotdot(h: &dyn Host, sb: &dyn Sandbox) { assert_eq!(canon, direct, "a/../b is b"); } -/// Bytes are bytes: NULs, invalid UTF-8 and a multi-megabyte body all come back -/// exactly as written. The editor reads files this way and would corrupt a -/// binary it merely *opened* if any of it were lossy. pub fn read_file_roundtrips_bytes(h: &dyn Host, sb: &dyn Sandbox) { let sandbox = sb.path(); let f = h.join(sandbox, "bytes.bin"); @@ -496,7 +364,6 @@ pub fn read_file_roundtrips_bytes(h: &dyn Host, sb: &dyn Sandbox) { put(h, &f, &body); assert_eq!(h.read_file(&f, 1024).unwrap(), body); - // Big enough to cross any chunking a transport might do. let big = h.join(sandbox, "big.bin"); body = (0..10 * 1024 * 1024u32).map(|i| (i % 251) as u8).collect(); put(h, &big, &body); @@ -505,20 +372,15 @@ pub fn read_file_roundtrips_bytes(h: &dyn Host, sb: &dyn Sandbox) { assert!(back == body, "10MB body round-tripped byte for byte"); } -/// The limit is the *host's*: an oversized file fails without its contents -/// being read or transferred, which is the difference between an instant "too -/// big" and a minute of transatlantic transfer thrown away on arrival. pub fn read_file_over_max_bytes_errors(h: &dyn Host, sb: &dyn Sandbox) { let sandbox = sb.path(); let f = h.join(sandbox, "fat.bin"); put(h, &f, &vec![b'x'; 4096]); let err = h.read_file(&f, 1024).unwrap_err(); assert_eq!(err.kind(), io::ErrorKind::FileTooLarge, "{err}"); - // Exactly at the limit is fine — the check is `>`, not `>=`. assert_eq!(h.read_file(&f, 4096).unwrap().len(), 4096); } -/// Reading a directory fails rather than returning something. pub fn read_file_on_a_dir_errors(h: &dyn Host, sb: &dyn Sandbox) { let sandbox = sb.path(); let d = h.join(sandbox, "adir"); @@ -526,26 +388,15 @@ pub fn read_file_on_a_dir_errors(h: &dyn Host, sb: &dyn Sandbox) { assert!(h.read_file(&d, 1024 * 1024).is_err()); } -// --------------------------------------------------------------------------- -// fs: writing -// --------------------------------------------------------------------------- - -/// Creating and overwriting both work, and the file the host reports after the -/// write is the file that is actually there. pub fn write_file_creates_and_overwrites(h: &dyn Host, sb: &dyn Sandbox) { let sandbox = sb.path(); let f = h.join(sandbox, "doc.txt"); let wrote = h.write_file(&f, b"first").unwrap(); assert_eq!(h.read_file(&f, 1024).unwrap(), b"first"); - // The metadata visible straight after the write describes the bytes that - // were written — the editor's whole external-change detection rests on - // being able to record "this mtime is mine" the moment a save lands. let first = h.stat(&f).unwrap(); assert_eq!(first.len, 5); assert!(first.mtime.is_some()); assert!(!first.is_dir); - // …and the write reports that same file itself, so the caller never has to - // ask again. This is the guard on the round trip `write_file -> Meta` saves. assert_eq!( wrote, first, "the write answers with the file it just wrote" @@ -560,13 +411,6 @@ pub fn write_file_creates_and_overwrites(h: &dyn Host, sb: &dyn Sandbox) { assert_eq!(wrote, second); } -/// The mtime a save records must come from the write itself. -/// -/// The editor tells its own save apart from someone else's edit by comparing -/// against a `disk_mtime` baseline. If that baseline came from a `stat` issued -/// *after* the write, an edit landing in the gap would be stamped as ours and -/// the editor would never report it — a silent lost-update, and the user never -/// gets the conflict prompt. So the write has to answer for itself. pub fn write_file_reports_its_own_metadata(h: &dyn Host, sb: &dyn Sandbox) { let f = h.join(sb.path(), "baseline.txt"); let wrote = h.write_file(&f, b"mine").unwrap(); @@ -578,9 +422,6 @@ pub fn write_file_reports_its_own_metadata(h: &dyn Host, sb: &dyn Sandbox) { assert!(!wrote.is_dir); assert!(!wrote.is_symlink); - // A later external write moves the mtime forward; the value we recorded is - // still the one describing *our* bytes, which is what makes the comparison - // meaningful. let after = h.write_file(&f, b"theirs, longer").unwrap(); assert_eq!(after.len, 14); assert_ne!( @@ -589,8 +430,6 @@ pub fn write_file_reports_its_own_metadata(h: &dyn Host, sb: &dyn Sandbox) { ); } -/// A missing parent is an error and stays missing. Silently creating it would -/// turn a typo in a save dialog into a directory tree nobody asked for. pub fn write_file_to_missing_parent_errors(h: &dyn Host, sb: &dyn Sandbox) { let sandbox = sb.path(); let parent = h.join(sandbox, "no-such-dir"); @@ -600,8 +439,6 @@ pub fn write_file_to_missing_parent_errors(h: &dyn Host, sb: &dyn Sandbox) { assert!(!h.exists(&parent), "the parent must not have been created"); } -/// Exclusive creation: the file tree's "new file" row must not silently -/// truncate a file that is already there. pub fn create_file_new_rejects_existing(h: &dyn Host, sb: &dyn Sandbox) { let sandbox = sb.path(); let f = h.join(sandbox, "fresh.txt"); @@ -618,8 +455,6 @@ pub fn create_file_new_rejects_existing(h: &dyn Host, sb: &dyn Sandbox) { ); } -/// Without `recursive`, a missing parent is an error rather than an implicit -/// `mkdir -p`. pub fn create_dir_non_recursive_needs_parent(h: &dyn Host, sb: &dyn Sandbox) { let sandbox = sb.path(); let deep = h.join(&h.join(sandbox, "a"), "b"); @@ -631,13 +466,10 @@ pub fn create_dir_non_recursive_needs_parent(h: &dyn Host, sb: &dyn Sandbox) { h.create_dir(&deep, false).unwrap(); assert!(h.stat(&deep).unwrap().is_dir); - // And a second non-recursive create of the same directory is a conflict. let err = h.create_dir(&a, false).unwrap_err(); assert_eq!(err.kind(), io::ErrorKind::AlreadyExists, "{err}"); } -/// With `recursive`, the whole chain appears at once and an existing directory -/// is success — the `mkdir -p` semantics the worktree setup depends on. pub fn create_dir_recursive_makes_chain(h: &dyn Host, sb: &dyn Sandbox) { let sandbox = sb.path(); let deep = h.join(&h.join(&h.join(sandbox, "x"), "y"), "z"); @@ -647,11 +479,6 @@ pub fn create_dir_recursive_makes_chain(h: &dyn Host, sb: &dyn Sandbox) { assert!(h.stat(&deep).unwrap().is_dir); } -/// An occupied destination is `AlreadyExists`, guaranteed by the host. -/// -/// This is the case that keeps the file tree's inline rename from needing an -/// `exists` probe first: the probe would be an extra round trip *and* racy, and -/// on Unix a bare `rename(2)` would have silently destroyed the other file. pub fn rename_moves_and_rejects_existing_target(h: &dyn Host, sb: &dyn Sandbox) { let sandbox = sb.path(); let a = h.join(sandbox, "a.txt"); @@ -669,8 +496,6 @@ pub fn rename_moves_and_rejects_existing_target(h: &dyn Host, sb: &dyn Sandbox) assert!(h.exists(&c), "source untouched"); } -/// Moving between directories on the same host works — a drag in the tree is -/// this call. pub fn rename_across_dirs_works(h: &dyn Host, sb: &dyn Sandbox) { let sandbox = sb.path(); let from_dir = h.join(sandbox, "from"); @@ -685,7 +510,6 @@ pub fn rename_across_dirs_works(h: &dyn Host, sb: &dyn Sandbox) { assert!(!h.exists(&src)); assert_eq!(h.read_file(&dst, 64).unwrap(), b"moved"); - // Directories move too. let sub = h.join(&from_dir, "sub"); mkdir(h, &sub); let sub_dst = h.join(&to_dir, "sub"); @@ -693,8 +517,6 @@ pub fn rename_across_dirs_works(h: &dyn Host, sb: &dyn Sandbox) { assert!(h.stat(&sub_dst).unwrap().is_dir); } -/// Deleting twice is `NotFound` the second time, so the tree's optimistic row -/// removal can tell "already gone" from "could not delete". pub fn remove_file_then_missing(h: &dyn Host, sb: &dyn Sandbox) { let sandbox = sb.path(); let f = h.join(sandbox, "doomed.txt"); @@ -705,8 +527,6 @@ pub fn remove_file_then_missing(h: &dyn Host, sb: &dyn Sandbox) { assert_eq!(err.kind(), io::ErrorKind::NotFound, "{err}"); } -/// A non-empty directory needs `recursive`. Without it the host refuses, which -/// is what lets a delete confirm before it destroys a subtree. pub fn remove_dir_non_recursive_needs_empty(h: &dyn Host, sb: &dyn Sandbox) { let sandbox = sb.path(); let d = h.join(sandbox, "full"); @@ -716,15 +536,12 @@ pub fn remove_dir_non_recursive_needs_empty(h: &dyn Host, sb: &dyn Sandbox) { assert_eq!(err.kind(), io::ErrorKind::DirectoryNotEmpty, "{err}"); assert!(h.exists(&d)); - // Empty, it goes. let empty = h.join(sandbox, "empty"); mkdir(h, &empty); h.remove(&empty, false).unwrap(); assert!(!h.exists(&empty)); } -/// With `recursive`, a whole tree goes in one call rather than one round trip -/// per file. pub fn remove_dir_recursive_clears_tree(h: &dyn Host, sb: &dyn Sandbox) { let sandbox = sb.path(); let root = h.join(sandbox, "tree"); @@ -737,19 +554,10 @@ pub fn remove_dir_recursive_clears_tree(h: &dyn Host, sb: &dyn Sandbox) { assert!(!h.exists(&root)); } -// --------------------------------------------------------------------------- -// git -// --------------------------------------------------------------------------- - -/// The nearest ancestor with a `.git`, found in one call rather than one round -/// trip per level — and `Ok(None)`, not an error, outside any repository. pub fn repo_root_finds_nearest_git(h: &dyn Host, sb: &dyn Sandbox) { let sandbox = sb.path(); let outside = h.join(sandbox, "outside"); mkdir(h, &outside); - // The sandbox itself may sit inside somebody's repository (a checkout under - // a repo-shaped temp dir), so the "outside" assertion is only meaningful - // when the sandbox is genuinely outside one. let sandbox_root = h.repo_root(sandbox).unwrap(); if sandbox_root.is_none() { assert_eq!(h.repo_root(&outside).unwrap(), None, "no repo, no root"); @@ -763,8 +571,6 @@ pub fn repo_root_finds_nearest_git(h: &dyn Host, sb: &dyn Sandbox) { assert_eq!(h.repo_root(&repo).unwrap(), Some(repo)); } -/// A linked worktree's `.git` is a *file*, not a directory. A root probe that -/// only looked for directories would treat every worktree as "not a repo". pub fn repo_root_handles_worktree_file(h: &dyn Host, sb: &dyn Sandbox) { let sandbox = sb.path(); let wt = h.join(sandbox, "worktree"); @@ -779,8 +585,6 @@ pub fn repo_root_handles_worktree_file(h: &dyn Host, sb: &dyn Sandbox) { assert_eq!(h.repo_root(&deep).unwrap(), Some(wt)); } -/// A real `git` invocation against a real repository: the sidebar's status line -/// is this call, and it has to see a change the host just made. pub fn git_status_porcelain_reflects_changes(h: &dyn Host, sb: &dyn Sandbox) { let sandbox = sb.path(); let repo = h.join(sandbox, "repo"); @@ -797,24 +601,14 @@ pub fn git_status_porcelain_reflects_changes(h: &dyn Host, sb: &dyn Sandbox) { ); } -/// **The load-bearing one.** A non-zero exit is `Ok`, with the code in -/// `Output::status`. -/// -/// Everything downstream is built on this split: `Err` means git never ran, so a -/// caller can keep the previous status instead of blanking it, while an exit -/// 128 is just git's answer to a question about a directory that isn't a repo. -/// Collapse the two and the sidebar starts showing errors for ordinary states. pub fn git_nonzero_exit_is_ok_not_err(h: &dyn Host, sb: &dyn Sandbox) { let sandbox = sb.path(); let plain = h.join(sandbox, "not-a-repo"); mkdir(h, &plain); let out = match h.git(&plain, &["rev-parse", "--show-toplevel"]) { Ok(out) => out, - // No git on this host at all: nothing to assert about exit codes. Err(_) => return, }; - // If the sandbox happens to live inside a repository, git succeeds — then - // the case has nothing to say, and saying it anyway would be a false red. if out.success() { return; } @@ -828,12 +622,6 @@ pub fn git_nonzero_exit_is_ok_not_err(h: &dyn Host, sb: &dyn Sandbox) { ); } -/// `Err` is reserved for "it could not run". A `cwd` that does not exist is -/// exactly that: the question was never about the repository. -/// -/// (The other way to reach `Err` — no `git` on `PATH` — cannot be provoked -/// in-process without mutating the environment out from under every other test -/// in the binary, so this is the deterministic half of that contract.) pub fn git_that_cannot_run_is_err(h: &dyn Host, sb: &dyn Sandbox) { let sandbox = sb.path(); let gone = h.join(sandbox, "no-such-directory"); @@ -841,12 +629,6 @@ pub fn git_that_cannot_run_is_err(h: &dyn Host, sb: &dyn Sandbox) { assert_eq!(err.kind(), io::ErrorKind::NotFound, "{err}"); } -/// `GIT_OPTIONAL_LOCKS=0` reaches the git process. -/// -/// Probed through a `!`-alias, which git runs in a shell that inherits git's own -/// environment — the only way to observe the variable without mutating this -/// process's `PATH`. Without it, every background status probe can take -/// `index.lock` and lose a race against a git command the user is running. pub fn git_optional_locks_env_is_set(h: &dyn Host, sb: &dyn Sandbox) { let sandbox = sb.path(); let repo = h.join(sandbox, "repo"); @@ -868,8 +650,6 @@ pub fn git_optional_locks_env_is_set(h: &dyn Host, sb: &dyn Sandbox) { let Ok(out) = h.git(&repo, &["tty7probe"]) else { return; }; - // A host without a shell for `!`-aliases (some Windows layouts) cannot run - // the probe; that is an environment limit, not a conformance failure. if !out.success() { return; } @@ -880,13 +660,6 @@ pub fn git_optional_locks_env_is_set(h: &dyn Host, sb: &dyn Sandbox) { ); } -/// git's stdin is closed, so a subcommand that reads it gets EOF immediately -/// instead of blocking a background thread forever on a terminal nobody is -/// attached to. -/// -/// Hard-bounded: if the invariant is broken the call hangs, and a hung test that -/// eventually times out the whole suite is a far worse failure report than a -/// named assertion. pub fn git_stdin_is_null(h: &dyn Host, sb: &dyn Sandbox) { let sandbox = sb.path(); let repo = h.join(sandbox, "repo"); @@ -895,27 +668,16 @@ pub fn git_stdin_is_null(h: &dyn Host, sb: &dyn Sandbox) { let (tx, rx) = std::sync::mpsc::channel(); std::thread::scope(|s| { s.spawn(|| { - // `stripspace` reads stdin to EOF and writes it out. With stdin - // nulled it returns instantly and empty; with stdin inherited from - // an interactive terminal it never returns at all. let _ = tx.send(h.git(&repo, &["stripspace"]).map(|o| o.stdout)); }); match rx.recv_timeout(Duration::from_secs(10)) { Ok(Ok(stdout)) => assert!(stdout.is_empty(), "stripspace read something from stdin"), - // No git here: nothing to assert. Ok(Err(_)) => {} Err(_) => panic!("git blocked on stdin — it must be nulled"), } }); } -// --------------------------------------------------------------------------- -// path arithmetic -// --------------------------------------------------------------------------- - -/// `join` uses the *host's* separator, not the client's. On a Windows client -/// talking to Linux, `PathBuf::join` would produce `/home/me\src`, which the -/// remote has never heard of. pub fn join_uses_host_separator(h: &dyn Host, sb: &dyn Sandbox) { let sandbox = sb.path(); let sep = h.separator(); @@ -927,7 +689,6 @@ pub fn join_uses_host_separator(h: &dyn Host, sb: &dyn Sandbox) { "{text} should extend {}", sandbox.display() ); - // Joining twice is joining a path, not concatenating two roots. let deep = h.join(&joined, "grand"); assert!( deep.to_string_lossy() @@ -935,9 +696,6 @@ pub fn join_uses_host_separator(h: &dyn Host, sb: &dyn Sandbox) { ); } -/// Absoluteness is the host's judgement. A Windows client asked about -/// `/home/me` would say "relative" — which would send every remote path down -/// the wrong branch of every call site that checks. pub fn is_absolute_matches_host_semantics(h: &dyn Host, sb: &dyn Sandbox) { let sandbox = sb.path(); assert!( @@ -950,11 +708,6 @@ pub fn is_absolute_matches_host_semantics(h: &dyn Host, sb: &dyn Sandbox) { assert!(!h.is_absolute(Path::new("child.txt"))); } -// --------------------------------------------------------------------------- -// search -// --------------------------------------------------------------------------- - -/// Breadth-first: the shallow hit is the one you meant, so it comes first. pub fn search_is_breadth_first(h: &dyn Host, sb: &dyn Sandbox) { let sandbox = sb.path(); write(h, &h.join(sandbox, "target-top.txt"), ""); @@ -972,9 +725,6 @@ pub fn search_is_breadth_first(h: &dyn Host, sb: &dyn Sandbox) { assert!(top < deep_pos, "shallow before deep: {names:?}"); } -/// Ignored directories are not walked at all. `node_modules` and `target` are -/// where the file count explodes and never where anyone is searching — walking -/// them would burn the whole directory budget before reaching real code. pub fn search_skips_ignored_dirs(h: &dyn Host, sb: &dyn Sandbox) { let sandbox = sb.path(); write(h, &h.join(sandbox, ".gitignore"), "node_modules/\n"); @@ -993,7 +743,6 @@ pub fn search_skips_ignored_dirs(h: &dyn Host, sb: &dyn Sandbox) { hit_names(&hits) ); - // With hidden/ignored shown, the walk does go in — the flag is the switch. let hits = h .search(&[sandbox.to_path_buf()], "needle", 100, 2000, true) .unwrap(); @@ -1002,7 +751,6 @@ pub fn search_skips_ignored_dirs(h: &dyn Host, sb: &dyn Sandbox) { assert_eq!(names, vec!["needle.js", "needle.rs"]); } -/// `limit` stops the walk, so a query like "e" cannot crawl a monorepo. pub fn search_respects_limit(h: &dyn Host, sb: &dyn Sandbox) { let sandbox = sb.path(); for i in 0..10 { @@ -1014,12 +762,8 @@ pub fn search_respects_limit(h: &dyn Host, sb: &dyn Sandbox) { assert_eq!(hits.len(), 3, "{:?}", hit_names(&hits)); } -/// `max_dirs` bounds the walk even when nothing matches, so a typo cannot turn -/// into a full-disk crawl. pub fn search_respects_max_dirs(h: &dyn Host, sb: &dyn Sandbox) { let sandbox = sb.path(); - // A chain deep enough that visiting it all would be obvious, with the only - // match at the bottom. let mut dir = sandbox.to_path_buf(); for i in 0..12 { dir = h.join(&dir, &format!("d{i}")); @@ -1036,25 +780,12 @@ pub fn search_respects_max_dirs(h: &dyn Host, sb: &dyn Sandbox) { hit_names(&hits) ); - // With room, it is found — proving the fixture, not just the bound. let hits = h .search(&[sandbox.to_path_buf()], "needle", 100, 2000, false) .unwrap(); assert_eq!(hit_names(&hits), vec!["needle.txt"]); } -// --------------------------------------------------------------------------- -// machine inventory -// --------------------------------------------------------------------------- - -/// Every row of the new-tab dropdown is launchable and labelled, and the menu -/// knows which one is the default. -/// -/// Deliberately not "the list is non-empty": a host with no shell registered -/// anywhere is a strange machine, not a broken `Host` implementation. What the -/// dropdown cannot survive is a blank row, a row with nothing to spawn, or two -/// rows with the same name — the dedupe the local probe does is part of the -/// contract, not an implementation detail of `/etc/shells` parsing. pub fn shells_are_named_and_have_a_default(h: &dyn Host, _sb: &dyn Sandbox) { let inv = h.shells().expect("a host can list its shells"); assert!( @@ -1077,11 +808,6 @@ pub fn shells_are_named_and_have_a_default(h: &dyn Host, _sb: &dyn Sandbox) { } } -// --------------------------------------------------------------------------- -// watch -// --------------------------------------------------------------------------- - -/// Creating and deleting a file in a watched directory both surface. pub fn watch_reports_create_and_delete(h: &dyn Host, sb: &dyn Sandbox) { let sandbox = sb.path(); let sub = h.watch(&[sandbox.to_path_buf()]).unwrap(); @@ -1102,9 +828,6 @@ pub fn watch_reports_create_and_delete(h: &dyn Host, sb: &dyn Sandbox) { ); } -/// Non-recursive, always. The tree watches the directories it has expanded; a -/// recursive watch on a repository root would report every file a build touches -/// and repaint the sidebar continuously. pub fn watch_is_non_recursive(h: &dyn Host, sb: &dyn Sandbox) { let sandbox = sb.path(); let sub_dir = h.join(sandbox, "child"); @@ -1127,9 +850,6 @@ pub fn watch_is_non_recursive(h: &dyn Host, sb: &dyn Sandbox) { ); } -/// The watched set is replaceable in place — the file tree changes it on every -/// expand, and rebuilding the subscription each time would cost a round trip -/// and a fresh server-side watcher per disclosure triangle. pub fn watch_set_dirs_adds_and_drops(h: &dyn Host, sb: &dyn Sandbox) { let sandbox = sb.path(); let a = h.join(sandbox, "a"); @@ -1143,14 +863,12 @@ pub fn watch_set_dirs_adds_and_drops(h: &dyn Host, sb: &dyn Sandbox) { sub.set_dirs(&[b.clone()]).unwrap(); drain(&sub); - // The newly watched directory reports. write(h, &h.join(&b, "in-b.txt"), "x"); assert!( await_event(&sub, "in-b.txt").is_some(), "the added directory should report" ); - // The dropped one does not. drain(&sub); write(h, &h.join(&a, "in-a.txt"), "x"); std::thread::sleep(WATCH_QUIET); @@ -1166,9 +884,6 @@ pub fn watch_set_dirs_adds_and_drops(h: &dyn Host, sb: &dyn Sandbox) { ); } -/// A burst becomes a batch. Fifty writes arrive as a handful of deduplicated -/// batches, not fifty repaints — and identically on every host, so where the -/// files live cannot change how busy the UI looks. pub fn watch_coalesces_within_window(h: &dyn Host, sb: &dyn Sandbox) { let sandbox = sb.path(); let sub = h.watch(&[sandbox.to_path_buf()]).unwrap(); @@ -1181,7 +896,6 @@ pub fn watch_coalesces_within_window(h: &dyn Host, sb: &dyn Sandbox) { } let burst = started.elapsed(); - // Give the window time to close, plus slack for a loaded machine. let batches = collect_batches(&sub, Duration::from_secs(2)); let with_file: Vec<&Vec<PathBuf>> = batches .iter() @@ -1191,11 +905,6 @@ pub fn watch_coalesces_within_window(h: &dyn Host, sb: &dyn Sandbox) { }) .collect(); assert!(!with_file.is_empty(), "the burst produced no events at all"); - // The guarantee is one batch per window, not a fixed batch count. A loaded - // runner can spend well over a window just issuing the writes, and a burst - // spread over N windows is *allowed* to arrive as N batches — bounding by a - // constant would be testing how fast the machine writes files, not whether - // the coalescer coalesces. let windows = burst .as_millis() .div_ceil(WATCH_COALESCE_WINDOW.as_millis()) @@ -1216,13 +925,10 @@ pub fn watch_coalesces_within_window(h: &dyn Host, sb: &dyn Sandbox) { } } -/// Dropping the subscription unsubscribes — the watcher goes away rather than -/// living on and (remotely) leaking a server-side watch per expanded directory. pub fn watch_drop_unsubscribes(h: &dyn Host, sb: &dyn Sandbox) { let sandbox = sb.path(); let sub = h.watch(&[sandbox.to_path_buf()]).unwrap(); drain(&sub); - // Keep the receiving end so we can prove nothing arrives after the drop. let rx = sub.events().clone(); drop(sub); @@ -1231,8 +937,6 @@ pub fn watch_drop_unsubscribes(h: &dyn Host, sb: &dyn Sandbox) { loop { match rx.try_recv() { - // A batch already in flight when the drop happened is fine; a batch - // describing the change made *after* it is not. Ok(batch) => assert!( !batch .iter() @@ -1245,23 +949,13 @@ pub fn watch_drop_unsubscribes(h: &dyn Host, sb: &dyn Sandbox) { } } -// --------------------------------------------------------------------------- -// connection semantics -// --------------------------------------------------------------------------- - -/// A host handed to a test is a working host. (Trivially true locally; the -/// point is that a remote one has to agree, so call sites can trust the flag to -/// mean "showing stale data is the right move".) pub fn is_connected_is_true_when_healthy(h: &dyn Host, sb: &dyn Sandbox) { let sandbox = sb.path(); assert!(h.is_connected()); - // And it stays true across real work. let _ = h.read_dir(sandbox, None).unwrap(); assert!(h.is_connected()); } -/// The id never changes under a live host. Caches key on it; a shifting id would -/// silently orphan every entry they hold. pub fn id_is_stable_across_calls(h: &dyn Host, _sb: &dyn Sandbox) { let first = h.id(); for _ in 0..8 { @@ -1269,9 +963,6 @@ pub fn id_is_stable_across_calls(h: &dyn Host, _sb: &dyn Sandbox) { } } -/// The separator is stable and is the one `join` actually uses — for a remote -/// host it comes from the handshake, so a mismatch here means every path the -/// client builds is wrong. pub fn separator_matches_hello(h: &dyn Host, sb: &dyn Sandbox) { let sandbox = sb.path(); let sep = h.separator(); @@ -1286,11 +977,6 @@ pub fn separator_matches_hello(h: &dyn Host, sb: &dyn Sandbox) { mod tests { use super::CASES; - /// Every `pub fn` case in this file appears in the registry. - /// - /// The failure mode this guards is silent: write a case, forget the list - /// entry, and it simply never runs — a green suite that tests one behaviour - /// less than it claims to. #[test] fn every_case_is_registered() { let src = include_str!("conformance.rs"); diff --git a/crates/tty7-core/src/host/local.rs b/crates/tty7-core/src/host/local.rs index 8d13a778..403ffc69 100644 --- a/crates/tty7-core/src/host/local.rs +++ b/crates/tty7-core/src/host/local.rs @@ -1,22 +1,3 @@ -//! [`LocalHost`] — the [`Host`] that answers with `std::fs` and a `git` -//! subprocess. -//! -//! This is the 99% path: every workspace whose files are on this machine holds -//! one, and so does the `tty7-server` process serving a *remote* workspace to -//! someone else's client. That second role is why it is written the way it is — -//! blocking, allocation-frugal, and with every semantic decision (sort order, -//! gitignore scoring, the search walk's bounds) made *here* rather than by the -//! caller, so that a remote workspace gets byte-identical answers to a local -//! one without the client and the server having to agree on anything but the -//! wire. -//! -//! Two pieces of state, both about not repeating work: -//! -//! - the compiled `.gitignore` matchers, shared across every listing rather -//! than shuttled to a worker and back the way the file tree used to before a -//! host existed to own them; -//! - nothing else. A host is otherwise a pure function of the filesystem. - use std::collections::{HashMap, HashSet, VecDeque}; use std::fs; use std::io; @@ -33,27 +14,13 @@ use crate::host::{ WatchSub, guard_off_ui, }; -/// How long changes are collected before a batch is delivered. Matched exactly -/// by the remote implementation — see [`WatchSub::events`]. const COALESCE_WINDOW: Duration = Duration::from_millis(100); -/// This machine's filesystem and git. pub struct LocalHost { - /// Compiled `.gitignore` matchers, keyed by the directory each came from. - /// - /// Behind an `Arc` as well as a `Mutex` so the watcher's coalescing thread - /// can hold the same cache and clear it when a `.gitignore` is edited — - /// which is the only thing that can invalidate a compiled matcher, and the - /// watcher is the only place that finds out. gitignore: Arc<Mutex<GitignoreChain>>, } impl LocalHost { - /// A new local host. - /// - /// Returns the trait object directly: nothing in the tree wants a concrete - /// `LocalHost`, and handing one out would invite a call site to depend on - /// something a remote host cannot do. #[allow(clippy::new_ret_no_self)] pub fn new() -> SharedHost { Arc::new(LocalHost { @@ -61,36 +28,19 @@ impl LocalHost { }) } - /// The process-wide local host. - /// - /// One instance, so the gitignore cache is shared by every local workspace - /// instead of being recompiled per tab. Workspaces take their host from - /// here rather than constructing their own. pub fn shared() -> SharedHost { static LOCAL: OnceLock<SharedHost> = OnceLock::new(); LOCAL.get_or_init(LocalHost::new).clone() } - /// List `dir`, keeping each entry's full path — the shape `search` needs - /// and `read_dir` throws away. fn list(&self, dir: &Path, root: Option<&Path>) -> io::Result<Vec<(Entry, PathBuf)>> { - // Two passes, because the second needs a lock the first must not hold. - // The file tree asks for every root and every expanded directory in one - // frame, so a dozen of these run at once; holding the shared matcher - // cache across the `readdir` syscalls would serialize work that has no - // reason to be serial. let mut out: Vec<(Entry, PathBuf)> = Vec::new(); for e in fs::read_dir(dir)?.flatten() { let path = e.path(); let name = e.file_name().to_string_lossy().into_owned(); - // `DirEntry::file_type` is free on Unix (it comes out of `readdir`) - // but does *not* follow links, and a link to a directory has to - // read as a directory — that is what the tree expands and what the - // sort puts first. So pay for the follow only on links. let ft = e.file_type().ok(); let is_symlink = ft.is_some_and(|t| t.is_symlink()); let is_dir = if is_symlink { - // A broken link resolves to nothing: not a directory. fs::metadata(&path).map(|m| m.is_dir()).unwrap_or(false) } else { ft.is_some_and(|t| t.is_dir()) @@ -106,8 +56,6 @@ impl LocalHost { )); } - // `.git` is ignored whatever the patterns say; everything else is scored - // against the chain, and only when there is a root to bound it. let mut chain = self.gitignore.lock().unwrap_or_else(|e| e.into_inner()); for (entry, path) in &mut out { entry.ignored = entry.name == ".git" @@ -120,11 +68,6 @@ impl LocalHost { } } -/// Directories first, then case-insensitive by name. -/// -/// Dotfiles keep their leading dot in that ordering, so they sort before -/// letters — which is where users expect them, and what the file tree has -/// always done. fn sort_entries(entries: &mut [(Entry, PathBuf)]) { entries.sort_by(|(a, _), (b, _)| { b.is_dir @@ -143,8 +86,6 @@ impl Host for LocalHost { } fn join(&self, dir: &Path, name: &str) -> PathBuf { - // Native semantics locally: `Path::join` already knows this platform's - // rules, including the ones a separator alone doesn't capture. dir.join(name) } @@ -159,9 +100,6 @@ impl Host for LocalHost { fn stat(&self, p: &Path) -> io::Result<Meta> { guard_off_ui(); - // `symlink_metadata` first: it answers `is_symlink` and, for the - // overwhelmingly common non-link, is also the answer — one syscall - // instead of two. let lmd = fs::symlink_metadata(p)?; let is_symlink = lmd.file_type().is_symlink(); let md = if is_symlink { fs::metadata(p)? } else { lmd }; @@ -183,8 +121,6 @@ impl Host for LocalHost { format!("{} is a directory", p.display()), )); } - // Checked before reading, not after: the whole point of the limit is - // that an oversized file is never carried anywhere. if md.len() > max_bytes { return Err(io::Error::new( io::ErrorKind::FileTooLarge, @@ -214,28 +150,18 @@ impl Host for LocalHost { guard_off_ui(); let needle = query.to_lowercase(); let mut out: Vec<SearchHit> = Vec::new(); - // Shared across roots: the budget bounds the *search*, not each root, - // so a workspace with six roots cannot walk six times as far. let mut visited = 0usize; for root in roots { - // A deque, not a `Vec` with `remove(0)`: the frontier of a wide tree - // gets long and shifting it down per pop is quadratic. let mut queue: VecDeque<PathBuf> = VecDeque::from([root.clone()]); while let Some(dir) = queue.pop_front() { if out.len() >= limit || visited >= max_dirs { break; } visited += 1; - // An unreadable directory is skipped, not fatal — a search that - // aborted on the first permission-denied subdirectory would be - // useless on any real machine. let Ok(entries) = self.list(&dir, Some(root)) else { continue; }; for (e, path) in entries { - // `.git`, `target`, `node_modules`: where the file count - // explodes and never where anyone is searching. Skipping - // them is what keeps the directory budget meaningful. if !show_hidden && (e.ignored || e.name.starts_with('.')) { continue; } @@ -262,10 +188,6 @@ impl Host for LocalHost { fn write_file(&self, p: &Path, bytes: &[u8]) -> io::Result<Meta> { guard_off_ui(); fs::write(p, bytes)?; - // Stat immediately after, on the same thread that just wrote: the - // remote peer answers from the same place, so both hosts report the - // metadata the write itself produced rather than whatever a later - // caller happens to observe. self.stat(p) } @@ -285,10 +207,6 @@ impl Host for LocalHost { fn rename(&self, from: &Path, to: &Path) -> io::Result<()> { guard_off_ui(); - // `fs::rename` overwrites silently on Unix, and this API promises it - // doesn't. `symlink_metadata` rather than `exists` so a dangling - // symlink at the destination still counts as occupied — clobbering one - // would destroy a link the user can see in the tree. if fs::symlink_metadata(to).is_ok() { return Err(io::Error::new( io::ErrorKind::AlreadyExists, @@ -300,9 +218,6 @@ impl Host for LocalHost { fn remove(&self, p: &Path, recursive: bool) -> io::Result<()> { guard_off_ui(); - // `symlink_metadata`, so a symlink pointing at a directory is unlinked - // rather than recursed into — deleting a link must never delete what it - // points at. let md = fs::symlink_metadata(p)?; if md.is_dir() { if recursive { @@ -317,8 +232,6 @@ impl Host for LocalHost { fn repo_root(&self, p: &Path) -> io::Result<Option<PathBuf>> { guard_off_ui(); - // `.git` is a directory in a normal checkout and a *file* in a linked - // worktree, so test for existence rather than for a directory. Ok(p.ancestors() .find(|a| a.join(".git").exists()) .map(Path::to_path_buf)) @@ -329,8 +242,6 @@ impl Host for LocalHost { git::git_output(cwd, args) } - /// Straight off the pipe: this machine's git writes into a buffer we drain - /// as it fills, so a multi-megabyte diff never exists as one allocation. fn git_lines( &self, cwd: &Path, @@ -358,34 +269,13 @@ impl Host for LocalHost { } } -// --------------------------------------------------------------------------- -// Watching -// --------------------------------------------------------------------------- - -/// The watched set, in both the form the caller gave and the form the platform -/// reports events in. -/// -/// macOS' FSEvents canonicalizes: a watch on `/var/folders/…` reports -/// `/private/var/folders/…`. Without the second form every event would look -/// like it came from somewhere unwatched; without the first, callers would get -/// back paths they cannot match against the ones they asked about. So both are -/// kept and events are rewritten into the caller's vocabulary on the way out. #[derive(Default)] struct WatchedDirs { - /// Canonical form → the form the caller used. by_canonical: HashMap<PathBuf, PathBuf>, - /// Exactly what the caller asked for, for `set_dirs` diffing. given: HashSet<PathBuf>, } impl WatchedDirs { - /// The caller-facing path for an event on `p`, or `None` when `p` is not in - /// (or directly under) a watched directory. - /// - /// This filter is what makes the subscription non-recursive regardless of - /// backend: FSEvents is inherently recursive and notify only filters on a - /// best-effort basis, so the guarantee is enforced here rather than - /// assumed. fn translate(&self, p: &Path) -> Option<PathBuf> { if let Some(parent) = p.parent() && let Some(given) = self.by_canonical.get(parent) @@ -395,28 +285,12 @@ impl WatchedDirs { None => Some(given.clone()), }; } - // The watched directory itself (created, removed, renamed). self.by_canonical.get(p).cloned() } } -/// A live local watch: the notify watcher plus the set it is following. struct LocalWatch { inner: Mutex<LocalWatchInner>, - /// The delivery end, kept solely so dropping this handle can close it. - /// - /// Tearing the watcher down is not instantaneous — the OS backend has its - /// own thread, and on Windows a `ReadDirectoryChangesW` completion can fire - /// *during* teardown, reach the event closure while `raw_tx` is still - /// alive, and be forwarded by a coalescer that has not noticed the - /// disconnect yet. A consumer holding a clone of the receiver would then - /// see an event for a change made after it unsubscribed. - /// - /// Closing the channel here makes "dropped" mean "no further batches" at - /// the instant of the drop, whatever the backend does afterwards. Batches - /// already queued stay readable — `close` stops sends, not receives — which - /// is the one thing a consumer racing its own drop may legitimately still - /// see. batch_tx: smol::channel::Sender<Vec<PathBuf>>, } @@ -442,14 +316,8 @@ impl WatchHandle for LocalWatch { let _ = watcher.unwatch(gone); } let added: Vec<PathBuf> = want.difference(&set.given).cloned().collect(); - // Rebuild rather than patch: `by_canonical` is keyed by a form we do not - // hold the inverse of, and the set is at most a few dozen expanded - // directories. set.by_canonical.clear(); for d in &added { - // A directory that has just been deleted is not an error worth - // failing the whole re-subscription over — the next listing will - // notice it is gone. let _ = watcher.watch(d, RecursiveMode::NonRecursive); } for d in &want { @@ -462,12 +330,6 @@ impl WatchHandle for LocalWatch { } } -/// Build a watch over `dirs`, coalescing events into 100ms batches. -/// -/// `gitignore` is cleared whenever a batch contains a `.gitignore`, which is the -/// only event that can invalidate a compiled matcher. Doing it here rather than -/// asking callers to remember means a remote client gets the same invalidation -/// for free: the server's own host is the one watching. fn local_watch(dirs: &[PathBuf], gitignore: Arc<Mutex<GitignoreChain>>) -> io::Result<WatchSub> { let (raw_tx, raw_rx) = std::sync::mpsc::channel::<Vec<PathBuf>>(); let (batch_tx, batch_rx) = smol::channel::unbounded::<Vec<PathBuf>>(); @@ -477,8 +339,6 @@ fn local_watch(dirs: &[PathBuf], gitignore: Arc<Mutex<GitignoreChain>>) -> io::R if let Ok(ev) = res && !ev.paths.is_empty() { - // A closed receiver means the subscription was dropped; the watcher - // is on its way out too, so there is nothing to report. let _ = raw_tx.send(ev.paths); } }) @@ -493,8 +353,6 @@ fn local_watch(dirs: &[PathBuf], gitignore: Arc<Mutex<GitignoreChain>>) -> io::R }; handle.set_dirs(dirs)?; - // The coalescer. It ends when the watcher is dropped: that drops the event - // closure, which drops `raw_tx`, which disconnects this receiver. std::thread::Builder::new() .name("tty7-host-watch".into()) .spawn(move || coalesce(raw_rx, batch_tx, watched, gitignore)) @@ -503,11 +361,6 @@ fn local_watch(dirs: &[PathBuf], gitignore: Arc<Mutex<GitignoreChain>>) -> io::R Ok(WatchSub::new(batch_rx, Box::new(handle))) } -/// Collect raw events into deduplicated 100ms batches. -/// -/// The window exists so that a `cargo build` touching ten thousand files is one -/// repaint rather than ten thousand, and it is identical on the remote side so -/// that where the files live cannot change how the tree behaves. fn coalesce( raw_rx: std::sync::mpsc::Receiver<Vec<PathBuf>>, batch_tx: smol::channel::Sender<Vec<PathBuf>>, @@ -515,7 +368,6 @@ fn coalesce( gitignore: Arc<Mutex<GitignoreChain>>, ) { loop { - // Block until something happens at all — an idle watch costs nothing. let Ok(first) = raw_rx.recv() else { return }; let mut seen: HashSet<PathBuf> = HashSet::new(); let mut batch: Vec<PathBuf> = Vec::new(); @@ -539,7 +391,6 @@ fn coalesce( match raw_rx.recv_timeout(left) { Ok(paths) => take(paths, &mut batch, &mut seen), Err(std::sync::mpsc::RecvTimeoutError::Timeout) => break, - // Watcher gone: deliver what we have, then stop. Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { if !batch.is_empty() { let _ = batch_tx.send_blocking(batch); @@ -552,9 +403,6 @@ fn coalesce( if batch.is_empty() { continue; } - // A `.gitignore` edit changes the answer for every path under it, so the - // compiled matchers all go. Cheap: they recompile lazily, per directory, - // on the next listing that needs one. if batch .iter() .any(|p| p.file_name().is_some_and(|n| n == ".gitignore")) @@ -567,8 +415,6 @@ fn coalesce( } } -/// notify's error type carries an `io::Error` for the cases that have one; the -/// rest become `Other` with the message preserved. fn notify_to_io(e: notify::Error) -> io::Error { match e.kind { notify::ErrorKind::Io(io) => io, @@ -581,7 +427,6 @@ mod tests { use super::*; use crate::host::conformance::Sandbox; - /// A temp directory that satisfies the conformance sandbox contract. struct TempSandbox(tempfile::TempDir); impl Sandbox for TempSandbox { @@ -596,8 +441,6 @@ mod tests { } #[cfg(not(unix))] { - // Windows needs a privilege we cannot assume in CI; the cases - // that want a symlink skip instead of failing. let _ = (target, link); None } @@ -611,14 +454,8 @@ mod tests { ) } - // Every case in the shared suite, run against `LocalHost`. `RemoteHost` and - // the stdio server run the identical list; a divergence between them is - // exactly what this exists to catch. crate::host_conformance_suite!(local, sandbox); - /// The sort is the file tree's, verbatim: directories first, then - /// case-insensitively by name, with dotfiles keeping their leading dot (so - /// `.gitignore` sorts before `main.rs`). #[test] fn sort_matches_the_file_trees_order() { let mut v: Vec<(Entry, PathBuf)> = ["main.rs", "Cargo.toml", ".gitignore", "src", "Zeta"] @@ -643,17 +480,12 @@ mod tests { ); } - /// The process-wide host is one instance, so every local workspace shares - /// the gitignore cache rather than recompiling per tab. #[test] fn shared_is_a_singleton() { assert!(Arc::ptr_eq(&LocalHost::shared(), &LocalHost::shared())); assert!(LocalHost::shared().id().is_local()); } - /// The gitignore chain the file tree used to hand back and forth now lives - /// in the host — and scores the same fixture the same way: deepest match - /// wins, `!` un-ignores, `.git` is ignored whatever the patterns say. #[test] fn gitignore_chain_scores_the_file_tree_fixture() { let (h, tmp) = sandbox(); @@ -684,8 +516,6 @@ mod tests { assert!(!ignored(&nested, "keep.log"), "whitelist un-ignores"); assert!(!ignored(&nested, "main.rs")); - // And the search agrees with the listing: the ignored `.log` stays out, - // the whitelisted one comes back. let hits = h .search(&[root.to_path_buf()], "log", 200, 2000, false) .unwrap(); @@ -693,8 +523,6 @@ mod tests { assert_eq!(names, vec!["keep.log"]); } - /// Editing a `.gitignore` has to change the answer, and the only thing that - /// finds out is the watcher — so the invalidation rides along with it. #[test] fn a_gitignore_edit_through_the_watcher_clears_the_cache() { let (h, tmp) = sandbox(); @@ -706,11 +534,6 @@ mod tests { let sub = h.watch(&[root.clone()]).unwrap(); - // Poll rather than block on the channel, and re-write each round. - // FSEvents registers asynchronously, so a write landing in the first - // few milliseconds after `watch` can simply never be reported — and a - // test that blocked waiting for that event would hang forever rather - // than fail. let deadline = Instant::now() + Duration::from_secs(15); let mut cleared = false; while Instant::now() < deadline { diff --git a/crates/tty7-core/src/host/mod.rs b/crates/tty7-core/src/host/mod.rs index a1f67078..5e59f244 100644 --- a/crates/tty7-core/src/host/mod.rs +++ b/crates/tty7-core/src/host/mod.rs @@ -1,40 +1,3 @@ -//! [`Host`]: the machine a workspace's files and git live on. -//! -//! Every filesystem read, every write, every `git` shell-out and every file -//! watch tty7 performs on behalf of a workspace goes through this one trait, so -//! that "the files are on this laptop" and "the files are on a box in another -//! datacentre" differ by which `Arc<dyn Host>` the workspace holds and by -//! nothing else. [`local::LocalHost`] is the implementation that answers with -//! `std::fs`; `host::remote::RemoteHost` answers over the control connection; -//! and both are checked against the same [`conformance`] suite, because a -//! difference between them is a bug that only shows up on someone else's -//! machine. -//! -//! # Blocking on purpose -//! -//! Every method blocks. That is a decision, not an oversight: the trait -//! has to be -//! object-safe because the whole tree holds `Arc<dyn Host>`, the server side -//! serves these same calls from a blocking thread pool, and a GPUI -//! `&mut Context<T>` cannot be held across an `.await` anyway — so making the -//! trait async would box every `LocalHost::stat` (the 99% path) without saving -//! a single call site from being restructured. -//! -//! The consequence is a rule: **no `Host` method may be called on the UI -//! thread.** The GUI reaches a host only through `ui::host_ops::HostOps`, which -//! does the `spawn` → `background_spawn` → `update` dance. [`guard_off_ui`] -//! turns a violation into a debug-build panic at the call site rather than a -//! dropped frame nobody can attribute. -//! -//! # Paths belong to the host, not to `std::path` -//! -//! A Windows client talking to a Linux host has to build `/home/me/src`, but -//! `PathBuf::join` would give it `/home/me\src` and `Path::is_absolute` would -//! call `/home/me` relative. So path arithmetic that crosses the boundary goes -//! through [`Host::join`] and [`Host::is_absolute`], which answer with the -//! *host's* semantics. `parent`, `file_name`, `starts_with` and friends are -//! fine as-is — Windows' `std::path` already treats `/` as a separator. - pub mod conformance; pub mod local; pub mod remote; @@ -48,52 +11,22 @@ use std::thread::ThreadId; pub use crate::core::shells::ShellInventory; -// --------------------------------------------------------------------------- -// Identity -// --------------------------------------------------------------------------- - -/// A stable, in-process identifier for one `Arc<dyn Host>`. -/// -/// **Never persisted.** It exists so that structures which cannot hold an -/// `Arc<dyn Host>` — the git-status cache's path tables, pane records, the -/// in-flight maps — can still say *which* machine a path belongs to, and so -/// that two identical paths on two different machines never collide in one map. -/// -/// [`HostId::LOCAL`] is `0` and always means this machine. Remote ids are -/// derived from the **connection**, not the workspace: several workspaces on -/// one remote box share an id, matching the granularity at which the SSH -/// connection itself is shared. #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, PartialOrd, Ord)] pub struct HostId(pub u64); impl HostId { - /// This machine. Reserved: no derivation ever produces it. pub const LOCAL: HostId = HostId(0); - /// Derive an id from a normalized connection key. - /// - /// `key` must be the canonical connection string for the machine - /// (`ssh-profile:<uuid>`, `ssh-alias:<alias>`, `ssh-direct:<user>@<host>:<port>`, - /// `wsl:<distro>`) so that two references to the same box always hash the - /// same. A hash of exactly `0` is bumped to `1`, because `0` is local's. pub fn from_connection_key(key: &str) -> HostId { let h = fnv1a64(key.as_bytes()); HostId(if h == 0 { 1 } else { h }) } - /// Whether this is [`HostId::LOCAL`]. pub fn is_local(self) -> bool { self == HostId::LOCAL } } -/// Deterministic 64-bit FNV-1a — the same one `daemon::transport` keys its -/// fallback socket path with. -/// -/// Not `DefaultHasher`: ids derived here are compared against ids derived by a -/// *different build* of tty7 (a daemon outlives an app upgrade; a remote server -/// is its own binary), so the function has to be stable across compiler and -/// std versions, which `DefaultHasher` explicitly is not. pub fn fnv1a64(bytes: &[u8]) -> u64 { let mut h: u64 = 0xcbf2_9ce4_8422_2325; for &b in bytes { @@ -103,36 +36,18 @@ pub fn fnv1a64(bytes: &[u8]) -> u64 { h } -// --------------------------------------------------------------------------- -// The UI-thread guard -// --------------------------------------------------------------------------- - static UI_THREAD: OnceLock<ThreadId> = OnceLock::new(); -/// Record the calling thread as the UI thread, so [`guard_off_ui`] has -/// something to compare against. Idempotent; later calls are ignored. -/// -/// `ui::host_ops` calls this on its way through, which is enough: everything it -/// runs on runs on the UI thread by construction, and until it is called the -/// guard simply never fires (a headless `tty7-server` has no UI thread and -/// wants none of this). pub fn register_ui_thread() { let _ = UI_THREAD.set(std::thread::current().id()); } -/// Whether the calling thread is the one [`register_ui_thread`] claimed. pub fn is_ui_thread() -> bool { UI_THREAD .get() .is_some_and(|t| *t == std::thread::current().id()) } -/// Panic (debug builds only) if a blocking `Host` call is happening on the UI -/// thread. -/// -/// Deliberately not `#[cfg(debug_assertions)]` on the *function* — call sites -/// would then need their own `cfg`, and one forgotten `cfg` is one unguarded -/// method. `debug_assert!` already compiles the check away in release. #[inline] pub fn guard_off_ui() { debug_assert!( @@ -141,72 +56,32 @@ pub fn guard_off_ui() { ); } -// --------------------------------------------------------------------------- -// Value types -// --------------------------------------------------------------------------- - -/// One entry of a directory listing. -/// -/// **No `path` field, on purpose.** The caller rebuilds it with -/// [`Host::join`]: a remote entry's path uses the *remote's* separator, and a -/// `PathBuf` assembled on a Windows client would use a backslash the remote has -/// never heard of. #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct Entry { - /// The file name, lossily decoded when the host's filesystem holds bytes - /// that are not valid UTF-8. pub name: String, - /// Whether the entry *resolves to* a directory — symlinks followed, so a - /// link to a directory is `true` here and `true` in `is_symlink` both. pub is_dir: bool, - /// Whether the entry is itself a symbolic link. pub is_symlink: bool, - /// The host's own gitignore verdict, computed against the chain of - /// `.gitignore` files from the listing's `root` down. `.git` is always - /// `true`. Always `false` when the listing had no `root`, or when the host - /// has no git. pub ignored: bool, } -/// What a `stat` answers. -/// -/// Deliberately not `std::fs::Metadata`, which can be neither constructed nor -/// serialized — a remote host has to be able to hand one across a wire. #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct Meta { - /// Whether the path resolves to a directory (symlinks followed). pub is_dir: bool, - /// Whether the path is itself a symbolic link. pub is_symlink: bool, - /// Size in bytes. pub len: u64, - /// `None` when the platform or filesystem has no modification time. pub mtime: Option<MTime>, - /// Whether the permission bits say read-only. pub readonly: bool, } -/// A modification time, to the nanosecond. -/// -/// Nanoseconds rather than milliseconds because the code editor detects -/// external edits by asking "is the mtime still the one I wrote?" — at -/// millisecond granularity a real edit landing in the same millisecond as our -/// own write is indistinguishable from our own write, and gets swallowed. -/// Two fields rather than a `u128` because JSON cannot carry a `u128` without -/// losing precision, and this type crosses a JSON wire. #[derive( Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize, )] pub struct MTime { - /// Whole seconds since the Unix epoch; negative before 1970. pub secs: i64, - /// Nanoseconds within the second, `0..1_000_000_000`. pub nanos: u32, } impl MTime { - /// Convert from a `SystemTime`, keeping pre-epoch times exact rather than - /// clamping them to zero. pub fn from_system_time(t: std::time::SystemTime) -> MTime { match t.duration_since(std::time::UNIX_EPOCH) { Ok(d) => MTime { @@ -214,7 +89,6 @@ impl MTime { nanos: d.subsec_nanos(), }, Err(e) => { - // Before the epoch: `duration_since` hands back how far before. let d = e.duration(); let (secs, nanos) = if d.subsec_nanos() == 0 { (-(d.as_secs() as i64), 0) @@ -227,34 +101,15 @@ impl MTime { } } -/// What one child-process run produced. -/// -/// Deliberately not `std::process::Output`: its `ExitStatus` cannot be -/// constructed portably, and a remote host has to synthesize one from a wire -/// message. #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct Output { - /// The exit code, or `None` when the process was killed by a signal or the - /// code could not be obtained. pub status: Option<i32>, - /// Raw stdout, base64 on the wire. - /// - /// Not a plain `Vec<u8>`: `serde_json` has no byte type and renders one as - /// an array of decimal numbers, so a 1 MB `git diff` would cross the wire as - /// roughly 4 MB of JSON. (`serde_bytes` does not help here — it forwards to - /// `serialize_bytes`, which `serde_json` implements as exactly that array.) #[serde(with = "b64")] pub stdout: Vec<u8>, - /// Raw stderr, same encoding. #[serde(with = "b64")] pub stderr: Vec<u8>, } -/// `Vec<u8>` ⇄ base64 string, for the byte fields that cross a JSON wire. -/// -/// Shared with the control dialect ([`crate::daemon::control::ControlEvent::GitChunk`]) -/// rather than duplicated there: every byte field on that wire has the same -/// hazard, and one encoding is one thing to get right. pub(crate) mod b64 { use base64::Engine as _; use base64::engine::general_purpose::STANDARD; @@ -271,174 +126,73 @@ pub(crate) mod b64 { } impl Output { - /// Exited zero. pub fn success(&self) -> bool { self.status == Some(0) } - /// stdout as lossy UTF-8, trimmed — the shape every git call site wants. pub fn stdout_trimmed(&self) -> String { String::from_utf8_lossy(&self.stdout).trim().to_string() } - /// stderr as lossy UTF-8, trimmed — the shape error messages want. pub fn stderr_trimmed(&self) -> String { String::from_utf8_lossy(&self.stderr).trim().to_string() } } -/// One hit from [`Host::search`]. Unlike [`Entry`] this *does* carry a path: -/// hits come from directories the caller never listed, so there is nothing to -/// join against — the host, which knows its own separator, builds it. #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct SearchHit { - /// The file name that matched. pub name: String, - /// The absolute path, in the host's own separator. pub path: PathBuf, - /// Whether the hit is a directory. pub is_dir: bool, - /// Whether the hit is gitignored. pub ignored: bool, } -// --------------------------------------------------------------------------- -// Watching -// --------------------------------------------------------------------------- - -/// A live subscription to filesystem changes. -/// -/// Long-lived and *mutable*: the file tree changes which directories it cares -/// about every time a row is expanded, and tearing the subscription down and -/// rebuilding it would cost a round trip plus a rebuilt server-side watcher for -/// every disclosure triangle. So the subscription outlives the set, and -/// [`WatchSub::set_dirs`] replaces the set in place. -/// -/// Dropping it unsubscribes. pub struct WatchSub { rx: smol::channel::Receiver<Vec<PathBuf>>, inner: Box<dyn WatchHandle>, } impl WatchSub { - /// Build a subscription from its two halves. Implementations of - /// [`Host::watch`] call this; nothing else needs to. pub fn new(rx: smol::channel::Receiver<Vec<PathBuf>>, inner: Box<dyn WatchHandle>) -> WatchSub { WatchSub { rx, inner } } - /// The batched event stream. - /// - /// Batches are coalesced over a 100ms window and deduplicated within it — - /// **by every implementation, identically**. A local watcher is not allowed - /// to be "helpfully" more immediate than a remote one, because then the - /// consumer's idea of how often it repaints would depend on where the files - /// happen to live. pub fn events(&self) -> &smol::channel::Receiver<Vec<PathBuf>> { &self.rx } - /// Replace the watched set wholesale. The implementation works out the - /// difference; the caller only ever states the full set. - /// - /// Always **non-recursive**: a directory being watched says nothing about - /// its subdirectories. pub fn set_dirs(&self, dirs: &[PathBuf]) -> io::Result<()> { self.inner.set_dirs(dirs) } } -/// The implementation half of a [`WatchSub`]: whatever has to be told when the -/// watched set changes, and whatever has to be torn down on drop. pub trait WatchHandle: Send + Sync { - /// Replace the watched directory set. fn set_dirs(&self, dirs: &[PathBuf]) -> io::Result<()>; } -// --------------------------------------------------------------------------- -// Host -// --------------------------------------------------------------------------- - -/// A machine's filesystem and git, behind one blocking, object-safe interface. -/// -/// See the module docs for why it blocks and why paths go through -/// [`join`](Host::join) / [`is_absolute`](Host::is_absolute) rather than -/// `std::path`. pub trait Host: Send + Sync + 'static { - // ----- identity -------------------------------------------------------- - - /// This host's in-process id. fn id(&self) -> HostId; - /// The path separator this host's filesystem uses: the platform's own for a - /// local host, `/` for a remote Linux or WSL one. fn separator(&self) -> char; - // ----- path arithmetic ------------------------------------------------- - - /// `dir` + `name`, using this host's separator. - /// - /// Use this instead of `Path::join` for any path that might belong to a - /// remote host — `PathBuf::join` uses the *client's* separator, which on a - /// Windows client talking to Linux produces `/home/me\src`. fn join(&self, dir: &Path, name: &str) -> PathBuf { default_join(dir, name, self.separator()) } - /// Whether `p` is absolute *in this host's semantics*. - /// - /// Use this instead of `Path::is_absolute`: on a Windows client - /// `Path::new("/home/me").is_absolute()` is `false` (it reads as - /// drive-relative), which would silently mis-classify every remote POSIX - /// path. fn is_absolute(&self, p: &Path) -> bool; - // ----- reading --------------------------------------------------------- - - /// List `dir`, **already sorted**: directories first, then case-insensitive - /// by name. Sorting is the host's job so that a remote listing arrives - /// ready to render and the order can never drift between hosts. - /// - /// `root` bounds the gitignore chain: each entry's `ignored` is scored by - /// walking `.gitignore` files from `root` down to `dir`, deepest match - /// winning and `!` whitelists un-ignoring. With `root == None` nothing is - /// ignored except `.git` itself. - /// - /// Hidden files are **not** filtered — "show hidden" is a UI preference and - /// stays on the client. fn read_dir(&self, dir: &Path, root: Option<&Path>) -> io::Result<Vec<Entry>>; - /// Metadata for `p`, symlinks followed. fn stat(&self, p: &Path) -> io::Result<Meta>; - /// Whether `p` exists. Separate from `stat` so an implementation can answer - /// in one round trip instead of shipping metadata nobody asked for. fn exists(&self, p: &Path) -> bool { self.stat(p).is_ok() } - /// Read `p` whole. - /// - /// `max_bytes` is enforced **by the host**: a file over the limit fails with - /// [`io::ErrorKind::FileTooLarge`] without its contents being transferred, - /// rather than being shipped across an ocean and then discarded. fn read_file(&self, p: &Path, max_bytes: u64) -> io::Result<Vec<u8>>; - /// Resolve `p` to an absolute path with symlinks and `..` resolved, on the - /// host's own filesystem. fn canonicalize(&self, p: &Path) -> io::Result<PathBuf>; - /// Breadth-first substring search over file names, **executed on the - /// host**. - /// - /// Running this client-side would mean up to `max_dirs` separate directory - /// listings; at 200ms of round trip each that is a search which takes - /// minutes. So the whole walk goes to the host and only the hits come back. - /// - /// The walk starts at each of `roots`, visits at most `max_dirs` - /// directories in total, stops at `limit` hits, and — when `show_hidden` is - /// false — never descends into an ignored or dot-prefixed directory, which - /// is what keeps `node_modules` and `target` from eating the whole budget. fn search( &self, roots: &[PathBuf], @@ -448,86 +202,20 @@ pub trait Host: Send + Sync + 'static { show_hidden: bool, ) -> io::Result<Vec<SearchHit>>; - // ----- writing --------------------------------------------------------- - - /// Write `bytes` to `p`, creating or truncating it, and answer the file's - /// post-write [`Meta`]. A missing parent directory is an error, not - /// something to create. - /// - /// **Why it returns `Meta` rather than `()`.** The editor keeps a - /// `disk_mtime` baseline to tell its own write apart from someone else's - /// edit. Taking that baseline from a *separate* `stat` after the write - /// leaves a window: a change landing in between is stamped as ours, and the - /// editor then never reports it — silent, and it costs the user their - /// conflict prompt. The post-write metadata is the write's own answer, so - /// it closes the window by construction. It is also one round trip instead - /// of two on every remote save; the control reply already carried it - ///, so nothing on the wire moved. - /// - /// Callers that genuinely don't want it write `.map(|_| ())`. fn write_file(&self, p: &Path, bytes: &[u8]) -> io::Result<Meta>; - /// Create `p` as an empty file, failing with - /// [`io::ErrorKind::AlreadyExists`] if anything is already there. fn create_file_new(&self, p: &Path) -> io::Result<()>; - /// Create directory `p`. With `recursive`, create missing parents too and - /// treat an existing directory as success. fn create_dir(&self, p: &Path, recursive: bool) -> io::Result<()>; - /// Move `from` to `to`. - /// - /// An existing `to` is [`io::ErrorKind::AlreadyExists`], **guaranteed by - /// the implementation** — a caller that probed first would be paying an - /// extra round trip for a check that is racy anyway. fn rename(&self, from: &Path, to: &Path) -> io::Result<()>; - /// Remove `p`. `recursive` only means anything for a directory; a - /// non-empty directory without it is - /// [`io::ErrorKind::DirectoryNotEmpty`]. fn remove(&self, p: &Path, recursive: bool) -> io::Result<()>; - // ----- git ------------------------------------------------------------- - - /// The work-tree root `p` belongs to: the nearest ancestor holding a `.git` - /// (a directory, or the file a linked worktree gets). `Ok(None)` — not an - /// error — when `p` is outside any repository. - /// - /// The whole ancestor walk happens on the host; a remote implementation - /// must not climb one level per round trip. fn repo_root(&self, p: &Path) -> io::Result<Option<PathBuf>>; - /// Run `git -C <cwd> <args>` on the host. - /// - /// `Ok` means git *ran*; its exit code is in [`Output::status`], and a - /// non-zero one is a perfectly ordinary answer (`rev-parse` outside a repo - /// exits 128). `Err` means it could not be run at all — no git, missing - /// `cwd`, connection gone. - /// - /// Every implementation runs it under the same invariants: `-C` rather than - /// a current directory, `GIT_OPTIONAL_LOCKS=0`, null stdin, `GIT_DIR` and - /// `GIT_WORK_TREE` cleared, and both output streams captured. fn git(&self, cwd: &Path, args: &[&str]) -> io::Result<Output>; - /// [`git`](Self::git), delivered a line at a time. - /// - /// Same invocation, same invariants; the difference is that neither side - /// has to hold the whole output. `git diff HEAD` on a large work tree is - /// tens of megabytes and the caller keeps a small fraction of it, so - /// buffering it first is pure cost — see [`crate::core::git::git_stream`]. - /// - /// Lines arrive with their trailing `\n`/`\r` stripped and invalid UTF-8 - /// replaced. `Ok` means git ran, carrying its exit code (`None` when a - /// signal killed it); `Err` means it could not be run at all, exactly as - /// for [`git`](Self::git). - /// - /// **The default implementation buffers**, so this is never a second way to - /// reach git — every implementation still funnels through the same - /// invocation, and a host with no incremental transport simply pays the - /// memory it would have paid anyway. Both hosts that ship override it; - /// the default is what keeps a future one from having to. Overriding is an - /// optimisation, not a behaviour change: the lines a caller sees must be - /// identical either way. fn git_lines( &self, cwd: &Path, @@ -541,38 +229,15 @@ pub trait Host: Send + Sync + 'static { Ok(out.status) } - // ----- machine inventory ----------------------------------------------- - - /// The shells this host can launch, plus which one a plain new tab lands - /// on — the new-tab dropdown's menu. - /// - /// On the trait rather than beside `detect_shells` because a window bound to - /// a remote workspace opens its tabs *over there*: a picker built from this - /// computer's `/etc/shells` offers paths that don't exist on the machine the - /// spawn actually reaches. Probing is not free (Windows enumerates WSL by - /// spawning `wsl.exe`), so callers ask once per machine, not per menu open. fn shells(&self) -> io::Result<ShellInventory>; - // ----- watching -------------------------------------------------------- - - /// Open a long-lived, non-recursive watch over `dirs` (which may be empty — - /// the set can be filled in later with [`WatchSub::set_dirs`]). fn watch(&self, dirs: &[PathBuf]) -> io::Result<WatchSub>; - // ----- liveness -------------------------------------------------------- - - /// Whether the host is reachable right now. - /// - /// Always true locally. A remote host reports false while reconnecting or - /// after being taken over, and call sites use that to keep showing the last - /// good listing instead of flashing an error. fn is_connected(&self) -> bool { true } } -/// Join `name` onto `dir` with an explicit separator — the default -/// [`Host::join`], and the one a remote host uses. pub fn default_join(dir: &Path, name: &str, sep: char) -> PathBuf { let mut s = dir.to_string_lossy().into_owned(); if !s.is_empty() && !s.ends_with(sep) && !s.ends_with('/') { @@ -582,18 +247,12 @@ pub fn default_join(dir: &Path, name: &str, sep: char) -> PathBuf { PathBuf::from(s) } -/// The alias the rest of the tree uses. A workspace holds one of these; nothing -/// holds a concrete host type. pub type SharedHost = Arc<dyn Host>; #[cfg(test)] mod tests { use super::*; - /// The hash has to stay bit-for-bit what `daemon::transport` computes, or an - /// upgraded client would derive different ids than the daemon it is talking - /// to. Pinned against the published FNV-1a-64 vectors rather than against - /// our own output, so a "refactor" that changes the algorithm fails here. #[test] fn fnv1a64_matches_the_published_vectors() { assert_eq!(fnv1a64(b""), 0xcbf2_9ce4_8422_2325); @@ -601,10 +260,6 @@ mod tests { assert_eq!(fnv1a64(b"foobar"), 0x8594_4171_f739_67e8); } - /// `HostId(0)` means local and nothing derived may claim it. Sweeping a few - /// thousand plausible keys is not a proof, but it is the part of the - /// reservation that could plausibly regress (someone dropping the `h == 0` - /// bump as dead code). #[test] fn zero_is_reserved_for_local() { assert!(HostId::LOCAL.is_local()); @@ -615,8 +270,6 @@ mod tests { assert!(!HostId::from_connection_key("").is_local()); } - /// Same machine, same id; different machines, different ids. This is what - /// keeps two workspaces on one remote box sharing a git-status cache. #[test] fn connection_keys_map_to_stable_ids() { let a = HostId::from_connection_key("ssh-direct:me@box:22"); @@ -625,15 +278,12 @@ mod tests { assert_ne!(a, HostId::from_connection_key("wsl:Ubuntu")); } - /// The remote separator wins, whatever the client's `std::path` thinks — - /// the whole point of not using `PathBuf::join`. #[test] fn default_join_uses_the_given_separator() { assert_eq!( default_join(Path::new("/home/me"), "src", '/'), PathBuf::from("/home/me/src") ); - // Already separated: no doubling. assert_eq!( default_join(Path::new("/"), "etc", '/'), PathBuf::from("/etc") @@ -648,13 +298,6 @@ mod tests { ); } - /// Pre-epoch times round-trip exactly rather than clamping to zero, because - /// the editor compares mtimes for equality. - /// - /// Every nanosecond figure here is a multiple of 100: a Windows - /// `SystemTime` is a FILETIME, whose tick *is* 100ns, so a finer value - /// would be rounded on the way in and the assertion would be about - /// `SystemTime`'s resolution rather than about this conversion. #[test] fn mtime_handles_both_sides_of_the_epoch() { use std::time::{Duration, UNIX_EPOCH}; @@ -680,8 +323,6 @@ mod tests { ); } - /// `Err` is "it did not run"; a non-zero exit is an ordinary `Ok`. Every - /// git call site's error handling is built on that split. #[test] fn output_success_is_exit_zero_only() { let ok = Output { @@ -706,8 +347,6 @@ mod tests { assert!(!signalled.success()); } - /// Non-UTF-8 output does not lose the run: it comes back lossy rather than - /// turning the call into an error. #[test] fn output_text_is_lossy_not_fallible() { let o = Output { @@ -718,9 +357,6 @@ mod tests { assert!(o.stdout_trimmed().contains('a')); } - /// Arbitrary bytes survive a JSON round trip, and they do it as base64 - /// rather than as an array of numbers — the difference between 1.33× and 4× - /// on a `git diff` big enough to matter. #[test] fn output_bytes_cross_json_as_base64() { let o = Output { @@ -737,8 +373,6 @@ mod tests { assert_eq!(serde_json::from_str::<Output>(&json).unwrap(), o); } - /// The listing/metadata types have to survive the same trip, since they are - /// the payload of every read RPC. #[test] fn value_types_round_trip_through_json() { let e = Entry { @@ -773,17 +407,11 @@ mod tests { assert_eq!(back, h); } - /// The guard is inert until a UI thread claims itself, which is what lets - /// `tty7-server` — and every test — call hosts from any thread. #[test] fn the_ui_guard_is_inert_without_registration() { - // No `register_ui_thread` in the server or in tests, so this is a no-op - // rather than a panic. guard_off_ui(); } - /// Object safety is not decoration: the whole tree stores `Arc<dyn Host>`, - /// and the conformance suite takes `&dyn Host` precisely to keep this true. #[test] fn host_is_object_safe() { fn takes_dyn(_h: &dyn Host) {} diff --git a/crates/tty7-core/src/host/remote.rs b/crates/tty7-core/src/host/remote.rs index 289dcc2e..7ce7e0cd 100644 --- a/crates/tty7-core/src/host/remote.rs +++ b/crates/tty7-core/src/host/remote.rs @@ -1,37 +1,3 @@ -//! [`RemoteHost`] — a [`Host`] whose filesystem is on another machine. -//! -//! Every method here is one control round trip: the `Host` call blocks its own -//! caller (which is always a background thread — see the module docs on why the -//! trait stays blocking), the request goes out with a fresh id, and -//! [`ControlClient`] wakes exactly that caller when the matching reply arrives. -//! Nothing is batched, nothing is cached, and nothing shares a queue: a -//! twenty-second `git` and a five-millisecond `read_dir` overlap freely. -//! -//! ## What this file is and isn't -//! -//! It is a **translation layer**, deliberately thin. The interesting machinery — -//! request ids, out-of-order reply matching, per-method deadlines, -//! cancellation, tearing every waiter down when the link dies — lives in -//! [`crate::daemon::control`], because the server needs the same wire and the -//! test suite needs to exercise the multiplexer without a `Host` in the -//! picture. What is left here is the mapping from a `Host` method to a -//! [`ControlRequest`] and back, plus the watch bookkeeping that has no wire -//! equivalent. -//! -//! ## Round trips are the budget -//! -//! On a transcontinental link a round trip is 150-250ms, so the count is the -//! only performance number that matters and every method is written to cost -//! exactly one: -//! -//! | Temptation | Why it is refused | -//! |---|---| -//! | `exists` as `stat().is_ok()` | The default would work, but `Exists` answers a bool without shipping metadata nobody asked for | -//! | `rename` probing `to` first | Two round trips *and* a TOCTOU. The server guarantees `AlreadyExists` | -//! | `repo_root` climbing one level per call | A twelve-deep path would cost twelve round trips; the server walks it | -//! | `search` listing directories one at a time | Up to `max_dirs` round trips — minutes. The whole walk runs on the server | -//! | `read_file` fetching then checking the size | `max_bytes` is enforced *before* the bytes move | - use std::collections::HashMap; use std::io::{self, Read, Write}; use std::path::{Path, PathBuf}; @@ -48,28 +14,16 @@ use crate::host::{ Entry, Host, HostId, Meta, Output, SearchHit, SharedHost, ShellInventory, WatchHandle, WatchSub, }; -/// A [`Host`] backed by a control connection to another machine. pub struct RemoteHost { id: HostId, client: Arc<ControlClient>, separator: char, watches: Arc<WatchRegistry>, streams: Arc<GitStreamRegistry>, - /// Ids for [`ControlRequest::GitStream`]. Client-assigned so the receiver - /// can be registered before the request is sent; unique per connection is - /// all they need to be. next_stream: AtomicU64, } impl RemoteHost { - /// Handshake over an already-connected duplex link and build the host. - /// - /// `r` and `w` are the two halves of one stream — a `try_clone`d socket, or - /// a child process's stdout and stdin. `connection_key` is the normalized - /// connection string the [`HostId`] is derived from (`ssh-alias:box`, - /// `wsl:Ubuntu`, …); it deliberately excludes the workspace, so several - /// workspaces on one machine share one id and therefore one git-status - /// cache. pub fn connect<R, W>( r: R, w: W, @@ -83,11 +37,6 @@ impl RemoteHost { Self::connect_with(r, w, None, connection_key, hello) } - /// [`RemoteHost::connect`] over a TCP socket, with link shutdown wired up. - /// - /// Prefer this wherever the transport has a shutdown. Without one, dropping - /// the host cannot wake its reader thread, so the drop costs a grace period - /// and leaves the thread behind — see [`LinkShutdown`]. pub fn over_tcp( sock: std::net::TcpStream, connection_key: &str, @@ -98,8 +47,6 @@ impl RemoteHost { Self::connect_with(r, sock, Some(closer), connection_key, hello) } - /// [`RemoteHost::connect`] over a Unix-domain socket, with link shutdown - /// wired up. #[cfg(unix)] pub fn over_unix( sock: std::os::unix::net::UnixStream, @@ -111,8 +58,6 @@ impl RemoteHost { Self::connect_with(r, sock, Some(closer), connection_key, hello) } - /// The full form. `shutdown` is what lets dropping this host actually close - /// the link rather than orphan its reader. pub fn connect_with<R, W>( r: R, w: W, @@ -124,35 +69,23 @@ impl RemoteHost { R: Read + Send + 'static, W: Write + Send + 'static, { - // The event sink has to exist before the client, and the watch table it - // feeds has to outlive both, so the table is built first and shared - // rather than reached back into. let watches = Arc::new(WatchRegistry::default()); let sink_watches = Arc::clone(&watches); let streams: Arc<GitStreamRegistry> = Arc::new(GitStreamRegistry::default()); let sink_streams = Arc::clone(&streams); - // The id is derived here rather than read back off the host because the - // sink has to exist before the host does — and because an event that - // could not say *which machine* it came from would be useless to the - // window layer, which has one connection per machine. let id = HostId::from_connection_key(connection_key); let sink: EventSink = Box::new(move |event| match event { - // Watch pushes belong to whoever is still holding the `WatchSub`. ControlEvent::Watch { .. } | ControlEvent::WatchOverflow { .. } => { sink_watches.dispatch(event); } - // Git chunks belong to whichever thread is draining that stream. ControlEvent::GitChunk { .. } | ControlEvent::GitEnd { .. } => { sink_streams.dispatch(event); } - // Everything else is about a *window*, and this layer has none. other => crate::daemon::control::observe_event(id, other), }); let client = Arc::new(ControlClient::connect_with(r, w, shutdown, hello, sink)?); let separator = client.hello().separator; - // A stream is answered by pushes, not by a reply, so `fail_all` cannot - // see anyone waiting on one — see [`GitStreamRegistry::close_all`]. let down_streams = Arc::clone(&streams); client.on_link_down(move || down_streams.close_all()); @@ -168,24 +101,18 @@ impl RemoteHost { Ok(host) } - /// What the peer said about itself at handshake time. pub fn peer(&self) -> &ControlHelloOk { self.client.hello() } - /// The server's `$HOME`, for "new workspace defaults to `~`" — which has to - /// mean the *remote's* home, not the client's. pub fn home(&self) -> PathBuf { PathBuf::from(&self.client.hello().home) } - /// The underlying connection, for callers that need to speak control - /// directly (the machine-tree verbs). pub fn client(&self) -> &Arc<ControlClient> { &self.client } - /// Erase to the shared trait object the rest of the tree holds. pub fn into_shared(self: Arc<Self>) -> SharedHost { self } @@ -195,12 +122,6 @@ impl RemoteHost { } } -/// Render a path for the wire. -/// -/// Lossy rather than an error: a remote path is UTF-8 by construction, and the -/// only way a non-UTF-8 one reaches here is if it came *from* the server's own -/// lossy listing — in which case failing would turn a cosmetically odd filename -/// into an unusable one. fn wire_path(p: &Path) -> String { p.to_string_lossy().into_owned() } @@ -209,8 +130,6 @@ fn wire_paths(paths: &[PathBuf]) -> Vec<String> { paths.iter().map(|p| wire_path(p)).collect() } -/// A reply of the wrong shape is a server bug, and it is worth saying so -/// plainly rather than letting it surface as a confusing empty result. fn wrong_shape(expected: &str, got: &ReplyOk) -> io::Error { io::Error::new( io::ErrorKind::InvalidData, @@ -227,14 +146,9 @@ impl Host for RemoteHost { self.separator } - /// Absolute *in the peer's* terms, which is the whole reason this is a - /// trait method: a Windows client asking `Path::is_absolute` about - /// `/home/me` is told `false`, and would then treat every remote path as - /// relative. fn is_absolute(&self, p: &Path) -> bool { let s = p.to_string_lossy(); if self.separator == '\\' { - // A remote Windows host: `C:\…`, or a UNC/rooted path. let mut c = s.chars(); let drive = matches!( (c.next(), c.next(), c.next()), @@ -263,8 +177,6 @@ impl Host for RemoteHost { } } - /// One round trip that ships a bool, rather than the default's `stat` that - /// ships metadata to throw away. fn exists(&self, p: &Path) -> bool { matches!( self.call(ControlRequest::Exists { path: wire_path(p) }), @@ -273,8 +185,6 @@ impl Host for RemoteHost { } fn read_file(&self, p: &Path, max_bytes: u64) -> io::Result<Vec<u8>> { - // The content rides the frame's blob; the JSON head carries only the - // metadata, so nothing has to be re-fetched afterwards. let got = self.client.call_full( ControlRequest::ReadFile { path: wire_path(p), @@ -316,10 +226,6 @@ impl Host for RemoteHost { } fn write_file(&self, p: &Path, bytes: &[u8]) -> io::Result<Meta> { - // One round trip, not two: the reply already carries the post-write - // metadata, so the editor's mtime baseline comes from the write itself - // rather than from a follow-up `stat` an external edit could slip in - // front of. match self .client .call_with_blob(ControlRequest::WriteFile { path: wire_path(p) }, bytes)? @@ -361,10 +267,6 @@ impl Host for RemoteHost { } } - /// `Ok` means git *ran* on the server. A non-zero exit is in - /// [`Output::status`], not in the `Err` — which is what keeps - /// `git_status`'s `Option<String>` semantics identical whether the repo is - /// local or six thousand miles away. fn git(&self, cwd: &Path, args: &[&str]) -> io::Result<Output> { match self.call(ControlRequest::Git { cwd: wire_path(cwd), @@ -375,19 +277,12 @@ impl Host for RemoteHost { } } - /// Incremental, always: [`ControlRequest::GitStream`] is part of the - /// protocol, not an extension a peer may lack. Remote workspaces have never - /// shipped a release, so there is no older server to negotiate with — and a - /// capability check with a buffered fallback would be dead code pretending - /// otherwise. fn git_lines( &self, cwd: &Path, args: &[&str], on_line: &mut dyn FnMut(&str), ) -> io::Result<Option<i32>> { - // Registered *before* the request goes out, so a chunk cannot arrive - // with nowhere to go — see `ControlRequest::GitStream`. let id = self.next_stream.fetch_add(1, Ordering::Relaxed); let (tx, rx) = mpsc::channel(); let queued = Arc::new(AtomicUsize::new(0)); @@ -398,9 +293,6 @@ impl Host for RemoteHost { queued: Arc::clone(&queued), }, ); - // The receiver comes off the registry however this returns: an early - // `?` below would otherwise leak the entry for the life of the - // connection. let _guard = StreamGuard { streams: &self.streams, id, @@ -418,10 +310,6 @@ impl Host for RemoteHost { drain_git_stream(&rx, &queued, GIT_STREAM_IDLE_TIMEOUT, on_line) } - /// Safe to send unguarded: the request landed in control v2, and the - /// handshake already refused any peer on another dialect. A server too old - /// to know the variant is never on the other end of a live connection — it - /// was replaced at install time, or the connection never opened. fn shells(&self) -> io::Result<ShellInventory> { match self.call(ControlRequest::Shells)? { ReplyOk::Shells(inv) => Ok(inv), @@ -437,9 +325,6 @@ impl Host for RemoteHost { other => return Err(wrong_shape("a watch id", &other)), }; - // Unbounded so the reader thread never blocks delivering a batch: the - // server has already coalesced within its window, and the consumer is a - // UI that may be a frame or two behind. let (tx, rx) = smol::channel::unbounded(); self.watches.insert(id, tx, dirs.to_vec()); @@ -477,46 +362,8 @@ impl std::fmt::Debug for RemoteHost { } } -// --------------------------------------------------------------------------- -// Watches -// --------------------------------------------------------------------------- - -/// Bytes one stream may have sitting between the reader thread and the thread -/// draining it. -/// -/// The queue below is unbounded and its `send` never waits, deliberately: the -/// reader thread serves the *whole* connection, so parking it there would stall -/// every other reply, every watch event and the keepalive pongs — a peer that -/// out-runs one diff reader would take the link down with it. The cost of not -/// waiting is that nothing throttles the sender, and "streaming" would bound -/// what each end reads at once while letting the queue between them grow to the -/// size of the whole diff — the exact peak this path exists to remove, one -/// container further along. -/// -/// So the queue is *bounded* instead of back-pressured: past this the stream is -/// failed with [`GitStreamMsg::Overrun`] rather than served, which turns an -/// unbounded allocation into a read that says what happened. Real back-pressure -/// would need credit-based flow control in the dialect — the client telling the -/// server how much more it may push — which is a protocol change, not a -/// buffering policy, and is not what this is. -/// -/// Set far above any healthy gap. The drainer only splits lines and parses, at -/// roughly 8 MB per 12 ms, so it stays within a chunk or two of a link that is -/// merely fast; reaching 32 MiB of arrears means the consumer is wedged, not -/// busy. const GIT_STREAM_QUEUE_BUDGET: usize = 32 * 1024 * 1024; -/// Reassemble one git stream's pushes into lines, ending on `GitEnd`, on a link -/// that died, on the queue budget blowing, or on `idle` elapsing between chunks. -/// -/// Split out from [`RemoteHost::git_lines`] so the ways a stream ends are -/// reachable from a test without a socket — the timeout in particular, which -/// otherwise could only be exercised by waiting out -/// [`GIT_STREAM_IDLE_TIMEOUT`]. -/// -/// `queued` is the arrears this stream has accrued, in bytes; every chunk taken -/// off the channel is subtracted from it, which is what lets the reader thread -/// see a consumer falling behind. See [`GIT_STREAM_QUEUE_BUDGET`]. fn drain_git_stream( rx: &mpsc::Receiver<GitStreamMsg>, queued: &AtomicUsize, @@ -525,30 +372,13 @@ fn drain_git_stream( ) -> io::Result<Option<i32>> { let mut split = crate::core::git::LineSplitter::default(); loop { - // The wait is per *chunk*, not for the stream as a whole — a slow link - // is allowed to take as long as it takes, a silent one is not. See - // `GIT_STREAM_IDLE_TIMEOUT` for what this catches that neither the - // request deadline nor keepalive can. match rx.recv_timeout(idle) { Ok(GitStreamMsg::Chunk(bytes)) => { - // Before parsing, not after: the arrears the reader thread reads - // must fall as soon as the bytes are ours, or a slow parse of one - // chunk would count against the budget twice. - // - // Saturating, because the two sides of this counter are updated - // by different threads and only the *sum* is ever meaningful: a - // chunk that reached the channel before its charge landed would - // otherwise wrap the counter to `usize::MAX` and kill the next - // healthy stream for being over budget. let _ = queued.fetch_update(Ordering::AcqRel, Ordering::Acquire, |q| { Some(q.saturating_sub(bytes.len())) }); split.push(&bytes, &mut *on_line); } - // The queue outgrew its budget, so the reader thread stopped filling - // it. Everything after the last delivered chunk is missing, which - // makes this a failed read rather than a short one — the same rule - // the timeout arm follows. Ok(GitStreamMsg::Overrun) => { return Err(io::Error::other(format!( "the git stream outran this client by more than \ @@ -563,18 +393,12 @@ fn drain_git_stream( Ok(code) }; } - // Nothing is coming and nothing said so. The lines already handed - // out are *not* retracted, but the result is an error: half a diff - // reported as a successful read is how a stale overlay becomes a - // wrong one. Err(mpsc::RecvTimeoutError::Timeout) => { return Err(io::Error::new( io::ErrorKind::TimedOut, format!("the git stream went silent for {idle:?} while the link stayed up"), )); } - // The connection died mid-stream. Distinguishable from a non-zero - // exit, same as everywhere else in this file. Err(mpsc::RecvTimeoutError::Disconnected) => { return Err(io::Error::other("the control connection closed mid-stream")); } @@ -582,9 +406,6 @@ fn drain_git_stream( } } -/// Removes a stream's registry entry however its reader leaves — an early -/// return on a wire error would otherwise leave the sender in the map for the -/// life of the connection. struct StreamGuard<'a> { streams: &'a GitStreamRegistry, id: u64, @@ -596,36 +417,17 @@ impl Drop for StreamGuard<'_> { } } -/// One chunk of a running [`ControlRequest::GitStream`], as the reader thread -/// hands it to the thread that asked for the stream. enum GitStreamMsg { Chunk(Vec<u8>), - /// The stream is over: git's exit code, and whether the server failed to - /// run it at all. - End { - code: Option<i32>, - failed: bool, - }, - /// This stream fell far enough behind to hit [`GIT_STREAM_QUEUE_BUDGET`], - /// so the reader thread cut it loose. Always the last message: its sender - /// is off the table by the time it is sent. + End { code: Option<i32>, failed: bool }, Overrun, } -/// Where one running stream's pushes go, plus what it owes. struct StreamSink { tx: mpsc::Sender<GitStreamMsg>, - /// Bytes handed to `tx` and not yet taken off it. Written by the reader - /// thread, subtracted by the drainer — the one number both sides of the - /// queue can see, and the only thing standing between an unbounded queue - /// and the whole diff. See [`GIT_STREAM_QUEUE_BUDGET`]. queued: Arc<AtomicUsize>, } -/// Receivers for git streams currently running on this connection, keyed by the -/// id the client chose for each. Entries are inserted *before* the request goes -/// out and removed when the stream ends, so no chunk can arrive with nowhere to -/// go — see [`ControlRequest::GitStream`]. #[derive(Default)] struct GitStreamRegistry { streams: Mutex<StreamTable>, @@ -634,11 +436,6 @@ struct GitStreamRegistry { #[derive(Default)] struct StreamTable { senders: HashMap<u64, StreamSink>, - /// Set by [`GitStreamRegistry::close_all`] and never cleared: a - /// `ControlClient` never comes back up, so once the link is gone no stream - /// registered afterwards could ever be answered. Without it a `git_lines` - /// that registered just after the teardown swept the table would park on a - /// sender nothing will ever close. closed: bool, } @@ -649,8 +446,6 @@ impl GitStreamRegistry { { m.senders.insert(id, sink); } - // Dropped rather than filed when the link is already down, which closes - // the channel and sends the caller straight down the mid-stream arm. } fn remove(&self, id: u64) { @@ -659,39 +454,14 @@ impl GitStreamRegistry { } } - /// Wake every reader still draining a stream, because the link they were - /// being fed by is gone. - /// - /// The counterpart to `ClientInner::fail_all`, and the reason this is - /// needed at all: a `GitStream` reply arrives long before its data, so from - /// `fail_all`'s point of view the request is finished and there is nobody - /// to fail. The thread is really parked on the channel below, which lives - /// here and outlives the reader thread — so unless the senders are closed - /// deliberately, a dropped connection parks that thread forever and the - /// caller's in-flight bookkeeping is never unwound. fn close_all(&self) { let Ok(mut m) = self.streams.lock() else { return; }; m.closed = true; - // Dropping every sender is the signal: chunks already queued stay - // readable and the receiver then sees `Disconnected` rather than - // waiting out its idle timeout for a link that is already gone. m.senders.clear(); } - /// Route one push. Runs on the reader thread, so it must not block — the - /// channel is unbounded and `send` never waits. A chunk for an id that has - /// already finished (a cancelled read the server had not noticed yet) is - /// dropped, which is the same unknown-id rule watches follow. - /// - /// Not blocking is what makes the queue everyone's problem, so this is also - /// where it is bounded: each chunk is charged to the stream's arrears, and a - /// stream whose drainer has fallen [`GIT_STREAM_QUEUE_BUDGET`] behind is cut - /// loose with an [`Overrun`](GitStreamMsg::Overrun) instead of being fed - /// further. Cutting it loose — rather than dropping the chunk — is the only - /// honest option: the queue is a byte stream being reassembled into lines, so - /// a hole in the middle of it is not a shorter diff, it is a wrong one. fn dispatch(&self, event: ControlEvent) { let (id, msg) = match event { ControlEvent::GitChunk { id, bytes } => (id, GitStreamMsg::Chunk(bytes)), @@ -707,16 +477,9 @@ impl GitStreamRegistry { sink.queued.fetch_add(bytes.len(), Ordering::AcqRel) + bytes.len() > GIT_STREAM_QUEUE_BUDGET } - // `End` and `Overrun` carry no payload to charge for, and an end must - // always get through — a stream that stops speaking without one is - // the shape the idle timeout exists to catch, at a cost of two - // minutes. (Some(_), _) => false, }; if over { - // Taken off the table first, so the chunks still arriving for this id - // meet the unknown-id rule above instead of queueing behind a message - // that says the queue is full. if let Some(sink) = m.senders.remove(&id) { let _ = sink.tx.send(GitStreamMsg::Overrun); } @@ -728,11 +491,6 @@ impl GitStreamRegistry { } } -/// Live subscriptions, keyed by the id the server assigned. -/// -/// It has to be shared rather than owned by the host because the reader thread -/// delivers into it and the host reads from it, and neither may wait on the -/// other. #[derive(Default)] struct WatchRegistry { subs: Mutex<HashMap<u64, WatchEntry>>, @@ -740,8 +498,6 @@ struct WatchRegistry { struct WatchEntry { tx: smol::channel::Sender<Vec<PathBuf>>, - /// The directories currently watched. Kept so an overflow can be answered - /// with a full re-report — see [`WatchRegistry::dispatch`]. dirs: Vec<PathBuf>, } @@ -766,18 +522,11 @@ impl WatchRegistry { } } - /// Route one server push. Runs on the reader thread, so it must not block — - /// hence `try_send` into an unbounded channel. fn dispatch(&self, event: ControlEvent) { let (id, paths) = match event { ControlEvent::Watch { id, paths } => { (id, paths.into_iter().map(PathBuf::from).collect::<Vec<_>>()) } - // Overflow means "too many paths changed to enumerate". There is no - // separate overflow signal on `WatchSub` — and there does not need - // to be: reporting every watched directory as changed produces - // exactly the behavior wanted, a re-listing of the whole watched - // set, through the path the consumer already handles. ControlEvent::WatchOverflow { id } => { let dirs = self .subs @@ -787,7 +536,6 @@ impl WatchRegistry { .unwrap_or_default(); (id, dirs) } - // Not this layer's business; the workspace handles them. other => { log::trace!("control event not routed by RemoteHost: {other:?}"); return; @@ -799,14 +547,11 @@ impl WatchRegistry { } let Ok(subs) = self.subs.lock() else { return }; if let Some(entry) = subs.get(&id) { - // A closed receiver means the `WatchSub` is being dropped; the - // `WatchClose` is already on its way. let _ = entry.tx.try_send(paths); } } } -/// The implementation half of a remote [`WatchSub`]. struct RemoteWatch { id: u64, client: Arc<ControlClient>, @@ -831,29 +576,12 @@ impl WatchHandle for RemoteWatch { impl Drop for RemoteWatch { fn drop(&mut self) { self.watches.remove(self.id); - // Best effort: on a link that has already died there is nothing to tell, - // and the server drops its watchers when the connection goes anyway. if self.client.is_connected() { let _ = self.client.call(ControlRequest::WatchClose { id: self.id }); } } } -// --------------------------------------------------------------------------- -// Keepalive -// --------------------------------------------------------------------------- - -/// Watch the link for silence. -/// -/// Two separate jobs, which is why the thresholds differ: *prove* the link is -/// alive when nothing else is using it (a ping after -/// [`KEEPALIVE_IDLE_BEFORE_PING`] of quiet), and *declare it dead* when even -/// that gets no answer ([`KEEPALIVE_DEAD_AFTER`], three ping intervals — two -/// may be lost without a false positive). A busy connection proves itself and -/// is never pinged. -/// -/// Holds a `Weak`, so dropping the last `RemoteHost` ends the thread rather -/// than keeping a connection alive for nobody. fn spawn_keepalive(client: Weak<ControlClient>) { let _ = std::thread::Builder::new() .name("tty7-control-keepalive".into()) @@ -877,8 +605,6 @@ fn spawn_keepalive(client: Weak<ControlClient>) { && last_ping.elapsed() >= KEEPALIVE_PING_INTERVAL { last_ping = Instant::now(); - // A failed ping is not itself fatal — the deadline above is - // what decides, so one lost packet doesn't drop a workspace. if let Err(e) = client.ping() { log::debug!("control keepalive ping failed: {e}"); } @@ -887,10 +613,6 @@ fn spawn_keepalive(client: Weak<ControlClient>) { }); } -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - #[cfg(test)] mod tests { use super::*; @@ -929,8 +651,6 @@ mod tests { } } - /// A scripted peer: every request it receives is forwarded to `seen`, and - /// answered by `answer`. fn host_with_peer<F>( separator: char, answer: F, @@ -955,9 +675,6 @@ mod tests { let (req_id, req) = match ControlClientMsg::read(&mut sock) { Ok(ControlClientMsg::Request { req_id, req }) => (req_id, req), Ok(ControlClientMsg::RequestBlob { req_id, req, blob }) => { - // Echo the blob back through the seen channel by way of - // the request itself: tests that care assert on it via - // a closure over their own state. let _ = blob; (req_id, req) } @@ -996,9 +713,6 @@ mod tests { (host, seen_rx) } - /// `git_lines` asks the peer to stream, and reassembles the chunks it pushes - /// into lines. The id in the request is the one the client chose, which is - /// what let it register the receiver before sending. #[test] fn git_lines_streams_over_the_wire() { let (host, seen) = host_with_peer_streaming(); @@ -1022,10 +736,6 @@ mod tests { } } - /// A peer that serves `GitStream`: it answers the request, then pushes the - /// output as chunks and a terminating `GitEnd`. Deliberately splits a line - /// across two chunks, since that is the case the client's reassembly exists - /// for. fn host_with_peer_streaming() -> (Arc<RemoteHost>, mpsc::Receiver<ControlRequest>) { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let addr = listener.local_addr().unwrap(); @@ -1082,24 +792,9 @@ mod tests { (host, seen_rx) } - /// A stream that goes quiet without ending gives up rather than parking - /// forever. - /// - /// This is the case nothing else on the client can see. Keepalive watches - /// the *link*, and the link is fine — a server whose `git` is wedged on a - /// network filesystem keeps answering pings. The request deadline was - /// satisfied by the immediate `Unit` reply, long before any data. So without - /// this the calling thread — one of a small pool — is parked for the life of - /// the process, and the repo it was probing never gets another answer. - /// - /// Driven through the extracted drain loop with a short idle so the test - /// costs milliseconds instead of `GIT_STREAM_IDLE_TIMEOUT`. #[test] fn a_stream_that_goes_silent_times_out() { let (tx, rx) = mpsc::channel(); - // A live sender that simply never speaks again — the wedged-server - // shape. Dropping it would exercise `Disconnected` instead, which is a - // different arm. tx.send(GitStreamMsg::Chunk(b"alpha\n".to_vec())).unwrap(); let mut lines = Vec::new(); @@ -1119,15 +814,11 @@ mod tests { drop(tx); } - /// The idle timer measures the gap *between* chunks, not the stream's total - /// length: a slow-but-alive read must be allowed to take as long as it - /// takes, which is why a total deadline would be the wrong instrument. #[test] fn a_slow_stream_outlives_its_idle_timeout() { let (tx, rx) = mpsc::channel(); let idle = Duration::from_millis(150); thread::spawn(move || { - // Five gaps, each under the idle limit; together well past it. for i in 0..5 { thread::sleep(Duration::from_millis(60)); let _ = tx.send(GitStreamMsg::Chunk(format!("line {i}\n").into_bytes())); @@ -1152,20 +843,6 @@ mod tests { ); } - /// A stream whose drainer falls far enough behind is cut loose instead of - /// being queued without limit. - /// - /// This is the bound that makes the streaming path's memory claim true on a - /// *remote* host. The read is incremental on both ends — 64 KiB at the - /// server, one line at the client — but between them sits a queue the reader - /// thread never waits on, and it cannot wait on it: that thread serves the - /// whole connection, so parking it there would stall every other reply and - /// the keepalive with it. Unbounded, a peer pushing faster than this client - /// parses rebuilds the whole-diff peak in the channel, which is the one thing - /// the buffered read was replaced to avoid. - /// - /// Driven through `dispatch`, not by hand, because the accounting is split - /// across the two threads and only their pairing is worth asserting. #[test] fn a_stream_that_outruns_its_queue_budget_is_cut_loose() { let registry = GitStreamRegistry::default(); @@ -1179,8 +856,6 @@ mod tests { }, ); - // Nobody is draining, so every chunk is arrears. One megabyte at a time - // to keep the test's own allocation modest. let chunk = vec![b'x'; 1024 * 1024]; let pushes = GIT_STREAM_QUEUE_BUDGET / chunk.len() + 2; for _ in 0..pushes { @@ -1194,15 +869,11 @@ mod tests { "the arrears stopped growing at the budget, not at the diff's size" ); - // The reader thread also stops routing to it, so a stream that keeps - // arriving cannot queue behind the notice. registry.dispatch(ControlEvent::GitChunk { id: 1, bytes: chunk.clone(), }); - // What the drainer sees: the chunks that fit, then the overrun, and an - // error rather than a short read reported as a success. let mut lines = Vec::new(); let err = drain_git_stream(&rx, &queued, Duration::from_secs(5), &mut |l| { lines.push(l.to_string()) @@ -1211,15 +882,6 @@ mod tests { assert!(err.to_string().contains("outran"), "{err}"); } - /// The budget must not fire on a stream that is merely *large*. It bounds - /// how far the consumer may fall behind, not how much may cross — a drainer - /// keeping up returns the arrears as fast as they are charged, so a diff of - /// any size passes through a queue that never grows. - /// - /// The feeder throttles itself on the same counter the reader thread charges, - /// which is what "a consumer keeping up" means here and is what keeps this - /// test a statement about the accounting rather than a race between two - /// threads' speeds. #[test] fn a_large_but_drained_stream_never_trips_the_budget() { let registry = Arc::new(GitStreamRegistry::default()); @@ -1233,8 +895,6 @@ mod tests { }, ); - // Twice the budget in total, in 1 MiB chunks, never more than 4 MiB of it - // outstanding at once. let feeder = Arc::clone(®istry); let feeder_queued = Arc::clone(&queued); let chunks = GIT_STREAM_QUEUE_BUDGET / (1024 * 1024) * 2; @@ -1243,7 +903,7 @@ mod tests { let deadline = Instant::now() + Duration::from_secs(10); while feeder_queued.load(Ordering::Acquire) > 4 * 1024 * 1024 { if Instant::now() > deadline { - break; // the drainer is wedged; let the assertions say so + break; } thread::yield_now(); } @@ -1266,14 +926,6 @@ mod tests { assert_eq!(queued.load(Ordering::Acquire), 0, "the arrears settled"); } - /// A link that dies mid-stream ends the read with an error rather than - /// parking the thread that was draining it. - /// - /// The regression this guards is a *hang*, not a wrong answer, so the call - /// is made on its own thread and the assertion is on it having returned at - /// all. A stream is answered by pushes, so the failure path every other - /// method relies on — a deadline, or `fail_all` emptying `pending` — has - /// nothing to fail here; only closing the stream's own channel wakes it. #[test] fn a_link_that_dies_mid_stream_ends_the_read() { let host = host_with_peer_dying_mid_stream(); @@ -1294,8 +946,6 @@ mod tests { } } - /// A peer that accepts a `GitStream`, pushes part of it, and then hangs up - /// without a `GitEnd` — an SSH link dropping mid-diff. fn host_with_peer_dying_mid_stream() -> Arc<RemoteHost> { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let addr = listener.local_addr().unwrap(); @@ -1329,9 +979,6 @@ mod tests { .encode(&mut sock) .unwrap(); sock.flush().unwrap(); - // Dropping the socket here is the whole point: the client is now - // waiting on chunks that will never come, with the reply it was - // told to wait for already delivered. }); let sock = TcpStream::connect(addr).unwrap(); @@ -1343,10 +990,6 @@ mod tests { .unwrap() } - /// The dropdown of a remote window is built from the *server's* shells. - /// This is the whole point: a menu filled from the client's `/etc/shells` - /// offers `/bin/zsh` on a box whose zsh lives elsewhere, and every pick - /// fails to spawn. #[test] fn shells_come_from_the_peer() { let (host, seen) = host_with_peer('/', |req| match req { @@ -1370,9 +1013,6 @@ mod tests { assert_eq!(inv.shells[0].program, "/usr/bin/zsh"); } - /// Path arithmetic follows the *peer's* separator, not the client's. On a - /// Windows client this is the difference between `/home/me/src` and - /// `/home/me\src`, and between "absolute" and "drive-relative". #[test] fn path_arithmetic_follows_the_peer_not_the_client() { let (host, _seen) = @@ -1393,8 +1033,6 @@ mod tests { assert!(!host.is_absolute(Path::new("C:/home"))); } - /// A remote Windows host gets Windows semantics — the separator is a - /// property of the peer, so this must work in both directions. #[test] fn a_windows_peer_gets_windows_path_semantics() { let (host, _seen) = @@ -1411,8 +1049,6 @@ mod tests { assert!(!host.is_absolute(Path::new("src\\main.rs"))); } - /// Each read method sends the request its name implies and unwraps the - /// reply's payload — one round trip, no probing, no second call. #[test] fn read_methods_map_to_one_request_each() { let (host, seen) = host_with_peer('/', |req| { @@ -1470,8 +1106,6 @@ mod tests { let sent: Vec<_> = (0..7).map(|_| seen.recv().unwrap()).collect(); assert!(matches!(sent[0], ControlRequest::ReadDir { .. })); assert!(matches!(sent[1], ControlRequest::Stat { .. })); - // `exists` must not degrade into a `stat`: that would ship metadata - // across an ocean to answer a yes/no question. assert!(matches!(sent[2], ControlRequest::Exists { .. })); assert!(matches!(sent[3], ControlRequest::Canonicalize { .. })); assert!(matches!(sent[4], ControlRequest::RepoRoot { .. })); @@ -1479,9 +1113,6 @@ mod tests { assert!(matches!(sent[6], ControlRequest::Git { .. })); } - /// `rename` states its intent once and trusts the server's `AlreadyExists` - /// guarantee — a client-side `exists` probe first would be an extra round - /// trip *and* racy. #[test] fn mutations_are_a_single_request_with_no_probe() { let (host, seen) = host_with_peer('/', |req| { @@ -1517,9 +1148,6 @@ mod tests { assert_eq!(seen.try_recv().ok(), None, "no probing round trips"); } - /// `read_file` gets its content from the reply's blob, and `write_file` - /// puts its content in the request's — the reason bulk frames carry a JSON - /// head at all is that the path and metadata travel beside the bytes. #[test] fn file_contents_ride_the_blob_in_both_directions() { let content: Vec<u8> = (0..=255u8).cycle().take(70_000).collect(); @@ -1540,9 +1168,6 @@ mod tests { h.write_file(Path::new("/f"), &content).unwrap(); } - /// An oversize file is refused by the server *before* the bytes move. The - /// error has to arrive as `FileTooLarge` so the editor can say so rather - /// than showing a generic failure. #[test] fn read_file_over_the_limit_fails_without_transferring() { let (host, _seen) = host_with_peer('/', |_| { @@ -1561,9 +1186,6 @@ mod tests { assert!(e.to_string().contains("900 MB")); } - /// A non-zero git exit is `Ok`. This is the invariant that lets every - /// existing git call site keep its `Option`/`Result<String, String>` shape - /// unchanged when the repo moves to another machine. #[test] fn a_nonzero_git_exit_is_ok_not_err() { let (host, _seen) = host_with_peer('/', |_| { @@ -1582,9 +1204,6 @@ mod tests { assert_eq!(out.stderr_trimmed(), "not a git repository"); } - /// The id comes from the *connection*, not the workspace, so two workspaces - /// on one machine share a host id — and therefore share its git-status - /// cache instead of each maintaining a private one. #[test] fn the_host_id_is_derived_from_the_connection_and_is_stable() { let (a, _sa) = host_with_peer('/', |_| Some((ControlReply::Ok(ReplyOk::Unit), vec![]))); @@ -1595,8 +1214,6 @@ mod tests { assert_eq!(a.id(), HostId::from_connection_key("ssh-alias:testbox")); } - /// Watch events reach the subscription's channel, and `set_dirs` replaces - /// the set in place rather than rebuilding the subscription. #[test] fn watch_events_reach_the_subscription() { let (host, seen) = host_with_peer('/', |req| match req { @@ -1615,7 +1232,6 @@ mod tests { ControlRequest::WatchOpen { .. } )); - // A push routed by id lands as a batch on the subscription. host.watches.dispatch(ControlEvent::Watch { id: 7, paths: vec!["/p/a".into(), "/p/b".into()], @@ -1625,7 +1241,6 @@ mod tests { vec![PathBuf::from("/p/a"), PathBuf::from("/p/b")] ); - // An event for an id nobody holds is dropped, not delivered elsewhere. host.watches.dispatch(ControlEvent::Watch { id: 999, paths: vec!["/elsewhere".into()], @@ -1639,8 +1254,6 @@ mod tests { ControlRequest::WatchSet { .. } )); - // Overflow re-reports the whole watched set, which is how "invalidate - // everything" reaches a consumer that only understands path batches. host.watches.dispatch(ControlEvent::WatchOverflow { id: 7 }); assert_eq!( sub.events().recv_blocking().unwrap(), @@ -1649,8 +1262,6 @@ mod tests { ); } - /// Dropping the subscription unsubscribes on the server, rather than - /// leaving a watcher running for a client that stopped caring. #[test] fn dropping_the_subscription_closes_it_on_the_server() { let (host, seen) = host_with_peer('/', |req| match req { @@ -1672,11 +1283,9 @@ mod tests { } } - /// A host whose link has died reports it, so call sites can keep showing - /// the last good listing instead of flashing an error at every repaint. #[test] fn a_dead_link_reports_disconnected() { - let (host, _seen) = host_with_peer('/', |_| None); // answers nothing, then hangs up + let (host, _seen) = host_with_peer('/', |_| None); let h: &dyn Host = host.as_ref(); assert!(h.is_connected()); let e = h.stat(Path::new("/f")).unwrap_err(); @@ -1684,8 +1293,6 @@ mod tests { assert!(!h.is_connected()); } - /// A reply of the wrong shape is called out as a peer bug rather than - /// being silently read as an empty result. #[test] fn a_reply_of_the_wrong_shape_is_invalid_data() { let (host, _seen) = @@ -1695,8 +1302,6 @@ mod tests { assert!(e.to_string().contains("Pong")); } - /// `RemoteHost` is usable as `Arc<dyn Host>` — object safety is not an - /// abstract property here, it is what the whole tree's `SharedHost` needs. #[test] fn remote_host_is_object_safe() { let (host, _seen) = diff --git a/crates/tty7-core/src/host/server.rs b/crates/tty7-core/src/host/server.rs index 3f53cfdc..02cd32f1 100644 --- a/crates/tty7-core/src/host/server.rs +++ b/crates/tty7-core/src/host/server.rs @@ -1,44 +1,3 @@ -//! The control dialect's **server** half: [`ControlRequest`] in, [`Host`] out. -//! -//! This is the other end of [`RemoteHost`](crate::host::remote::RemoteHost). -//! One connection arrives, says hello, and then issues filesystem, git and watch -//! requests that this module runs against a `Host` — in practice the -//! [`LocalHost`](crate::host::local::LocalHost) of the machine the server is on. -//! The client sees a `Host`; the server *uses* a `Host`; the wire in between -//! carries nothing else. -//! -//! That symmetry is not an accident, it is the reason the trait is blocking -//!. A server handler runs on a thread pool, where blocking is what -//! you want, so the identical `LocalHost` that answers a local file tree answers -//! a remote one — no async mirror of every method, and no second implementation -//! to drift. -//! -//! # Out-of-order is the point -//! -//! Requests are dispatched onto a pool and replied to as they finish, in -//! whatever order that turns out to be. A serial loop would be far simpler and -//! completely useless: a `git status` on a cold monorepo takes tens of seconds, -//! and behind a serial server every disclosure triangle in the file tree would -//! wait for it. `req_id` exists precisely so a reply can arrive out of turn, and -//! this side has to actually produce that — the client's multiplexer is only -//! half of the guarantee. -//! -//! The pool is elastic rather than fixed for the same reason. A fixed pool of -//! `N` serializes at `N` concurrent slow requests, which just moves the stall -//! rather than removing it; workers here are spawned on demand up to -//! [`MAX_WORKERS`] and retire after [`WORKER_LINGER`] of idleness, so the common -//! case (a burst of `stat`s) costs one or two threads and the pathological case -//! (sixty simultaneous `git`s) still answers the sixty-first `stat` immediately. -//! -//! # What a connection owns -//! -//! | Thing | Lifetime | -//! |---|---| -//! | The reply sink | The connection. One mutex, one whole frame per lock, so replies never interleave | -//! | In-flight ids | Per request. Records cancellation and is erased when the reply goes out | -//! | Watch subscriptions | Until `WatchClose`, or until the connection ends — whichever comes first, so a dropped link cannot leak a server-side watcher | -//! | Pool workers | Shared per connection; retired on idle, cleared on teardown | - use std::collections::{HashMap, VecDeque}; use std::io::{self, Read, Write}; use std::path::{Path, PathBuf}; @@ -55,71 +14,25 @@ use crate::daemon::control::{ use crate::daemon::duplex::{Duplex, Halves}; use crate::host::{Host, SearchHit, SharedHost, WatchSub}; -/// Ceiling on threads one connection's pool will grow to. -/// -/// High enough that no realistic client can serialize behind it — the file tree -/// issues a few dozen listings per frame at worst — and low enough that a -/// runaway or hostile peer cannot turn request volume into thread count. pub const MAX_WORKERS: usize = 64; -/// How long an idle worker waits for more work before retiring. A burst of -/// listings should not leave sixty threads parked for the rest of the session, -/// and a steady trickle should not pay a spawn per request. pub const WORKER_LINGER: Duration = Duration::from_secs(10); -/// Requests allowed to queue once every worker is busy. Past this the server -/// answers immediately rather than growing an unbounded backlog whose entries -/// would each be answered long after the client's own deadline gave up on them. pub const MAX_QUEUED: usize = 1024; -/// `Layout` deltas one connection will let queue before it starts dropping. -/// -/// A delta is *not* self-superseding — a dropped one leaves the peer's -/// picture of the tree wrong until it re-pulls. The cap is still -/// right, for the same reason as the watch caps: a peer -/// that has stopped reading its socket must not turn another client's edit -/// into unbounded server memory. What makes the drop survivable is that it is -/// *announced*: the connection is flagged lagged, and the forwarder replaces -/// the whole superseded backlog with a single -/// [`ControlEvent::LayoutResync`], so a client that lost one edit re-pulls -/// instead of silently mirroring a tree it is no longer looking at. A peer too -/// wedged to hear even that is already inside -/// [`crate::daemon::control::KEEPALIVE_DEAD_AFTER`] of losing the link, and -/// every reconnect begins with a full pull. pub const LAYOUT_EVENT_QUEUE: usize = 1024; -// --------------------------------------------------------------------------- -// Entry points -// --------------------------------------------------------------------------- - -/// What a control server offers **beyond** the host itself. -/// -/// Separate from the `SharedHost` argument because the two are genuinely -/// independent roles, and the handshake says so: a box can back a remote -/// workspace's file tree without owning any workspace tree (which is what -/// [`Services::default`] produces), and the `machine-tree` capability bit is -/// advertised only when this actually carries one. A client therefore learns -/// from the handshake whether asking is worth a round trip. #[derive(Clone, Default)] pub struct Services { - /// The machine's own workspace *tree* — the daemon-owned structure the - /// semantic operations edit. `None` answers every tree verb with "this - /// server does not serve the machine tree". pub machine: Option<Arc<MachineStore>>, - /// Who currently holds each workspace, and how to reach them. Shared across - /// every connection this server accepts — that sharing *is* the takeover: - /// two connections can only displace each other if they are looking at one - /// table. pub attachments: Arc<AttachRegistry>, } impl Services { - /// Host RPC only, no machine tree. pub fn none() -> Services { Services::default() } - /// Host RPC plus the machine tree. pub fn with_machine(store: Arc<MachineStore>) -> Services { Services { machine: Some(store), @@ -128,86 +41,34 @@ impl Services { } } -// --------------------------------------------------------------------------- -// Attachment / takeover (D8) -// --------------------------------------------------------------------------- - -/// The live half of the attachment record. -/// -/// [`Attachment`](crate::core::machine::Attachment) in the machine tree is the -/// *data* — token, hostname, since — and answers "who holds this workspace". -/// This is the *handles*: the sink a `Preempted` push goes out on and the -/// shutdown that closes the displaced session's link. They are separate because -/// the tree lives in `core` and knows nothing about sockets, and because an -/// attachment must never be written to the file (a stale one on disk would have -/// the server report a takeover against a client that no longer exists). -/// -/// **D8's rule, concretely**: the newcomer always wins. The previous holder is -/// told and let go, never refused — "the old machine forgot to close the -/// window" is the common case, and rejecting the new client locks the user out -/// of their own box. #[derive(Default)] pub struct AttachRegistry { live: Mutex<Vec<Live>>, - /// Held across *both* tables for the length of one handover. - /// - /// A takeover moves two things that live in different places: this - /// registry's handles, and the `MachineStore`'s record. Each is - /// internally locked, and that is not enough — two clients attaching to one - /// workspace at the same moment can each win a different table, after which - /// the store names a session the registry has already evicted and no - /// `detach` can ever clear it, because the token no longer matches. From - /// then on the workspace reports a takeover against a client that - /// disconnected hours ago. - /// - /// Coarse on purpose: attach and detach happen once per workspace opened or - /// closed, so serializing them costs nothing worth measuring. Always the - /// outermost lock of the two, and never held while writing to a peer. handover: Mutex<()>, } struct Live { workspace: String, - /// The connection holding it. Compared before evicting, so re-attaching from - /// the same connection is a no-op rather than a self-takeover. conn: u64, token: String, hostname: String, sink: Arc<Sink>, shutdown: Arc<dyn LinkShutdown>, - /// The link was opened *for* this workspace — its - /// [`ControlHello::workspace`] named it. See [`Evicted::dedicated`]. dedicated: bool, } -/// A session that has just been displaced, and how to tell it so. struct Evicted { hostname: String, sink: Arc<Sink>, shutdown: Arc<dyn LinkShutdown>, - /// Whether the link exists *for* this workspace, and so should be closed - /// with it. - /// - /// The displaced session's streams are closed. When the - /// connection was opened for one workspace — its hello named it — that is - /// exactly right, and it is the strong form of the guarantee: the old client - /// cannot write again even if it is wedged or hostile. - /// - /// A connection that attached by *request* is multiplexing by construction: - /// the client has told us this link carries more than one thing, and a - /// client holds one connection per **machine**. Closing it would take - /// windows nobody preempted down with the one that was, so that link keeps - /// running and only loses the workspace. The push goes out either way. dedicated: bool, } impl AttachRegistry { - /// Take the handover lock. See [`AttachRegistry::handover`]. fn handover(&self) -> std::sync::MutexGuard<'_, ()> { self.handover.lock().unwrap_or_else(|e| e.into_inner()) } - /// Who holds `workspace` — `(token, hostname)`. Diagnostics and tests. pub fn holder(&self, workspace: &str) -> Option<(String, String)> { self.locked() .iter() @@ -215,7 +76,6 @@ impl AttachRegistry { .map(|l| (l.token.clone(), l.hostname.clone())) } - /// How many workspaces are attached right now. pub fn len(&self) -> usize { self.locked().len() } @@ -224,11 +84,6 @@ impl AttachRegistry { self.len() == 0 } - /// Take `workspace` for `conn`, answering whoever it was taken from. - /// - /// One lock covers the eviction and the insert: a third client arriving - /// mid-takeover must end up displacing exactly one of the two, never both - /// and never neither. fn claim( &self, workspace: &str, @@ -239,8 +94,6 @@ impl AttachRegistry { let mut live = self.locked(); let evicted = match live.iter().position(|l| l.workspace == workspace) { Some(i) if live[i].conn == conn => { - // The same connection re-attaching. Refresh it and tell nobody: - // a client must not be able to preempt itself. live[i].token = holder.token.clone(); None } @@ -269,17 +122,10 @@ impl AttachRegistry { evicted } - /// Forget `workspace` whoever holds it — the workspace itself is gone. - /// - /// Unconditional, unlike [`AttachRegistry::release`]: a delete is not one - /// session giving something up, it is the thing ceasing to exist. fn forget_workspace(&self, workspace: &str) { self.locked().retain(|l| l.workspace != workspace); } - /// Release `workspace`, but only if `conn` still holds it. `false` means it - /// had already been taken over, which is success as far as the caller is - /// concerned — and the reason releasing is conditional at all. fn release(&self, workspace: &str, conn: u64) -> bool { let mut live = self.locked(); let before = live.len(); @@ -287,8 +133,6 @@ impl AttachRegistry { live.len() != before } - /// Release everything `conn` holds, naming what was released so the store's - /// records can be dropped too. fn release_conn(&self, conn: u64) -> Vec<String> { let mut live = self.locked(); let mut released = Vec::new(); @@ -308,7 +152,6 @@ impl AttachRegistry { } } -/// The identity one connection attaches under. struct Holder { token: String, hostname: String, @@ -316,17 +159,10 @@ struct Holder { shutdown: Arc<dyn LinkShutdown>, } -/// Serve one control connection to completion. -/// -/// Returns when the peer hangs up, when a frame does not decode, or when the -/// link is shut down from elsewhere. Every resource the connection acquired — -/// watches, workers, the workspace subscription, the write half — is released -/// before this returns. pub fn serve<D: Duplex>(link: D, host: SharedHost) -> io::Result<()> { serve_with(link, host, Services::none()) } -/// [`serve`], with the extra services this server offers. pub fn serve_with<D: Duplex>(link: D, host: SharedHost, services: Services) -> io::Result<()> { let label = link.kind_label(); let Halves { @@ -337,12 +173,6 @@ pub fn serve_with<D: Duplex>(link: D, host: SharedHost, services: Services) -> i serve_halves_with(read, write, shutdown, host, services, label) } -/// [`serve`] over halves that have already been split. -/// -/// Exists for callers holding two unrelated handles — the stdio server, and -/// tests driving a socket pair — which is the same reason -/// [`ControlClient::connect`](crate::daemon::control::ControlClient::connect) -/// takes its halves separately on the other side. pub fn serve_halves<R, W>( r: R, w: W, @@ -357,7 +187,6 @@ where serve_halves_with(r, w, shutdown, host, Services::none(), label) } -/// [`serve_halves`], with the extra services this server offers. pub fn serve_halves_with<R, W>( mut r: R, w: W, @@ -372,13 +201,6 @@ where { let sink = Arc::new(Sink::new(w)); - // The handshake happens on this thread, before anything is spawned: a peer - // that speaks a different dialect must cost exactly one frame each way. - // - // A peer that hangs up *during* the handshake is a disconnect like any - // other, not a failure worth reporting — a port scanner, a health check, or - // a client that changed its mind all land here, and none of them are this - // server's problem. let hello = match handshake(&mut r, &sink, &*host, &services) { Ok(Some(hello)) => hello, Ok(None) => { @@ -396,10 +218,6 @@ where } }; - // Subscribed before the first request is read, so an edit another client - // makes while this one is still pulling cannot slip through the gap: a - // full pull issued after this point can race a delta (the client tolerates - // that), but an edit can never fall between subscription and first read. let machine_sub = subscribe_machine(&services, &sink); let conn = Arc::new(Conn { @@ -423,11 +241,6 @@ where }, }); - // A hello that names a workspace attaches to it straight away, before the - // first request is read: the client that opened a connection *for* a - // workspace has already taken it over, and a window between the handshake - // and an explicit `WorkspaceAttach` is a window in which two clients both - // believe they hold it. if let Some(workspace) = hello.workspace.as_deref() && let Err(e) = attach_workspace(&conn, workspace, true) { @@ -436,11 +249,6 @@ where let outcome = read_loop(&mut r, &conn); - // Teardown, in the order that makes each step meaningful: stop accepting - // work, drop the watches (which stops the pushes and releases the OS - // watchers), release anything this session still holds, drop the machine - // subscription (which ends its forwarder), then close the link so anything - // still writing fails fast rather than blocking on a peer that is gone. conn.pool.close(); conn.watches .lock() @@ -464,7 +272,6 @@ where } } -/// A peer going away is how connections normally end, not a failure to report. fn is_disconnect(e: &io::Error) -> bool { matches!( e.kind(), @@ -475,13 +282,6 @@ fn is_disconnect(e: &io::Error) -> bool { ) } -/// Exchange hellos. `Ok(None)` means the versions did not match and the caller -/// should close. -/// -/// The reply goes out **even on a mismatch**, which is the only reason the -/// client can say "the server speaks v2, I speak v1" instead of "the connection -/// dropped" — a `HELLO` carries no `req_id`, so there is no error reply to hang -/// the mismatch on. fn handshake<R: Read>( r: &mut R, sink: &Sink, @@ -498,11 +298,6 @@ fn handshake<R: Read>( } }; - // Advertised from what this server actually carries, not from what the - // build can do. A client that sees `machine-tree` missing knows not to - // spend a round trip asking, and — the case that matters — a machine - // serving only a file tree does not claim to own a workspace tree it has - // no file for. let mut features = vec![ feature::CONTROL.to_string(), feature::HOST_RPC.to_string(), @@ -534,32 +329,13 @@ fn handshake<R: Read>( Ok(Some(hello)) } -/// Serial number for connections, so the attach registry can tell two -/// connections apart even when the same client opens both. static NEXT_CONN: AtomicU64 = AtomicU64::new(1); -/// The takeover, server side: claim `workspace` for this connection and -/// tell whoever held it. -/// -/// The order is the whole behaviour. The tree's record moves first (so a -/// concurrent `attachment()` never shows the workspace as free), the registry's -/// handles move under one lock, and only then is the displaced session told — -/// outside every lock, because writing to a peer that has stopped reading must -/// not hold up the client that just took over. -/// -/// `dedicated` says the link was opened *for* this workspace (its hello named -/// it), which is what decides whether a later takeover closes it — see -/// [`Evicted::dedicated`]. -/// -/// Answers the hostname taken over from, or `None` when nobody held it. fn attach_workspace( conn: &Arc<Conn>, workspace: &str, dedicated: bool, ) -> io::Result<Option<String>> { - // The attach verbs predate the typed tree, so the id arrives as a string. - // The data half of the attachment lives in the machine tree; a server - // without one answers the refusal a tree-less server always has. if conn.machine.is_none() { return Err(io::Error::other( "this server does not serve the machine tree", @@ -567,16 +343,8 @@ fn attach_workspace( } let tree_id: Option<crate::core::session::WorkspaceId> = workspace.parse().ok(); let (displaced, evicted) = { - // Both tables move under one lock. Held only across the moves — - // the notice below goes out with nothing held, because writing to a - // peer that has stopped reading must not hold up the next client's - // attach. let _handover = conn.attachments.handover(); let attachment = Attachment::new(conn.holder.token.clone(), conn.holder.hostname.clone()); - // A workspace the tree does not list (or an id that is not a uuid) - // records no data half; the registry's live handles still move, so - // the takeover behaviour is identical either way, and the tree's - // record appears the moment the workspace does. let displaced = match (&conn.machine, tree_id) { (Some(machine), Some(id)) => machine.attach(id, attachment), _ => None, @@ -598,8 +366,6 @@ fn attach_workspace( by: conn.holder.hostname.clone(), }; if let Err(e) = evicted.sink.send(&ControlServerMsg::Event(notice)) { - // The displaced client is already gone. Nothing to tell, and - // certainly not a reason to fail the attach that replaced it. log::debug!("could not tell the displaced session about {workspace}: {e}"); } if evicted.dedicated { @@ -607,18 +373,11 @@ fn attach_workspace( } } - // A session re-attaching to something it already held is not a takeover, so - // its own record is not reported back to it as one. Ok(displaced .filter(|a| a.token != conn.holder.token) .map(|a| a.hostname)) } -/// Release `workspace` if this connection still holds it. -/// -/// Token-checked in the tree *and* connection-checked in the registry, which -/// are the same guard seen from both halves: a session that was preempted and -/// then tidied up must not evict the client that took over from it. fn detach_workspace(conn: &Arc<Conn>, workspace: &str) -> io::Result<bool> { if conn.machine.is_none() { return Err(io::Error::other( @@ -634,9 +393,6 @@ fn detach_workspace(conn: &Arc<Conn>, workspace: &str) -> io::Result<bool> { Ok(released || forgotten) } -/// This machine's home directory, for the handshake's `home` field — the value -/// "new workspace defaults to `~`" resolves against, which has to be the -/// *server's* home and not the client's. fn home_dir() -> Option<PathBuf> { let pick = |k: &str| { std::env::var_os(k) @@ -646,14 +402,10 @@ fn home_dir() -> Option<PathBuf> { pick("HOME").or_else(|| pick("USERPROFILE")) } -/// Read frames until the peer stops. fn read_loop<R: Read>(r: &mut R, conn: &Arc<Conn>) -> io::Result<()> { loop { let msg = ControlClientMsg::read(r)?; match msg { - // A second hello on a live connection is a desync, not a - // re-handshake: the peer and this side no longer agree on where the - // stream is, and continuing would misread every later frame. ControlClientMsg::Hello(_) => { return Err(io::Error::new( io::ErrorKind::InvalidData, @@ -667,8 +419,6 @@ fn read_loop<R: Read>(r: &mut R, conn: &Arc<Conn>) -> io::Result<()> { } } -/// Queue one request onto the pool, or answer it immediately if the pool is -/// saturated. fn submit(conn: &Arc<Conn>, req_id: u64, req: ControlRequest, blob: Vec<u8>) { conn.inflight .lock() @@ -692,12 +442,7 @@ fn submit(conn: &Arc<Conn>, req_id: u64, req: ControlRequest, blob: Vec<u8>) { } } -/// Run one request on a pool worker and send its reply. fn run_job(conn: &Arc<Conn>, req_id: u64, req: ControlRequest, blob: Vec<u8>) { - // Cheap pre-check: a request cancelled before a worker picked it up is not - // worth running at all. (Once it *has* started there is nothing to do — a - // filesystem call is not interruptible, so "best effort, not - // guaranteed" is discharged by discarding the result.) if conn.is_cancelled(req_id) { conn.forget(req_id); return; @@ -711,24 +456,6 @@ fn run_job(conn: &Arc<Conn>, req_id: u64, req: ControlRequest, blob: Vec<u8>) { conn.finish(req_id, reply, out_blob, wants_blob); } -// --------------------------------------------------------------------------- -// Dispatch -// --------------------------------------------------------------------------- - -/// Drop the hits whose paths this wire cannot carry. -/// -/// `SearchHit::path` is the one `PathBuf` that crosses the control dialect — -/// every path in `ControlRequest` is a `String` for exactly this reason — and -/// `serde` refuses to serialize a `Path` that is not UTF-8 rather than -/// converting it lossily. So one Latin-1 filename under the search roots makes -/// the *whole* reply unencodable: before this, the client saw no reply at all -/// and waited out its 20-second search deadline, on that keystroke and on every -/// one after it. -/// -/// Dropped rather than converted lossily, because the lossy form is a path that -/// does not open — the client would be offering the user a hit it cannot act -/// on. `LocalHost` keeps full fidelity; this is the wire's limit, applied at the -/// wire. fn drop_unsendable_hits(hits: &mut Vec<SearchHit>) { let before = hits.len(); hits.retain(|hit| hit.path.to_str().is_some()); @@ -740,8 +467,6 @@ fn drop_unsendable_hits(hits: &mut Vec<SearchHit>) { } } -/// One request against the host. `Ok` carries the reply value and, for the -/// methods that have one, the bulk bytes that ride the frame's blob. fn run_request( conn: &Arc<Conn>, req_id: u64, @@ -754,7 +479,6 @@ fn run_request( Ok(match req { ControlRequest::Ping => (ReplyOk::Pong, Vec::new()), - // ----- filesystem reads -------------------------------------------- ControlRequest::ReadDir { dir, root } => { let root = root.map(|r| p(&r)); let entries = h.read_dir(&p(&dir), root.as_deref())?; @@ -771,9 +495,6 @@ fn run_request( } ControlRequest::ReadFile { path, max_bytes } => { let path = p(&path); - // `max_bytes` is enforced inside `read_file`, *before* the bytes are - // read — which is the whole reason the limit crosses the wire at all - // rather than being applied on arrival. let bytes = h.read_file(&path, max_bytes)?; let meta = h.stat(&path)?; (ReplyOk::FileMeta { meta }, bytes) @@ -797,14 +518,7 @@ fn run_request( (ReplyOk::Hits(hits), Vec::new()) } - // ----- filesystem writes ------------------------------------------- ControlRequest::WriteFile { path } => { - // The post-write metadata rides back on the same round trip: the - // editor needs that mtime to recognize its own change when the - // watcher reports it, and a follow-up `stat` would both cost a trip - // and leave a window for an external edit to be recorded as ours. - // It comes from `write_file` itself now, so even that window — - // between this handler's write and its own stat — is gone. (ReplyOk::Meta(h.write_file(&p(&path), &blob)?), Vec::new()) } ControlRequest::CreateFileNew { path } => { @@ -824,7 +538,6 @@ fn run_request( (ReplyOk::Unit, Vec::new()) } - // ----- git ----------------------------------------------------------- ControlRequest::RepoRoot { path } => { let root = h.repo_root(&p(&path))?; ( @@ -834,23 +547,15 @@ fn run_request( } ControlRequest::Git { cwd, args } => { let borrowed: Vec<&str> = args.iter().map(String::as_str).collect(); - // A non-zero exit is `Ok` with the status inside, never an `Err`. - // Collapsing the two here would break `git_status`'s `Option` - // semantics for every remote workspace at once. (ReplyOk::Output(h.git(&p(&cwd), &borrowed)?), Vec::new()) } ControlRequest::GitStream { id, cwd, args } => { - // Started straight away: the client chose `id` and registered its - // receiver before sending, so a chunk that overtakes this reply is - // delivered, not dropped. See `ControlRequest::GitStream`. conn.start_git_stream(id, p(&cwd), args); (ReplyOk::Unit, Vec::new()) } - // ----- machine inventory --------------------------------------------- ControlRequest::Shells => (ReplyOk::Shells(h.shells()?), Vec::new()), - // ----- watch --------------------------------------------------------- ControlRequest::WatchOpen { dirs } => { let id = conn.open_watch(req_id, &paths(&dirs))?; (ReplyOk::WatchId(id), Vec::new()) @@ -864,7 +569,6 @@ fn run_request( (ReplyOk::Unit, Vec::new()) } - // ----- attachment (D8) ----------------------------------- ControlRequest::WorkspaceAttach { id } => ( ReplyOk::Attached { took_over_from: attach_workspace(conn, &id, false)?, @@ -872,18 +576,10 @@ fn run_request( Vec::new(), ), ControlRequest::WorkspaceDetach { id } => { - // Detaching something this session no longer holds is success, for - // the same reason a redundant delete is: the caller wanted not to - // hold it, and it does not. detach_workspace(conn, &id)?; (ReplyOk::Unit, Vec::new()) } - // ----- machine tree -------------------------------------------------- - // Each arm is a thin translation: the store validates, mutates, - // persists and broadcasts (with this connection's origin excluded), - // and its refusals cross the wire as the client-visible errors they - // already are. ControlRequest::MachineGet => ( ReplyOk::MachineTree(Box::new(conn.machine()?.machine())), Vec::new(), @@ -908,9 +604,6 @@ fn run_request( ControlRequest::WorkspaceRemove { workspace } => { let store = conn.machine()?; let panes = { - // The tree drops its own attachment with the workspace; the - // attach registry forgets it under the handover lock, or a - // stale dedicated entry would one day close an innocent link. let _handover = conn.attachments.handover(); let panes = store.workspace_delete(workspace, conn.machine_origin)?; conn.attachments.forget_workspace(&workspace.to_string()); @@ -1036,73 +729,33 @@ fn paths(v: &[String]) -> Vec<PathBuf> { v.iter().map(PathBuf::from).collect() } -/// The wire carries `u64` where the trait takes `usize`, because a 32-bit server -/// must not silently wrap a limit into something smaller than asked for. fn clamp_usize(v: u64) -> usize { usize::try_from(v).unwrap_or(usize::MAX) } -// --------------------------------------------------------------------------- -// Connection state -// --------------------------------------------------------------------------- - -/// Everything one connection owns, shared by its reader thread, its pool -/// workers, and its watch forwarders. struct Conn { host: SharedHost, sink: Arc<Sink>, - /// Request id → cancelled. Present exactly while the request is outstanding, - /// so a cancel for an id that has already been answered records nothing and - /// leaks nothing. inflight: Mutex<HashMap<u64, bool>>, watches: Mutex<HashMap<u64, WatchSub>>, - /// Watch forwarders that have been set up but must not start until their - /// `WatchId` reply has gone out, keyed by the request that opened them. - /// - /// The forwarder writes to the same sink the reply does, and nothing - /// ordered the two: a directory that changed in the instant it was first - /// watched could push a batch that overtook the reply. The client files a - /// watch id only once `call` returns, so it has no entry for that id yet - /// and drops the batch under the unknown-id rule — and since the file tree - /// relists only on a watch event, the change stays invisible until - /// something else touches the directory. deferred_watches: Mutex<HashMap<u64, (u64, smol::channel::Receiver<Vec<PathBuf>>)>>, next_watch: AtomicU64, - /// Git streams running on this connection right now, against - /// [`MAX_CONCURRENT_GIT_STREAMS`]. Shared with each stream's own thread, - /// which gives its slot back on the way out — see [`StreamSlot`]. git_streams: Arc<AtomicUsize>, pool: Pool, - /// The machine's workspace tree, when this server serves it. machine: Option<Arc<MachineStore>>, - /// This connection's tree-subscriber id — origin exclusion, so a tree - /// operation's own `Layout` delta never comes back to its writer. machine_origin: Option<machine::SubscriberId>, - /// Shared with every other connection this server accepts — see - /// [`AttachRegistry`]. attachments: Arc<AttachRegistry>, - /// This connection's serial number, which is what "the *same* connection - /// re-attaching" is decided on. id: u64, - /// Who this connection attaches as, and how to reach it. holder: Holder, } impl Conn { - /// The machine tree, or the refusal a server not carrying one answers. - /// The client's cue is the `machine-tree` capability bit; this is the - /// answer for one that asked anyway. fn machine(&self) -> io::Result<&Arc<MachineStore>> { self.machine .as_ref() .ok_or_else(|| io::Error::other("this server does not serve the machine tree")) } - /// Give up every workspace this connection still holds, at teardown. - /// - /// Connection-scoped, so a workspace that was taken over from this session - /// earlier is already gone from the registry and is not touched — the exact - /// case the tree's token check exists for, seen from the other side. fn release_all_workspaces(&self) { let _handover = self.attachments.handover(); let released = self.attachments.release_conn(self.id); @@ -1129,8 +782,6 @@ impl Conn { .remove(&req_id); } - /// Mark a request abandoned. Best effort by contract: work already running - /// runs to completion, and only its reply is dropped. fn cancel(&self, req_id: u64) { let mut f = self.inflight.lock().unwrap_or_else(|e| e.into_inner()); if let Some(flag) = f.get_mut(&req_id) { @@ -1138,7 +789,6 @@ impl Conn { } } - /// Retire a request and, unless it was cancelled, send its reply. fn finish(&self, req_id: u64, reply: ControlReply, blob: Vec<u8>, wants_blob: bool) { let cancelled = self .inflight @@ -1152,9 +802,6 @@ impl Conn { return; } - // `wants_blob` rather than `!blob.is_empty()`: `ReadFile` on an empty - // file still has to answer on the blob-carrying kind, because the client - // matches on the reply shape and an empty file is a legitimate answer. let msg = if wants_blob && matches!(reply, ControlReply::Ok(_)) { ControlServerMsg::ResponseBlob { req_id, @@ -1164,12 +811,6 @@ impl Conn { } else { ControlServerMsg::Response { req_id, reply } }; - // Encoded before anything is written, so a reply this server cannot put - // on the wire — a `SearchHit` whose path is not UTF-8, a `MachineGet` - // grown past `MAX_FRAME` — becomes an error the client *receives*. - // Dropping it instead leaves the client waiting out the request's whole - // deadline (20s for a search, and again on the next keystroke) for a - // reply that was never coming. let delivered = match msg.to_frame() { Ok((k, payload)) => match self.sink.send_frame(k, &payload) { Ok(()) => true, @@ -1191,26 +832,17 @@ impl Conn { } }; - // Only now, and only if the client actually learned the id: a batch - // that overtook this reply would be dropped by a client that has no - // entry for it yet. See `Conn::deferred_watches`. self.start_deferred_watch(req_id, delivered); } - /// Open a watch and start forwarding its batches as pushes. fn open_watch(&self, req_id: u64, dirs: &[PathBuf]) -> io::Result<u64> { let sub = self.host.watch(dirs)?; let id = self.next_watch.fetch_add(1, Ordering::Relaxed); - // Clone the receiver before the subscription is filed away: the - // forwarder needs it, and `WatchSub` itself stays here so that dropping - // the entry is what unsubscribes. let rx = sub.events().clone(); self.watches .lock() .unwrap_or_else(|e| e.into_inner()) .insert(id, sub); - // Parked rather than started — see `deferred_watches`. `finish` starts - // it once the reply carrying `id` is on the wire. self.deferred_watches .lock() .unwrap_or_else(|e| e.into_inner()) @@ -1218,12 +850,6 @@ impl Conn { Ok(id) } - /// Start the forwarder for a watch opened by `req_id`, if there was one. - /// - /// Called from [`Conn::finish`] after the reply has gone out, and on the - /// paths where no reply goes out at all — a cancelled request, or one whose - /// reply could not be written — so a parked forwarder is never left holding - /// a receiver nobody will read. fn start_deferred_watch(&self, req_id: u64, deliver: bool) { let parked = self .deferred_watches @@ -1234,8 +860,6 @@ impl Conn { return; }; if !deliver { - // The client never learned this id, so every batch would be - // dropped. Let the subscription go with the watch entry instead. self.watches .lock() .unwrap_or_else(|e| e.into_inner()) @@ -1245,24 +869,9 @@ impl Conn { spawn_watch_forwarder(id, rx, Arc::clone(&self.sink)); } - /// Run a git stream, pushing its output under `id`. - /// - /// Whatever happens, the client hears the end of it: a stream that stops - /// sending without a [`ControlEvent::GitEnd`] leaves the reader waiting on - /// pushes that will never come, and only its idle timeout to fall out of. - /// The single exception is a link that is already gone, where there is - /// nobody left to tell. - /// - /// Capped at [`MAX_CONCURRENT_GIT_STREAMS`] per connection: this is the one - /// request that spawns a thread outside the bounded worker pool, so nothing - /// else counts them. fn start_git_stream(&self, id: u64, cwd: PathBuf, args: Vec<String>) { let host = Arc::clone(&self.host); let sink = Arc::clone(&self.sink); - // The slot is claimed *before* the thread exists, so a burst of requests - // cannot all look at the counter and each see room. The guard gives it - // back however this leaves — refused here, failed to spawn below, or the - // thread running to its end. let slot = StreamSlot(Arc::clone(&self.git_streams)); if slot.0.fetch_add(1, Ordering::AcqRel) >= MAX_CONCURRENT_GIT_STREAMS { drop(slot); @@ -1284,9 +893,6 @@ impl Conn { let mut stopped: Option<StreamStop> = None; let flush = |batch: &mut Vec<u8>, stopped: &mut Option<StreamStop>| { if stopped.is_some() { - // Nothing will be sent again, so nothing is worth - // holding: whatever accumulated goes now rather than - // riding along to the end of the read. batch.clear(); return; } @@ -1294,19 +900,11 @@ impl Conn { return; } let bytes = std::mem::take(batch); - // One flush is usually one frame; it is more only when a - // single line pushed the batch past the frame ceiling. for piece in bytes.chunks(GIT_STREAM_CHUNK_MAX) { let event = ControlServerMsg::Event(ControlEvent::GitChunk { id, bytes: piece.to_vec(), }); - // Encoding and writing are asked separately because - // their failures mean opposite things — see - // `Sink::send_frame`. A payload that will not encode - // leaves a connection this stream still owes an answer - // to; a write that fails means there is nobody left to - // answer. let Ok((kind, payload)) = event.to_frame() else { *stopped = Some(StreamStop::Unencodable); return; @@ -1318,14 +916,6 @@ impl Conn { } }; let result = host.git_lines(&cwd, &borrowed, &mut |line| { - // Once the stream has stopped speaking, keeping the rest of - // the output would grow this batch to the size of the whole - // diff for a client that will never see a byte of it — the - // peak-memory cost streaming exists to remove. The residual - // is deliberate and bounded: `git_lines` takes a callback - // with no way to say "stop", so git still runs to - // completion and spends its own CPU, but nothing here - // accumulates. if stopped.is_some() { return; } @@ -1340,14 +930,8 @@ impl Conn { return; } let (code, failed) = match (stopped, result) { - // The bytes exist but could not be put on the wire. The - // client cannot be given them, and saying so is the only - // honest end — silence would leave it parked on a stream - // that will never speak again. (Some(StreamStop::Unencodable), _) => (None, true), (_, Ok(code)) => (code, false), - // git could not be run at all — distinct from a non-zero exit, - // and the client must be able to tell them apart. (_, Err(_)) => (None, true), }; let _ = sink.send(&ControlServerMsg::Event(ControlEvent::GitEnd { @@ -1357,8 +941,6 @@ impl Conn { })); }); if spawned.is_err() { - // No thread to read git with: say so rather than leaving the client - // waiting out its deadline on a stream that will never speak. let _ = self .sink .send(&ControlServerMsg::Event(ControlEvent::GitEnd { @@ -1380,9 +962,6 @@ impl Conn { sub.set_dirs(dirs) } - /// Closing is dropping: the `WatchSub`'s own `Drop` releases the OS watcher, - /// which closes the batch channel, which ends the forwarder thread. Nothing - /// else has to be told. fn close_watch(&self, id: u64) { self.watches .lock() @@ -1391,20 +970,9 @@ impl Conn { } } -/// Subscribe this connection to the machine tree's deltas, if there is one. -/// -/// Two hops rather than one, and the split is the point. The store's callback -/// runs on the thread of *whichever connection made the change*, so it only -/// enqueues; the forwarder thread is what actually writes, and a peer that has -/// stopped reading stalls nothing but its own forwarder. Calling `Sink::send` -/// straight from the callback would have one wedged client hold up every other -/// client's edit. The queue-full case is documented on [`LAYOUT_EVENT_QUEUE`]. fn subscribe_machine(services: &Services, sink: &Arc<Sink>) -> Option<machine::Subscription> { let store = services.machine.as_ref()?; let (tx, rx) = smol::channel::bounded::<(String, machine::LayoutDelta)>(LAYOUT_EVENT_QUEUE); - // Set on a drop, consumed by the forwarder: a dropped delta leaves the - // peer's mirror wrong forever, so it must be told to re-pull rather than - // left to mirror a tree it is no longer looking at. let lagged = Arc::new(std::sync::atomic::AtomicBool::new(false)); let saw_drop = Arc::clone(&lagged); let subscription = store.subscribe(Arc::new( @@ -1422,27 +990,6 @@ fn subscribe_machine(services: &Services, sink: &Arc<Sink>) -> Option<machine::S Some(subscription) } -/// Relay tree deltas to the peer as `Layout` pushes — prefixed by a -/// [`ControlEvent::LayoutResync`] **in place of** everything the queue still -/// holds, whenever it dropped one. -/// -/// Announcing and then draining the backlog would be worse than not announcing -/// at all. The queue is FIFO, so a drop discards the *newest* delta and -/// everything still queued is **older** than the gap: the peer would re-pull the -/// tree on the resync and then apply a stretch of history from before it, -/// `TabRestructured` replacing whole tabs with the shapes they had — a window -/// silently reverting to a layout nobody is looking at, with mirror and window -/// in agreement so nothing triggers recovery a second time. Every queued delta -/// is by construction already in the tree the peer is about to pull, so the -/// backlog is not lost information; it is superseded information. -/// -/// The flag-then-announce order is safe by construction: `lagged` is only set -/// when the queue is full, so a delivery always follows a drop and the -/// announcement never waits on a quiet tree. -/// -/// Ends on its own when the `Subscription` is dropped: that removes the -/// closure holding the sender, which closes the channel. Same shape, and the -/// same reason, as [`spawn_watch_forwarder`]. fn spawn_layout_forwarder( rx: smol::channel::Receiver<(String, machine::LayoutDelta)>, sink: Arc<Sink>, @@ -1453,9 +1000,6 @@ fn spawn_layout_forwarder( .spawn(move || { while let Ok((workspace, delta)) = rx.recv_blocking() { if lagged.swap(false, Ordering::AcqRel) { - // This delta and everything behind it predate the gap. Drop - // the lot and send the one event that repairs a peer whose - // history has a hole in it. let mut superseded = 1; while rx.try_recv().is_ok() { superseded += 1; @@ -1483,21 +1027,12 @@ fn spawn_layout_forwarder( } } -/// Relay one subscription's batches to the peer as `CONTROL_EVENT` pushes. -/// -/// The host has already coalesced and deduplicated within its window, so this -/// thread does exactly two things the wire needs: renders paths as strings, and -/// converts an oversized batch into an overflow. It ends on its own when the -/// subscription is dropped — that closes the channel — so nothing has to track -/// or join it. fn spawn_watch_forwarder(id: u64, rx: smol::channel::Receiver<Vec<PathBuf>>, sink: Arc<Sink>) { let spawned = std::thread::Builder::new() .name("tty7-control-watch".into()) .spawn(move || { while let Ok(batch) = rx.recv_blocking() { let event = if batch.len() > WATCH_BURST_CAP { - // Past the cap, enumerating costs more than re-listing: the - // client answers an overflow by invalidating the subtree. ControlEvent::WatchOverflow { id } } else { ControlEvent::Watch { @@ -1518,11 +1053,6 @@ fn spawn_watch_forwarder(id: u64, rx: smol::channel::Receiver<Vec<PathBuf>>, sin } } -/// One connection's claim on a concurrent git stream, given back on drop. -/// -/// A guard rather than a bare `fetch_sub` at the end of the thread, so a slot is -/// returned on every exit — including the thread that fails to spawn, and a -/// panic unwinding out of the read. struct StreamSlot(Arc<AtomicUsize>); impl Drop for StreamSlot { @@ -1531,27 +1061,12 @@ impl Drop for StreamSlot { } } -/// Why a git stream stopped pushing chunks early. -/// -/// The two are not interchangeable, which is the whole reason the distinction -/// is carried: only one of them means the peer is gone. See -/// [`Conn::start_git_stream`]. #[derive(Clone, Copy, PartialEq, Eq)] enum StreamStop { - /// The write failed: the link is retired or broken, and nothing more will - /// reach the client — including a `GitEnd`. LinkGone, - /// The chunk would not encode. The connection is fine and still owes this - /// stream a terminating event. Unencodable, } -/// The connection's write half. -/// -/// One mutex, one whole frame per acquisition. Pool workers and watch forwarders -/// all write here concurrently, and a frame that interleaved with another would -/// desync the peer permanently — there is no resynchronization point in a -/// length-prefixed stream. struct Sink { out: Mutex<Option<Box<dyn Write + Send>>>, } @@ -1568,11 +1083,6 @@ impl Sink { self.send_frame(k, &payload) } - /// The write half of [`Sink::send`], for callers that already encoded. - /// - /// Split so that "this reply cannot be encoded" and "this link is gone" are - /// distinguishable: only the second may have put bytes on the wire, and only - /// the first leaves the connection well enough to answer on. fn send_frame(&self, k: u8, payload: &[u8]) -> io::Result<()> { let mut slot = self.out.lock().unwrap_or_else(|e| e.into_inner()); let w = slot.as_mut().ok_or_else(|| { @@ -1581,27 +1091,13 @@ impl Sink { crate::daemon::protocol::write_frame(&mut *w, k, payload).and_then(|()| w.flush()) } - /// Drop the write half at teardown, so a worker that finishes afterwards - /// fails immediately instead of writing into a link nobody is reading. fn retire(&self) { *self.out.lock().unwrap_or_else(|e| e.into_inner()) = None; } } -// --------------------------------------------------------------------------- -// The pool -// --------------------------------------------------------------------------- - type Job = Box<dyn FnOnce() + Send + 'static>; -/// An elastic worker pool: grows on demand to [`MAX_WORKERS`], shrinks by -/// letting idle workers retire after [`WORKER_LINGER`]. -/// -/// Deliberately not a fixed pool. A fixed `N` guarantees that `N` slow requests -/// stall the `N+1`th — which is the exact failure `req_id` multiplexing exists to -/// prevent, reintroduced one layer down. Deliberately not thread-per-request -/// either: a file tree expanding a directory issues dozens of `stat`s, and a -/// thread per `stat` costs more than the `stat`. struct Pool { inner: Arc<PoolInner>, } @@ -1613,33 +1109,12 @@ struct PoolInner { struct PoolState { jobs: VecDeque<Job>, - /// Live worker threads. workers: usize, - /// Workers currently parked on [`PoolInner::wake`]. A job only needs a *new* - /// worker when this is zero. idle: usize, closed: bool, } impl PoolState { - /// Whether a job just queued needs a worker spawned for it. - /// - /// Compares the backlog against the parked workers rather than asking - /// whether *any* worker is parked. `idle` counts a worker from before it - /// parks until after it has re-acquired the lock on its way out, so for the - /// whole wake-up window a worker that has already been handed a job still - /// looks free — and the `notify_one` a second submit sends in that window - /// goes to a thread that has left the wait set, so it is lost. - /// - /// Concretely: with `k` workers parked, a client pipelining `k+1` frames in - /// one read — a `Git` plus `k` `ReadDir`s, which is what the file tree and - /// the branch line produce together — got `k` of them running and left the - /// last queued behind a `git status` that can take twenty seconds. That is - /// the head-of-line blocking this pool is elastic in order to avoid. - /// - /// Counting both sides makes the window harmless: the job destined for a - /// parked-but-notified worker is still in `jobs`, so it cancels that - /// worker out and the next job over sees no spare capacity. fn wants_another_worker(&self) -> bool { self.jobs.len() > self.idle && self.workers < MAX_WORKERS } @@ -1660,8 +1135,6 @@ impl Pool { } } - /// Queue `job`. `false` means the pool is closed or the backlog is full and - /// the caller must answer the request itself. fn submit(&self, job: impl FnOnce() + Send + 'static) -> bool { let mut st = self.inner.state.lock().unwrap_or_else(|e| e.into_inner()); if st.closed || st.jobs.len() >= MAX_QUEUED { @@ -1669,9 +1142,6 @@ impl Pool { } st.jobs.push_back(Box::new(job)); - // Spawn only when the backlog outruns the workers parked to take it, so - // a steady stream of requests is served by one warm worker rather than a - // thread per call. if st.wants_another_worker() { st.workers += 1; let inner = Arc::clone(&self.inner); @@ -1681,8 +1151,6 @@ impl Pool { { Ok(_) => return true, Err(e) => { - // Out of threads: keep the job queued for whoever is already - // running, and only fail if there is nobody at all. st.workers -= 1; log::warn!("could not start a control worker: {e}"); if st.workers == 0 { @@ -1697,15 +1165,6 @@ impl Pool { true } - /// Stop the pool and drop anything still queued. - /// - /// Clearing the queue is not just tidiness: a queued job holds an `Arc<Conn>` - /// and the `Conn` holds the pool, so leaving jobs behind would be a reference - /// cycle that keeps the whole connection — watches included — alive forever. - /// - /// Running workers are not joined. One may be twenty seconds into a `git`, - /// and the caller of `close` is a connection teardown that must not wait on - /// it; the worker finds a retired sink, fails its write, and exits. fn close(&self) { { let mut st = self.inner.state.lock().unwrap_or_else(|e| e.into_inner()); @@ -1751,13 +1210,6 @@ fn worker(inner: Arc<PoolInner>) { } } -// --------------------------------------------------------------------------- -// The machine-local control socket -// --------------------------------------------------------------------------- - -/// Overrides [`control_socket_path`]. Set by anything that needs its own -/// endpoint instead of the per-user one: the end-to-end tests, and a second -/// server on a shared box. pub const CONTROL_SOCK_ENV: &str = "TTY7_CONTROL_SOCK"; #[cfg(unix)] @@ -1766,24 +1218,8 @@ mod sock { use std::os::unix::fs::PermissionsExt as _; use std::os::unix::net::{UnixListener, UnixStream}; - /// `sockaddr_un.sun_path` is 104 bytes on macOS and 108 on Linux, NUL - /// included. Staying under the smaller figure keeps one code path. const MAX_SOCKET_PATH_BYTES: usize = 100; - /// Where a `tty7-server` listens for control connections. - /// - /// | Order | Path | Why | - /// |---|---|---| - /// | 1 | `$TTY7_CONTROL_SOCK` | Explicit wins; this is how tests and a second instance get their own endpoint | - /// | 2 | `$XDG_RUNTIME_DIR/tty7/daemon.sock` | The per-user, per-boot, already-0700 directory Linux provides for exactly this | - /// | 3 | `~/.local/share/tty7/daemon.sock` | No `XDG_RUNTIME_DIR` (macOS, minimal containers) | - /// | 4 | `<runtime-or-tmp>/tty7-<hash>.sock` | Any of the above too long for `sun_path` | - /// - /// The hashed fallback is the same construction - /// [`daemon::transport`](crate::daemon::transport) uses for the pane socket, - /// over the same [`fnv1a64`](crate::host::fnv1a64) — deliberately a fixed - /// hash rather than `DefaultHasher`, because the two processes that have to - /// derive the same path can be different builds. pub fn control_socket_path() -> io::Result<PathBuf> { if let Some(explicit) = std::env::var_os(CONTROL_SOCK_ENV).filter(|v| !v.is_empty()) { return Ok(PathBuf::from(explicit)); @@ -1805,20 +1241,12 @@ mod sock { socket_path_in(&dir, &fallbacks) } - /// The length-aware half of [`control_socket_path`], split out because the - /// whole of it reads `$XDG_RUNTIME_DIR` — and a test that set that would be - /// changing a process-global under every other test running beside it. pub(super) fn socket_path_in(dir: &Path, fallbacks: &[PathBuf]) -> io::Result<PathBuf> { let inline = dir.join("daemon.sock"); if fits(&inline) { return Ok(inline); } - // The hashed name keeps distinct directories on distinct endpoints while - // fitting in `sun_path`. Every candidate base is tried because any of - // them can itself be too long — a deep `$XDG_RUNTIME_DIR` makes the - // "short" path no shorter — and handing back something `bind` will - // reject turns a fixable configuration into an unexplained failure. use std::os::unix::ffi::OsStrExt as _; let name = format!( "tty7-{:016x}.sock", @@ -1847,39 +1275,13 @@ mod sock { p.as_os_str().as_bytes().len() <= MAX_SOCKET_PATH_BYTES } - /// Bind the control socket, replacing a stale one. - /// - /// Permissions are the access boundary — a Unix socket has no other, and - /// what is behind it is `ReadFile`/`WriteFile`/`Git` on arbitrary paths as - /// this user. So the socket has to be 0600 from the first instant its final - /// name exists, which `bind` alone cannot give: `bind` creates the node at - /// `0777 & ~umask`, and a `chmod` on the next line is a window. Under a - /// `umask 002` — the default wherever user-private groups are configured — - /// that window is group-connectable. - /// - /// So the umask is tightened across the `bind` itself. Not a staging - /// directory and a rename, which would also close the window: the staging - /// path is longer than the final one, and `sun_path` is the one budget here - /// with no room to spend — [`socket_path_in`] already falls back to a hashed - /// name to stay under it. pub fn bind_control_socket(path: &Path) -> io::Result<UnixListener> { let parent = path.parent().unwrap_or(Path::new(".")); - // `create_dir_all` and *only then* a chmod, on a directory that did not - // exist a moment ago: `path` can be `$TTY7_CONTROL_SOCK` or the hashed - // fallback, whose parent is `$XDG_RUNTIME_DIR` or `/tmp` — directories - // this process does not own and must not re-permission. Tightening - // `/tmp` to 0700 would lock every other user out of it, sticky bit and - // all, with nothing linking the breakage back to tty7. if !parent.exists() { std::fs::create_dir_all(parent)?; - // Ours by construction, since it did not exist above. Best effort: - // losing the race to another tty7 starting at the same instant - // leaves the directory correct anyway. let _ = std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700)); } - // A socket file left by a crashed server looks exactly like a live one. - // Connecting is the only way to tell, so probe before clearing. if path.exists() { match UnixStream::connect(path) { Ok(_) => { @@ -1898,51 +1300,24 @@ mod sock { } let listener = bind_private(path)?; - // Belt and braces on two counts: it narrows the 0700 the umask below - // yields to the 0600 a socket actually needs, and it covers a - // filesystem that ignores the umask entirely (some FUSE mounts, - // anything with a default ACL) — which would otherwise leave the socket - // wide open with nothing to notice it. std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?; Ok(listener) } - /// `bind`, with the umask tightened so the node is created owner-only - /// rather than created at `0777 & ~umask` and fixed up afterwards. - /// - /// `0o077`, not `0o177`: the umask is process-global for the length of the - /// `bind`, and a *directory* another thread creates in that window would - /// come out without its owner-execute bit and be unusable — the test suite - /// found this the hard way. Masking only the group and other bits leaves - /// anything created alongside it working while still giving the socket no - /// group or world access, which is the whole property: connecting to a Unix - /// socket needs write permission on it. pub(super) fn bind_private(path: &Path) -> io::Result<UnixListener> { - // Serializes tty7's own binds against each other, so two of them cannot - // interleave their save/restore and leave the umask tightened. static UMASK: Mutex<()> = Mutex::new(()); let _held = UMASK.lock().unwrap_or_else(|e| e.into_inner()); - // SAFETY: `umask` is always successful and has no preconditions; the - // only hazard is the process-global effect, which the lock and the - // immediate restore below bound. let previous = unsafe { libc::umask(0o077) }; let bound = UnixListener::bind(path); unsafe { libc::umask(previous) }; bound } - /// Serve control connections on `listener` until it fails, one thread per - /// connection. pub fn serve_listener(listener: UnixListener, host: SharedHost) { serve_listener_with(listener, host, Services::none()) } - /// [`serve_listener`], with the extra services every connection gets. - /// - /// One [`MachineStore`] shared by every connection, which is what makes a - /// change on one visible to the others: two stores over one file would - /// each believe their own copy and the last save would win silently. pub fn serve_listener_with(listener: UnixListener, host: SharedHost, services: Services) { for stream in listener.incoming() { match stream { @@ -1960,25 +1335,15 @@ mod sock { log::warn!("could not start a control connection thread: {e}"); } } - // One bad accept must not take the server down; the daemon's own - // listener has behaved this way since it was written. Err(e) => log::warn!("control accept failed: {e}"), } } } - /// Bind the per-user control socket and serve it on a background thread. - /// - /// Returns the path it is listening on. Used by `tty7-server --daemon`, - /// which then goes on to run the pane server on the *other* socket — the two - /// dialects are independent listeners on purpose, so a machine can serve a - /// remote workspace's files without serving panes, and vice versa. pub fn spawn_control_listener(host: SharedHost) -> io::Result<PathBuf> { spawn_control_listener_with(host, Services::none()) } - /// [`spawn_control_listener`], with the extra services every connection - /// gets. pub fn spawn_control_listener_with( host: SharedHost, services: Services, @@ -1998,58 +1363,25 @@ pub use sock::{ spawn_control_listener, spawn_control_listener_with, }; -/// The machine-local control endpoint on Windows, which has no Unix sockets. -/// -/// The same two-listeners-one-daemon shape as [`sock`], over the transport the -/// pane dialect already uses on this platform: a loopback `TcpListener` on an -/// OS-assigned port, recorded in a user-private marker file next to -/// `daemon.port` together with a 256-bit token every connection must present. -/// Loopback is reachable by any local process, so the token is the access -/// boundary here exactly as file permissions are on Unix — see -/// [`crate::daemon::transport`], whose machinery this reuses rather than -/// re-deriving. -/// -/// Its own port and its own token, not the pane listener's: the two dialects are -/// independent services, and a client that learned one endpoint's token has not -/// thereby been granted the other. #[cfg(windows)] mod wsock { use super::*; use crate::daemon::transport; use std::net::TcpListener; - /// Where the control listener records its port and token, beside the pane - /// dialect's `daemon.port`. pub const CONTROL_PORT_FILE: &str = "control.port"; - /// The marker file's path — the Windows answer to `control_socket_path`, - /// and what a log line naming the endpoint should print. pub fn control_endpoint_path() -> io::Result<PathBuf> { transport::port_path_named(CONTROL_PORT_FILE).ok_or_else(|| { io::Error::other("no config directory to record the control endpoint in") }) } - /// Bind the control endpoint and serve it on a background thread, one thread - /// per connection. Returns the marker file it recorded itself in. - /// - /// Every accepted connection is authenticated *before* a frame is parsed: - /// what is behind this endpoint is `ReadFile` / `WriteFile` / `Git` on - /// arbitrary paths as this user, plus the machine's workspace tree. pub fn spawn_control_listener_with( host: SharedHost, services: Services, ) -> io::Result<PathBuf> { let path = control_endpoint_path()?; - // Refuse when one is already listening, exactly as - // [`bind_control_socket`](sock::bind_control_socket) does — and for a - // reason that bites harder here. Binding is what *writes* the marker - // file, so a second daemon that goes on to lose the pane-socket race - // (`run` bails with "already running") would have pointed every client - // on the machine at a listener that is about to exit. A marker left by a - // crashed daemon looks exactly like a live one, so the only way to tell - // is to connect: the same probe, with the same rare false positive if an - // unrelated process has since taken that ephemeral port. if let Ok(live) = transport::connect_endpoint(CONTROL_PORT_FILE) { drop(live); return Err(io::Error::new( @@ -2068,13 +1400,10 @@ mod wsock { Ok(path) } - /// [`spawn_control_listener_with`] with no extra services — host RPC only. pub fn spawn_control_listener(host: SharedHost) -> io::Result<PathBuf> { spawn_control_listener_with(host, Services::none()) } - /// Serve control connections on `listener` until it fails, one thread per - /// connection, rejecting any that cannot present `token`. pub fn serve_listener_with( listener: TcpListener, token: transport::Token, @@ -2090,9 +1419,6 @@ mod wsock { let spawned = std::thread::Builder::new() .name("tty7-control-conn".into()) .spawn(move || { - // Before anything else: an unauthenticated peer is - // some other process on this machine, and it gets - // no dialect at all. if let Err(e) = transport::check_endpoint_token(&mut stream, &token) { log::warn!("control connection rejected: {e}"); return; @@ -2105,22 +1431,15 @@ mod wsock { log::warn!("could not start a control connection thread: {e}"); } } - // One bad accept must not take the server down; the daemon's own - // listener has behaved this way since it was written. Err(e) => log::warn!("control accept failed: {e}"), } } } - /// Dial this machine's control endpoint, presenting the token from its - /// marker file. The client half of the boundary above; the GUI's local link - /// is its only caller. pub fn connect_control() -> io::Result<std::net::TcpStream> { transport::connect_endpoint(CONTROL_PORT_FILE) } - /// Forget the endpoint marker — the daemon's shutdown path, so a stale file - /// does not send the next GUI at a port nobody is listening on. pub fn remove_control_endpoint() { transport::remove_endpoint(CONTROL_PORT_FILE); } @@ -2132,24 +1451,16 @@ pub use wsock::{ spawn_control_listener, spawn_control_listener_with, }; -/// The pool is plain threads and channels, so unlike the rest of this file's -/// tests — which need a Unix socket pair — these hold on every platform. #[cfg(test)] mod pool_tests { use super::*; use std::sync::atomic::AtomicBool; use std::time::Instant; - /// Workers are spawned only when nobody is free, so a serial stream of jobs - /// costs one thread rather than one thread per job. #[test] fn the_pool_reuses_a_warm_worker() { let pool = Pool::new(); - // A job signals from *inside* itself, so it has sent before its worker - // has parked again. Submitting in that window is a genuine "nobody is - // free" and legitimately spawns a second worker — so let the pool settle - // first, and the assertion is about reuse rather than about timing. let settled = || { let deadline = Instant::now() + Duration::from_secs(5); while Instant::now() < deadline { @@ -2178,14 +1489,6 @@ mod pool_tests { ); } - /// The spawn decision, at the three states that distinguish it from - /// "is anybody parked?". - /// - /// Driven directly because the state it has to get right — a worker that - /// has been notified but has not yet re-acquired the lock, so it is counted - /// in `idle` while the job meant for it is still counted in `jobs` — is a - /// few instructions wide and a racing test passes against the broken rule - /// far more often than it fails. #[test] fn a_worker_is_spawned_when_the_backlog_outruns_the_parked_workers() { let state = |jobs: usize, workers: usize, idle: usize| PoolState { @@ -2195,24 +1498,15 @@ mod pool_tests { closed: false, }; - // One parked worker, one job: it is exactly the warm-worker case, and - // spawning here is what would cost a thread per request. assert!(!state(1, 1, 1).wants_another_worker()); - // One parked worker, two jobs. The second job arrived inside the - // first's wake-up window, so `idle` still says 1 — and the old rule, - // which only asked whether `idle == 0`, left this job queued behind a - // request that can take twenty seconds. assert!(state(2, 1, 1).wants_another_worker()); - // Nobody parked at all. assert!(state(1, 1, 0).wants_another_worker()); - // And the ceiling still holds. assert!(!state(64, MAX_WORKERS, 0).wants_another_worker()); } - /// And it does grow when work genuinely overlaps. #[test] fn the_pool_grows_for_concurrent_work() { let pool = Pool::new(); @@ -2241,16 +1535,12 @@ mod pool_tests { pool.close(); } - /// Closing drops the backlog. That is what breaks the `Conn` → `Pool` → job - /// → `Arc<Conn>` cycle; leaving the jobs queued would keep every watch on a - /// dead connection alive for the life of the process. #[test] fn closing_the_pool_drops_queued_work() { let pool = Pool::new(); let gate = Arc::new((Mutex::new(false), Condvar::new())); let ran = Arc::new(AtomicBool::new(false)); - // One job to occupy the single worker... let blocker = Arc::clone(&gate); assert!(pool.submit(move || { let (lock, cv) = &*blocker; @@ -2260,7 +1550,6 @@ mod pool_tests { } })); std::thread::sleep(Duration::from_millis(100)); - // ...and one that must never run. let ran2 = Arc::clone(&ran); pool.submit(move || ran2.store(true, Ordering::SeqCst)); @@ -2288,11 +1577,6 @@ mod tests { use std::sync::atomic::AtomicBool; use std::time::Instant; - /// A server on one end of a socket pair and a `RemoteHost` on the other. - /// - /// The same shape as the real thing minus the process boundary, which keeps - /// these unit tests to milliseconds while the cross-process proof lives in - /// `tty7-server`'s stdio integration test. struct Pair { host: Arc<RemoteHost>, } @@ -2311,22 +1595,14 @@ mod tests { pair_with(LocalHost::new()) } - /// A raw client: the frames, with no `Host` in the way. Some assertions are - /// about the wire itself (which kind a reply used, whether a blob rode - /// along) and cannot be made through a typed API that hides it. fn raw() -> (UnixStream, ControlHelloOk) { raw_with(Services::none()) } - /// [`raw`], against a server offering `services`. fn raw_with(services: Services) -> (UnixStream, ControlHelloOk) { raw_hello(services, ControlHello::host_rpc("t", "h")).0 } - /// [`raw_with`], saying a specific hello — the token, hostname and bound - /// workspace are what the takeover decides on, so the attach tests need to - /// choose them. Also answers the server thread's handle so a test can prove - /// the connection was closed rather than merely quiet. fn raw_hello( services: Services, hello: ControlHello, @@ -2344,7 +1620,6 @@ mod tests { ((client, ok), served) } - /// A hello that binds a connection to one workspace. fn hello_for(workspace: &str, token: &str, hostname: &str) -> ControlHello { ControlHello { control_version: CONTROL_VERSION, @@ -2354,10 +1629,6 @@ mod tests { } } - /// Issue one request on a raw socket and return its reply, collecting any - /// pushes that arrive first — a `Preempted` and a reply race by - /// construction, so a reader that assumed the next frame was its answer - /// would be flaky rather than wrong. fn round_trip( sock: &mut UnixStream, req_id: u64, @@ -2379,7 +1650,6 @@ mod tests { } } - /// Read frames until a `Preempted` push arrives, or the link ends. fn await_preempted(sock: &mut UnixStream) -> Option<ControlEvent> { loop { match ControlServerMsg::read(sock) { @@ -2390,9 +1660,6 @@ mod tests { } } - /// Wait for the hello-time attach to land. It runs on the server thread - /// *after* the handshake reply goes out, so a client that read `HELLO_OK` - /// has not necessarily been recorded yet. fn await_holder(registry: &AttachRegistry, workspace: &str, hostname: &str) { let deadline = Instant::now() + Duration::from_secs(5); while registry.holder(workspace).map(|(_, h)| h).as_deref() != Some(hostname) { @@ -2405,18 +1672,12 @@ mod tests { } } - /// Services carrying a fresh machine tree — the shape every attach and - /// takeover test runs against, because the tree is where the attachment's - /// data half lives. fn workspace_services() -> (Services, tempfile::TempDir) { let dir = tempfile::TempDir::new().unwrap(); let store = MachineStore::open(dir.path().join(machine::MACHINE_FILE)); (Services::with_machine(store), dir) } - /// A workspace created in `services`' tree, as the string id the attach - /// verbs carry. The tree only records an attachment for a workspace it - /// lists, so the takeover tests attach to a real one. fn tree_workspace(services: &Services) -> String { services .machine @@ -2428,15 +1689,6 @@ mod tests { .to_string() } - // ----------------------------------------------------------------------- - // Concurrency - // ----------------------------------------------------------------------- - - /// A host whose `git` sleeps, and whose every other method is the real one. - /// - /// Delegation rather than a stub because the slow request has to be a - /// *real* request that really occupies a worker; a stub host would prove the - /// pool schedules closures, not that the server stays responsive. struct SlowGit { inner: SharedHost, delay: Duration, @@ -2511,10 +1763,6 @@ mod tests { } } - /// **The property the whole design is for.** A twenty-second `git` must not - /// hold up a five-millisecond `stat`, which means the server has to be - /// genuinely concurrent — a serial loop passes every other test in this file - /// and fails this one. #[test] fn a_slow_request_does_not_block_a_fast_one() { let running = Arc::new(AtomicBool::new(false)); @@ -2529,8 +1777,6 @@ mod tests { let dir = tmp.path().to_path_buf(); let slow = std::thread::spawn(move || slow_host.git(&dir, &["status"])); - // Wait for the slow request to actually be inside the handler, so the - // measurement below is about overlap and not about scheduling luck. let deadline = Instant::now() + Duration::from_secs(5); while !running.load(Ordering::SeqCst) && Instant::now() < deadline { std::thread::sleep(Duration::from_millis(5)); @@ -2549,8 +1795,6 @@ mod tests { assert_eq!(out.stdout, b"slow"); } - /// And it holds at scale: dozens of parked requests still leave the pool - /// able to answer, which a fixed-size pool would not. #[test] fn many_slow_requests_still_leave_the_server_answering() { let running = Arc::new(AtomicBool::new(false)); @@ -2585,8 +1829,6 @@ mod tests { } } - /// Replies are matched by id, so they may arrive in any order — and here - /// they arrive in the opposite one. #[test] fn replies_come_back_out_of_order() { let running = Arc::new(AtomicBool::new(false)); @@ -2621,12 +1863,6 @@ mod tests { slow.join().unwrap(); } - // ----------------------------------------------------------------------- - // Handshake - // ----------------------------------------------------------------------- - - /// The handshake reports this machine, and advertises what it can actually - /// do — the client reads `separator` to build every path it will ever send. #[test] fn the_handshake_describes_this_server() { let p = pair(); @@ -2645,8 +1881,6 @@ mod tests { ); } - /// A version mismatch is answered, then closed. Answering first is what lets - /// the client say *which* version it met instead of "the link dropped". #[test] fn a_version_mismatch_is_answered_then_closed() { let (server, mut client) = UnixStream::pair().unwrap(); @@ -2663,7 +1897,6 @@ mod tests { ControlServerMsg::HelloOk(ok) => assert_eq!(ok.control_version, CONTROL_VERSION), other => panic!("{other:?}"), } - // And then nothing: the link is closed, not merely idle. let mut buf = [0u8; 1]; assert_eq!( client.read(&mut buf).unwrap(), @@ -2672,13 +1905,10 @@ mod tests { ); } - /// The client's own connect path refuses a peer that speaks another version, - /// naming both — the end-to-end version of the case above. #[test] fn the_client_refuses_a_mismatched_peer() { let (server, client) = UnixStream::pair().unwrap(); std::thread::spawn(move || { - // A hand-rolled server that claims a different dialect. let mut s = server; let _ = ControlClientMsg::read(&mut s); let _ = ControlServerMsg::HelloOk(ControlHelloOk { @@ -2697,14 +1927,6 @@ mod tests { assert_eq!(err.kind(), io::ErrorKind::Unsupported, "{err}"); } - // ----------------------------------------------------------------------- - // Wire-level guarantees - // ----------------------------------------------------------------------- - - /// `max_bytes` is refused **before the bytes move**. The whole reason the - /// limit crosses the wire is to avoid a transatlantic transfer that ends in - /// the client throwing the result away — so the failing reply must be a - /// plain response with no blob at all. #[test] fn an_oversized_read_ships_no_bytes() { let (mut client, _) = raw(); @@ -2738,9 +1960,6 @@ mod tests { } } - /// An empty file still answers on the blob-carrying kind: the client matches - /// on the reply's *shape*, and "zero bytes" is a real answer rather than an - /// absent one. #[test] fn an_empty_read_still_uses_the_blob_kind() { let (mut client, _) = raw(); @@ -2768,11 +1987,6 @@ mod tests { } } - /// Every error class the server can produce survives the round trip as - /// itself. The client rebuilds an `io::Error` from the wire kind, and a - /// mapping that was not a bijection would silently turn, say, a - /// `DirectoryNotEmpty` into an `Other` and break the file tree's delete - /// confirmation. #[test] fn error_kinds_survive_the_round_trip() { let p = pair(); @@ -2808,12 +2022,9 @@ mod tests { ]; for (want, got) in cases { assert_eq!(got.kind(), want, "{got}"); - // And the kind is what the wire carried, not a local guess: the - // message came from the server. assert!(!got.to_string().is_empty()); } - // The mapping itself, in both directions, over every class. for kind in [ WireErrorKind::NotFound, WireErrorKind::PermissionDenied, @@ -2836,18 +2047,15 @@ mod tests { } } - /// A non-zero `git` exit is a successful reply carrying that status, and it - /// has to stay that way across the wire — this is the split every git call - /// site downstream is built on. #[test] fn a_failing_git_is_ok_across_the_wire() { let p = pair(); let tmp = tempfile::TempDir::new().unwrap(); let Ok(out) = p.host.git(tmp.path(), &["rev-parse", "--show-toplevel"]) else { - return; // no git on this machine + return; }; if out.success() { - return; // the temp dir happens to live in a repository + return; } assert!(out.status.is_some()); assert!( @@ -2856,13 +2064,9 @@ mod tests { ); } - /// A frame the server cannot decode ends the connection rather than being - /// skipped: a length-prefixed stream has no resynchronization point, so - /// continuing would misread everything after it. #[test] fn an_unknown_frame_kind_ends_the_connection() { let (mut client, _) = raw(); - // Kind 99 is in neither space. crate::daemon::protocol::write_frame(&mut client, 99, b"junk").unwrap(); client.flush().unwrap(); let mut buf = [0u8; 1]; @@ -2873,15 +2077,11 @@ mod tests { ); } - /// A cancelled request produces no reply, and the connection carries on — a - /// timed-out request must not leave a late frame that desyncs the peer's - /// idea of what it is reading. #[test] fn a_cancelled_request_is_answered_with_silence() { let (mut client, _) = raw(); let tmp = tempfile::TempDir::new().unwrap(); - // Cancel before the request, so the worker sees the flag on pickup. ControlClientMsg::Cancel { req_id: 1 } .encode(&mut client) .unwrap(); @@ -2895,8 +2095,6 @@ mod tests { .unwrap(); client.flush().unwrap(); - // Only request 2 is answered; a reply for the cancelled id would arrive - // first and fail this. match ControlServerMsg::read(&mut client).unwrap() { ControlServerMsg::Response { req_id, reply } => { assert_eq!(req_id, 2); @@ -2906,8 +2104,6 @@ mod tests { } } - /// A server not carrying the machine tree says so instead of answering - /// with something that looks like an empty machine. #[test] fn the_machine_tree_is_declined_not_faked() { let p = pair(); @@ -2919,13 +2115,6 @@ mod tests { assert!(err.to_string().contains("machine tree"), "{err}"); } - // ----------------------------------------------------------------------- - // Watches - // ----------------------------------------------------------------------- - - /// Watch batches reach the client as pushes, with the host's own coalescing - /// window — the remote path has to behave exactly like the local one, which - /// is why the server adds no window of its own. #[test] fn watch_batches_reach_the_client() { let p = pair(); @@ -2950,20 +2139,6 @@ mod tests { assert!(seen, "no watch event crossed the connection"); } - /// The `WatchId` reply is on the wire before any batch for that id. - /// - /// The ordering is structural — the forwarder is parked in - /// `Conn::deferred_watches` and started by `finish`, so there is no - /// interleaving left to hit. This is the end-to-end statement of that, run - /// under churn; it does **not** reproduce the old race, whose window was a - /// few instructions between `open_watch` returning and `finish` writing on - /// the same thread. - /// - /// What the race cost, when it landed: the client files a watch id only - /// once its `call` returns, so a batch that overtook the reply hit a client - /// with no entry for that id and was dropped under the unknown-id rule. The - /// file tree relists only on a watch event, so that first change stayed - /// invisible until something else touched the directory. #[test] fn a_watch_id_reaches_the_client_before_any_batch_for_it() { let (mut client, _ok) = raw(); @@ -2979,8 +2154,6 @@ mod tests { .unwrap(); client.flush().unwrap(); - // Churn from the moment the request is sent, so the window between the - // watcher going live and the reply going out is a busy one. let churn = tmp.path().to_path_buf(); let stop = Arc::new(AtomicBool::new(false)); let churning = Arc::clone(&stop); @@ -2993,7 +2166,6 @@ mod tests { } }); - // The very first frame back has to be the reply, not an event. let first = ControlServerMsg::read(&mut client).unwrap(); stop.store(true, Ordering::SeqCst); churner.join().unwrap(); @@ -3007,9 +2179,6 @@ mod tests { } } - /// Dropping the subscription releases the *server's* watcher, not just the - /// client's bookkeeping — otherwise every expanded directory in a long - /// session leaks an OS watch on the remote machine. #[test] fn closing_a_watch_releases_it_on_the_server() { let (server, client) = UnixStream::pair().unwrap(); @@ -3025,8 +2194,6 @@ mod tests { let rx = sub.events().clone(); drop(sub); - // Give the close request time to reach the server, then prove the - // watcher is gone by making a change nobody should hear about. std::thread::sleep(Duration::from_millis(300)); std::fs::write(tmp.path().join("after.txt"), b"x").unwrap(); std::thread::sleep(Duration::from_millis(800)); @@ -3040,9 +2207,6 @@ mod tests { } } - /// A `.gitignore` edit invalidates the server's compiled matchers, and the - /// client sees the new answer on its next listing — the invalidation lives in - /// the host's watcher, so a remote client inherits it without asking. #[test] fn a_gitignore_edit_reaches_a_remote_listing() { let p = pair(); @@ -3059,7 +2223,6 @@ mod tests { "the fixture should start out ignored" ); - // The watch is what carries the invalidation, so it has to exist. let sub = p.host.watch(std::slice::from_ref(&root)).unwrap(); let deadline = Instant::now() + Duration::from_secs(15); @@ -3086,9 +2249,6 @@ mod tests { ); } - /// A metadata shape the client asked for comes back as that shape, not a - /// near miss — `write_file`'s reply carries the post-write mtime so the - /// editor never needs a follow-up `stat`. #[test] fn a_write_answers_with_the_new_metadata() { let (mut client, _) = raw(); @@ -3126,16 +2286,8 @@ mod tests { } } - // ----------------------------------------------------------------------- - // The socket - // ----------------------------------------------------------------------- - - /// The explicit override wins, which is what lets a test — or a second - /// server on a shared box — have its own endpoint. #[test] fn the_socket_path_honours_its_override() { - // Not `set_var` on the real environment: this binary runs its tests in - // parallel threads and one of them would see the other's path. let dir = tempfile::TempDir::new().unwrap(); let sock = dir.path().join("explicit.sock"); let listener = bind_control_socket(&sock).unwrap(); @@ -3149,11 +2301,6 @@ mod tests { drop(listener); } - /// A directory that was already there is left alone. `$TTY7_CONTROL_SOCK` - /// and the hashed fallback both put the socket straight into - /// `$XDG_RUNTIME_DIR` or `/tmp`; tightening one of those to 0700 would lock - /// every other user out of it — sticky bit and all, if this is running as - /// root — with nothing linking the breakage back to tty7. #[test] fn binding_does_not_re_permission_a_directory_it_did_not_create() { use std::os::unix::fs::PermissionsExt as _; @@ -3171,19 +2318,12 @@ mod tests { drop(listener); } - /// Owner-only comes from the umask the bind runs under, not from a `chmod` - /// after the fact. A socket that spends even one syscall at `0777 & ~umask` - /// is one another user on the box can connect to, and what is behind it is - /// `ReadFile` on arbitrary paths as this user. #[test] fn a_bind_is_owner_only_under_a_permissive_umask() { use std::os::unix::fs::PermissionsExt as _; let dir = tempfile::TempDir::new().unwrap(); let path = dir.path().join("permissive.sock"); - // SAFETY: `umask` has no preconditions. It is process-global, so an - // unrelated test creating a file in this window sees `0` rather than - // the developer's umask — more permissive, which nothing asserts on. let previous = unsafe { libc::umask(0) }; let bound = sock::bind_private(&path); unsafe { libc::umask(previous) }; @@ -3197,13 +2337,6 @@ mod tests { drop(listener); } - /// One filename this wire cannot carry must not cost the whole search. - /// - /// Not driven through a real file: APFS rejects a non-UTF-8 name outright - /// (`EILSEQ`), so the case cannot be staged on the machine most of this is - /// developed on. The filter is the whole behaviour, so the filter is what - /// is pinned — `to_frame_refuses_what_cannot_be_sent_without_writing_it` - /// covers the other half, that such a hit really would fail to encode. #[test] fn a_filename_that_is_not_utf8_costs_only_its_own_hit() { use std::ffi::OsStr; @@ -3217,7 +2350,6 @@ mod tests { }; let mut hits = vec![ hit("plain-needle.rs", Path::new("/home/me/plain-needle.rs")), - // `café-needle.rs` in Latin-1: a lone 0xe9 is not valid UTF-8. hit( "caf\u{fffd}-needle.rs", Path::new(OsStr::from_bytes(b"/home/me/caf\xe9-needle.rs")), @@ -3234,8 +2366,6 @@ mod tests { ); } - /// A whole conformance-shaped exchange over a real listener, proving the - /// socket path end to end: bind, connect, handshake, RPC. #[test] fn a_socket_connection_serves_requests() { let dir = tempfile::TempDir::new().unwrap(); @@ -3259,9 +2389,6 @@ mod tests { assert_eq!(entries[0].name, "x.txt"); } - /// Binding on top of a live server is refused rather than silently stealing - /// its endpoint — two servers on one socket would each get an arbitrary half - /// of the connections. #[test] fn binding_refuses_a_live_server() { let dir = tempfile::TempDir::new().unwrap(); @@ -3273,68 +2400,43 @@ mod tests { ); } - /// The natural path is used when it fits, and a too-long one falls back to a - /// hashed short name — but only in a base that *itself* fits. - /// - /// The last part is the one worth a test: a deep `$XDG_RUNTIME_DIR` (a - /// sandboxed CI runner, a nested container) makes the "short" fallback no - /// shorter than what it replaced, and returning it anyway produces a `bind` - /// failure that names `SUN_LEN` and nothing a reader could act on. #[test] fn a_too_long_socket_path_falls_back_to_one_that_fits() { let short = PathBuf::from("/tmp/rt"); let long = PathBuf::from(format!("/tmp/{}", "d".repeat(120))); - // Fits: used as-is, so an ordinary machine gets the readable path. assert_eq!( sock::socket_path_in(&short, &[]).unwrap(), PathBuf::from("/tmp/rt/daemon.sock") ); - // Too long: hashed into the first base that fits, skipping the one that - // does not. let picked = sock::socket_path_in(&long, &[long.clone(), short.clone()]).unwrap(); assert!(picked.starts_with(&short), "{}", picked.display()); assert!(picked.to_string_lossy().ends_with(".sock")); - // Distinct directories stay on distinct endpoints, or two servers would - // collide on one socket. let other = PathBuf::from(format!("/tmp/{}", "e".repeat(120))); assert_ne!( picked, sock::socket_path_in(&other, std::slice::from_ref(&short)).unwrap() ); - // And it is deterministic: the bridge and the server derive it - // independently, in different processes and possibly different builds. assert_eq!( picked, sock::socket_path_in(&long, &[long.clone(), short.clone()]).unwrap() ); - // Nowhere short enough is an error that says so, not a path that fails - // later inside `bind`. let err = sock::socket_path_in(&long, std::slice::from_ref(&long)).unwrap_err(); assert!(err.to_string().contains(CONTROL_SOCK_ENV), "{err}"); } - /// A socket file a crash left behind is cleared and rebound, because - /// otherwise a server could never start again after one bad exit. #[test] fn binding_clears_a_socket_a_crash_left_behind() { let dir = tempfile::TempDir::new().unwrap(); let sock = dir.path().join("s.sock"); let first = bind_control_socket(&sock).unwrap(); - // Drop the listener but leave the file: exactly what a crash leaves. drop(first); assert!(sock.exists()); - // Darwin keeps answering `connect` on a closed listener for a short - // while, and the probe genuinely cannot tell that from a live server — - // so retry rather than assert on the first attempt. The production - // caller is a process start-up, where a few milliseconds do not matter, - // and the property under test is that the stale file is *eventually* - // cleared rather than being fatal forever. let deadline = Instant::now() + Duration::from_secs(5); loop { match bind_control_socket(&sock) { @@ -3350,21 +2452,10 @@ mod tests { } } - // ----------------------------------------------------------------------- - // Raw-wire helpers - // ----------------------------------------------------------------------- - - /// A stream slot is given back on every exit, so the per-connection ceiling - /// is a limit on *concurrency* and never drifts into a permanent refusal. - /// - /// The failure this guards is one-directional and unrecoverable: a slot that - /// leaks is never reclaimed, so after [`MAX_CONCURRENT_GIT_STREAMS`] leaks - /// that connection can no longer read a diff at all until it is rebuilt. #[test] fn a_stream_slot_comes_back_however_it_leaves() { let count = Arc::new(AtomicUsize::new(0)); - // The ordinary path: claimed, then released at the end of the read. { let slot = StreamSlot(Arc::clone(&count)); slot.0.fetch_add(1, Ordering::AcqRel); @@ -3372,14 +2463,11 @@ mod tests { } assert_eq!(count.load(Ordering::Acquire), 0, "released on drop"); - // The refusal path and the failed-to-spawn path are the same shape: the - // guard exists but the thread never runs. let refused = StreamSlot(Arc::clone(&count)); refused.0.fetch_add(1, Ordering::AcqRel); drop(refused); assert_eq!(count.load(Ordering::Acquire), 0); - // A panic unwinding out of the read still returns the slot. let panicking = Arc::clone(&count); let _ = std::thread::spawn(move || { let slot = StreamSlot(panicking); @@ -3394,10 +2482,6 @@ mod tests { ); } - /// A git stream end to end over the wire: the reply is accepted, chunks - /// arrive as events under the id the *client* chose, and the terminating - /// event carries git's exit code. Reassembled, the lines are exactly what - /// the buffered `Git` returns for the same invocation. #[test] fn git_stream_delivers_the_same_lines_as_the_buffered_read() { let (mut client, _hello) = raw(); @@ -3407,7 +2491,6 @@ mod tests { .map(|s| s.to_string()) .collect(); - // What the buffered path says, for comparison. let buffered = match ask( &mut client, 1, @@ -3420,15 +2503,13 @@ mod tests { other => panic!("expected output, got {other:?}"), }; if buffered.status != Some(0) { - return; // no git here; nothing to compare + return; } let expected: Vec<String> = String::from_utf8_lossy(&buffered.stdout) .lines() .map(str::to_string) .collect(); - // The client picks the id, so it could have registered a receiver - // before sending — that is what makes an early chunk safe. ControlClientMsg::Request { req_id: 2, req: ControlRequest::GitStream { @@ -3472,9 +2553,6 @@ mod tests { assert!(!got.is_empty(), "this repo has commits"); } - /// Issue one request and return its reply, ignoring any pushes that arrive - /// first — a `Layout` delta from another connection can legitimately - /// interleave with this one's reply. fn ask(client: &mut UnixStream, req_id: u64, req: ControlRequest) -> ControlReply { ControlClientMsg::Request { req_id, req } .encode(&mut *client) @@ -3491,16 +2569,6 @@ mod tests { } } - // ----------------------------------------------------------------------- - // The machine tree, over the wire - // ----------------------------------------------------------------------- - - /// A subscription is a connection's resource like any other: when the - /// connection ends, the tree must stop holding a callback into its sink. - /// - /// (A leaked subscriber shows up as a `BrokenPipe` log rather than a - /// failure, so the assertion is that a later operation succeeds and lands — - /// with the tree's own `Drop`-based unsubscribe doing the work.) #[test] fn a_closed_connection_stops_being_a_subscriber() { let dir = tempfile::TempDir::new().unwrap(); @@ -3511,7 +2579,6 @@ mod tests { let handle = std::thread::spawn(move || { let _ = serve_with(server, LocalHost::new(), Services::with_machine(store)); }); - // Handshake, then hang up. let mut client = client; ControlClientMsg::Hello(ControlHello::host_rpc("t", "h")) .encode(&mut client) @@ -3530,12 +2597,6 @@ mod tests { assert_eq!(store.workspace(ws.id).unwrap().name.as_deref(), Some("api")); } - /// One tree shared by many connections writing at once: the server has to be - /// as safe as the store is, and no operation may be lost or answered twice. - /// - /// Six connections × ten workspaces, which is also the shape the design is - /// *for* — several clients editing one machine — rather than the single - /// writer the retired record store assumed. #[test] fn concurrent_connections_can_all_write_the_tree() { let (services, _dir) = workspace_services(); @@ -3573,21 +2634,6 @@ mod tests { ); } - // ----------------------------------------------------------------------- - // Layout delta forwarding - // ----------------------------------------------------------------------- - - /// A connection whose delta queue dropped something is *told* — and told - /// **instead of** being handed the backlog. - /// - /// Before the announcement existed, the drop was a server-side log line and - /// the client mirrored a tree it was no longer looking at, indefinitely. - /// Announcing and *then* draining is the subtler version of the same bug: - /// the queue is FIFO, so everything in it is older than the gap, and a - /// client that re-pulled on the notice would then be walked back through - /// history it had already left — `TabRestructured` restoring the shape a tab - /// used to have, with the window and its mirror agreeing on the stale - /// answer, so nothing recovers a second time. #[test] fn a_lagged_connection_hears_a_resync_instead_of_the_superseded_backlog() { let (server_end, mut client_end) = UnixStream::pair().unwrap(); @@ -3595,8 +2641,6 @@ mod tests { let (tx, rx) = smol::channel::bounded::<(String, machine::LayoutDelta)>(8); let lagged = Arc::new(AtomicBool::new(false)); - // A backlog, then the drop that makes every bit of it stale. Queued - // before the forwarder starts so nothing can be delivered early. for _ in 0..4 { tx.send_blocking(("ws-1".to_string(), machine::LayoutDelta::WorkspaceDeleted)) .unwrap(); @@ -3613,8 +2657,6 @@ mod tests { "the flag is consumed: one gap, one resync" ); - // The next frame is the *next* edit, not the four that were queued - // behind the gap. tx.send_blocking(("ws-2".to_string(), machine::LayoutDelta::WorkspaceDeleted)) .unwrap(); match ControlServerMsg::read(&mut client_end).unwrap() { @@ -3626,17 +2668,6 @@ mod tests { } } - // ----------------------------------------------------------------------- - // Attachment and takeover (D8) - // ----------------------------------------------------------------------- - - /// **D8 in one test.** The newcomer wins, the incumbent is *told* rather - /// than silently dropped, and the workspace ends up held by exactly one - /// session. - /// - /// The refusal design would pass an "only one client at a time" assertion - /// just as well; what pins the decision is that the *second* connection is - /// the one that ends up holding it. #[test] fn a_second_client_takes_the_workspace_and_the_first_is_told() { let (services, _dir) = workspace_services(); @@ -3650,9 +2681,6 @@ mod tests { let ((mut desktop, _), _desktop_served) = raw_hello(services.clone(), hello_for(&w, "tok-desktop", "desktop")); - // The displaced session hears who took it, and which workspace: one - // connection can carry several, so a push without the id would leave the - // client unable to say which window went read-only. assert_eq!( await_preempted(&mut laptop), Some(ControlEvent::Preempted { @@ -3673,8 +2701,6 @@ mod tests { "the tree's record moves with the live handles" ); - // And the newcomer is told what it took over from — a takeover the new - // client cannot see is one the user cannot explain. let (reply, _) = round_trip( &mut desktop, 1, @@ -3689,8 +2715,6 @@ mod tests { ); } - /// The displaced session's stream is closed, and when the - /// connection exists for that one workspace that is exactly right. #[test] fn a_dedicated_connection_is_closed_when_its_workspace_is_taken() { let (services, _dir) = workspace_services(); @@ -3702,8 +2726,6 @@ mod tests { raw_hello(services.clone(), hello_for(&w, "tok-desktop", "desktop")); assert!(await_preempted(&mut laptop).is_some()); - // The push comes first and the close after: the notice is useless if it - // races the shutdown, which is why the order is fixed in the server. let mut sink = Vec::new(); let _ = std::io::Read::read_to_end(&mut laptop, &mut sink); assert!( @@ -3712,13 +2734,6 @@ mod tests { ); } - /// Removing a workspace clears it from *both* tables. - /// - /// The tree drops its own attachment with the workspace. If the registry - /// keeps its handle, the two disagree with no race needed, and the next - /// client to attach that id evicts a session nobody displaced — closing - /// its whole link, since a dedicated entry takes every other workspace on - /// that connection down with it. #[test] fn removing_a_workspace_clears_both_attachment_tables() { let (services, _dir) = workspace_services(); @@ -3752,19 +2767,6 @@ mod tests { ); } - /// The tree's record and the registry's handle move under **one** lock. - /// - /// They are separate tables with separate locks, and taking them one after - /// the other is not enough: two clients attaching the same workspace at the - /// same instant can each win a different one, after which the tree names a - /// session the registry has already evicted. No `detach` can clear it — its - /// token no longer matches — so from then on the workspace reports a - /// takeover against a client that disconnected hours ago. - /// - /// Held from the test rather than raced, because the window is a few - /// instructions wide and a racing test passes against the broken ordering - /// far more often than it fails. Holding the handover proves the stronger - /// thing anyway: with it held, an attach reaches *neither* table. #[test] fn an_attach_moves_both_tables_under_one_lock() { let (services, _dir) = workspace_services(); @@ -3774,8 +2776,6 @@ mod tests { let id: crate::core::session::WorkspaceId = w.parse().unwrap(); let held = registry.handover(); - // The handshake replies before the attach, so this returns rather than - // blocking on the lock we are holding. let ((_laptop, _ok), _served) = raw_hello(services.clone(), hello_for(&w, "tok-laptop", "laptop")); @@ -3798,9 +2798,6 @@ mod tests { ); } - /// The other half of that rule. A client holds **one connection per - /// machine**, so closing the link on a takeover would drop windows nobody - /// preempted; the push still goes out, the link stays up. #[test] fn a_shared_connection_survives_losing_one_of_its_workspaces() { let (services, _dir) = workspace_services(); @@ -3837,7 +2834,6 @@ mod tests { }) ); - // Still alive, and still holding the workspace nobody touched. let (reply, _) = round_trip(&mut laptop, 9, ControlRequest::Ping); assert_eq!(reply, ControlReply::Ok(ReplyOk::Pong)); assert_eq!( @@ -3850,9 +2846,6 @@ mod tests { ); } - /// A preempted client tears down *after* the new one attached. An - /// unconditional release would then evict the client that just took over, - /// leaving the workspace looking free while a live window is on it. #[test] fn a_displaced_session_tidying_up_does_not_evict_the_new_owner() { let (services, _dir) = workspace_services(); @@ -3865,8 +2858,6 @@ mod tests { services.clone(), ControlHello::host_rpc("tok-laptop", "laptop"), ); - // Two workspaces on one link, which is what a client with two windows - // on one machine has — and what keeps this link up once `w` is taken. for (i, id) in [&w, &other].iter().enumerate() { round_trip( &mut laptop, @@ -3878,7 +2869,6 @@ mod tests { raw_hello(services.clone(), hello_for(&w, "tok-desktop", "desktop")); assert!(await_preempted(&mut laptop).is_some()); - // The laptop, which no longer holds anything, tidies up. let (reply, _) = round_trip( &mut laptop, 3, @@ -3896,8 +2886,6 @@ mod tests { ); } - /// A connection ending gives its workspaces back, so the next client does - /// not see a takeover against a session that is gone. #[test] fn a_closed_connection_releases_what_it_held() { let (services, _dir) = workspace_services(); @@ -3914,8 +2902,6 @@ mod tests { assert_eq!(machine.attachment(w.parse().unwrap()), None); } - /// A server with no machine tree has no workspaces to attach to, and - /// says so rather than pretending the claim succeeded. #[test] fn attaching_to_a_server_without_a_tree_is_an_error() { let (mut client, _) = raw_with(Services::none()); diff --git a/crates/tty7-core/src/lib.rs b/crates/tty7-core/src/lib.rs index a992cbd2..606bf796 100644 --- a/crates/tty7-core/src/lib.rs +++ b/crates/tty7-core/src/lib.rs @@ -1,23 +1,3 @@ -//! tty7's framework-free core. -//! -//! Everything that has to run on a machine with no display lives here: the -//! wire protocol, the session daemon (PTY ownership, replay rings, fan-out), -//! the native SSH engine, and the parts of the domain model — config, session -//! layout, shell/agent knowledge, git — that the GUI and the headless -//! `tty7-server` must agree on byte for byte. -//! -//! **This crate must never depend on gpui.** That is the invariant the split -//! exists to enforce; -//! `cargo tree -p tty7-core | grep gpui` must stay empty. Where a type genuinely -//! needs a gpui shape — `Config` as a `Global`, `WindowState` as a `Bounds`, -//! `FontFeatures` — the data lives here and the GUI crate adds the gpui-facing -//! layer on top. -//! -//! The module paths deliberately mirror what they were inside the old single -//! crate (`crate::core::config`, `crate::daemon::protocol`), and the GUI crate -//! re-exports them under the same names, so call sites read identically on -//! either side of the boundary. - pub mod core; pub mod daemon; pub mod host; diff --git a/crates/tty7-server/src/main.rs b/crates/tty7-server/src/main.rs index f9a729ad..ed759a5c 100644 --- a/crates/tty7-server/src/main.rs +++ b/crates/tty7-server/src/main.rs @@ -1,49 +1,3 @@ -//! `tty7-server` — the tty7 session daemon with no GUI attached. -//! -//! This is the binary that runs on the machine a *remote* workspace lives on. -//! It runs the same -//! `daemon::server` the local GUI auto-spawns, plus the control listener that -//! backs a remote `Host`; the only difference from the GUI's daemon is that -//! nothing on this side ever opens a window, which is why the code it needs had -//! to leave the GUI crate first. -//! -//! | Command | Effect | -//! |---|---| -//! | `--daemon` | Serve panes *and* control connections in the foreground until killed | -//! | `--stdio` | Carry one control connection on this process's stdin/stdout | -//! | `agent-hook <agent> <event>` | Emit one agent sentinel event; the same code the GUI binary runs | -//! -//! # The two sockets -//! -//! `--daemon` listens twice, on purpose: -//! -//! | Dialect | Endpoint | Served by | -//! |---|---|---| -//! | Panes (`daemon::protocol`) | `<config-dir>/daemon.sock` | `daemon::server::run` | -//! | Control (`daemon::control`) | `$XDG_RUNTIME_DIR/tty7/daemon.sock` | `host::server` | -//! -//! They are separate because the roles are separate. A machine can back a remote -//! workspace's file tree without hosting a single pane, and a pane daemon that -//! predates the control dialect must keep working untouched. Folding control -//! into the pane listener would have made every existing client's version -//! negotiation answer for a feature it does not use. -//! -//! # `--stdio` -//! -//! Two jobs behind one flag, chosen by whether a control server is already -//! listening on this machine: -//! -//! | Situation | Mode | Why | -//! |---|---|---| -//! | A `--daemon` is up | **bridge** — copy bytes between stdio and its socket | One server per machine owns the state; a second one would fork it | -//! | Nothing is listening | **serve** — answer control requests here | A box with no daemon still has to be reachable | -//! -//! `--bridge` and `--serve` force one or the other. The auto choice is what -//! makes `ssh host tty7-server --stdio` work whether or not the remote already -//! had a daemon, which is the fallback path for -//! `AllowStreamLocalForwarding no`, the only path under WSL, and how the -//! end-to-end conformance test reaches a real server without an sshd. - use std::io; use std::process::ExitCode; @@ -72,11 +26,6 @@ OPTIONS: fn main() -> ExitCode { let args: Vec<String> = std::env::args().skip(1).collect(); - // The agent-hook emitter runs before anything else touches config, logging - // or the crash handler: it is a fire-and-forget child of an agent's hook - // runner that must stay silent and exit fast, and the same code the GUI - // binary runs for `tty7 agent-hook`. Only the binary that carries it - // changed — a remote machine has a `tty7-server` and no `tty7`. if args.first().map(String::as_str) == Some("agent-hook") { if let (Some(agent), Some(event)) = (args.get(1), args.get(2)) { tty7_core::core::agent_hooks::run_agent_hook(agent, event); @@ -88,12 +37,6 @@ fn main() -> ExitCode { println!("tty7-server {}", env!("CARGO_PKG_VERSION")); return ExitCode::SUCCESS; } - // Before the config dir, the crash handler and the logger, like `--version`: - // a client asking what this binary speaks must not touch the machine's - // state, and must answer even on a box where the config dir is unwritable. - // - // One line of JSON on stdout, because the reader is a client parsing SSH - // output rather than a person (`install::RemoteProtocol::parse`). if args .iter() .any(|a| a == tty7_core::daemon::install::PROTOCOL_FLAG) @@ -109,26 +52,15 @@ fn main() -> ExitCode { return ExitCode::SUCCESS; } - // Resolve the config-dir override before anything touches config, session, - // or the socket path — they all resolve under it, so the order matters. - // Same parsing as the GUI's `apply_config_dir_arg`. apply_config_dir_arg(&args); - // Panics in the server are recorded to `crash.log` in the config dir, for - // the same reason the GUI does it: on a headless box there is no console to - // read a backtrace off, and the process that notices the crash is a client - // on the other end of a socket. tty7_core::core::crash::install("server"); - // Same reasoning for the ordinary log records: a headless box has no - // console, and the client that notices a problem is on the far end of a - // socket. Off unless `TTY7_LOG` asks for it. tty7_core::core::logfile::install("server"); if args.iter().any(|a| a == "--stdio") { return match run_stdio(&args) { Ok(()) => ExitCode::SUCCESS, Err(e) => { - // stderr, never stdout: stdout is the protocol. eprintln!("tty7-server: stdio session ended with error: {e}"); ExitCode::FAILURE } @@ -143,12 +75,6 @@ fn main() -> ExitCode { ExitCode::FAILURE } -/// Serve panes and control connections until killed. -/// -/// The whole of it lives in [`tty7_core::daemon::server::run_daemon`], shared -/// verbatim with `tty7 --daemon`: local and remote machines run the identical -/// daemon, which is what makes "one machine = one daemon = one workspace tree" -/// a fact rather than a convention. fn run_daemon() -> ExitCode { if let Err(e) = tty7_core::daemon::server::run_daemon() { eprintln!("tty7-server: daemon exited with error: {e}"); @@ -157,7 +83,6 @@ fn run_daemon() -> ExitCode { ExitCode::SUCCESS } -/// Carry one control connection on this process's stdin/stdout. fn run_stdio(args: &[String]) -> io::Result<()> { #[cfg(not(unix))] { @@ -200,9 +125,6 @@ fn run_stdio(args: &[String]) -> io::Result<()> { None => server::control_socket_path()?, }; - // Probe unless told which mode to use. Connecting is the only way to - // tell a live server from a socket file a crash left behind, and it is - // also exactly the connection the bridge would have made anyway. let upstream = if force_serve { None } else { @@ -214,20 +136,6 @@ fn run_stdio(args: &[String]) -> io::Result<()> { "no control server at {} ({e})", sock.display() )); - // One control server per machine, started if nobody has — - // the same rule `bridge_panes` follows one dialect over, - // and for the same reason. Two `--stdio` sessions both - // falling through to serving in-process would each hold - // their own `MachineStore` over the one file, and - // `persist` writes the whole document: the second to save - // silently drops the first's changes. Their attachment - // registries would be separate too, which makes design - // the takeover a no-op between them — both clients would - // hold the same workspace and neither would be told. - // - // Not attempted when the caller named a socket: starting a - // daemon binds the machine's default endpoint, not theirs, - // so it would be a daemon nobody asked for and nobody uses. if may_start_daemon(args) { match spawn::ensure_running() .map_err(io::Error::other) @@ -255,8 +163,6 @@ fn run_stdio(args: &[String]) -> io::Result<()> { match upstream { Some(s) => bridge(s), None => { - // Takes stdin/stdout away from the rest of the process before a - // single frame is written — see `StdioDuplex::take`. let link = StdioDuplex::take()?; server::serve_with( link, @@ -268,24 +174,6 @@ fn run_stdio(args: &[String]) -> io::Result<()> { } } -/// Carry one **pane** connection on stdin/stdout, bridged to this machine's pane -/// socket. -/// -/// # Why panes need their own stdio mode -/// -/// `--daemon` listens twice (see this module's header), and a routed connection -/// is for exactly one of the two dialects. The control half already had a way in -/// — plain `--stdio`. The pane half had none, which is why a remote workspace -/// could browse a file tree and could not open a single terminal. -/// -/// # Why it always bridges and never serves -/// -/// Panes are *state*. Serving them in this process would give every routed -/// connection its own registry, so a pane would die with the window that opened -/// it and `List` would never see anything anyone else spawned — the exact -/// failure `install::wsl::ensure_wsl_server`'s doc warns about, one layer down. -/// There is one pane daemon per machine and this connects to it, starting it -/// first if nobody has. #[cfg(unix)] fn bridge_panes() -> io::Result<()> { use tty7_core::daemon::{spawn, transport}; @@ -293,10 +181,6 @@ fn bridge_panes() -> io::Result<()> { let upstream = match transport::connect() { Ok(s) => s, Err(e) => { - // `ensure_running` re-execs *this* binary with `--daemon`, which is - // what starts both listeners. Normally the install path has already - // done it and this never runs; it covers the daemon dying between - // that check and this connection. log_stderr(format_args!( "no pane daemon at {} ({e}); starting one", transport::endpoint_display() @@ -308,13 +192,6 @@ fn bridge_panes() -> io::Result<()> { bridge(upstream) } -/// Copy bytes between this process's stdio and an already-running control -/// server, in both directions, until either side stops. -/// -/// Deliberately dumb: it parses nothing. The version handshake this stream -/// carries is between the *client* and the server at the far end, and a bridge -/// that understood the frames would be a third opinion about -/// the protocol version, which is exactly the coupling the design forbids. #[cfg(unix)] fn bridge(upstream: std::os::unix::net::UnixStream) -> io::Result<()> { use std::io::{Read as _, Write as _}; @@ -323,21 +200,6 @@ fn bridge(upstream: std::os::unix::net::UnixStream) -> io::Result<()> { let mut up_read = upstream.try_clone()?; let mut up_write = upstream.try_clone()?; - // Upstream → stdout on this thread, stdin → upstream on another. Either - // direction ending means the session is over, so whichever finishes first - // shuts the socket down and the other returns immediately instead of - // parking on a peer that will never speak again. - // - // **The feeder is never joined.** Shutting the socket down wakes a thread - // blocked on *the socket*, but this one is blocked on `stdin`, and nothing - // this process can do wakes that — the far end of the pipe is `ssh`, or a - // parent that has no reason to close it. Joining it turns "the server hung - // up" into a bridge that never exits and, worse, never closes its stdout, so - // the client at the far end waits forever for an EOF that is sitting in this - // process. Returning lets the process exit, which closes stdout, which is - // the signal the client is actually waiting for. The takeover is - // the case that made this visible: the server closes the displaced session's - // link, and that has to reach the client through this bridge. let feeder_socket = upstream.try_clone()?; let feeder = std::thread::Builder::new() .name("tty7-stdio-bridge-in".into()) @@ -354,9 +216,6 @@ fn bridge(upstream: std::os::unix::net::UnixStream) -> io::Result<()> { Ok(0) => break, Ok(n) => { stdout.write_all(&buf[..n])?; - // Flushed per read, not per buffer: a control reply is useless - // sitting in a buffer waiting for the next one, and the peer is - // blocked on it. stdout.flush()?; } Err(e) if e.kind() == io::ErrorKind::Interrupted => continue, @@ -373,14 +232,6 @@ fn bridge(upstream: std::os::unix::net::UnixStream) -> io::Result<()> { Ok(()) } -/// `--flag <value>` or `--flag=<value>`, first occurrence wins. -/// Whether a failed control probe may start the machine's daemon. -/// -/// Only when the caller did not name a socket. `--control-sock` says "this -/// endpoint", and `spawn::ensure_running` binds the machine's default one — so -/// starting a daemon there would leave a process nobody asked for and nobody -/// reaches. It is also what keeps the test suite, and any `--config-dir` -/// isolation built on it, from spraying daemons across a developer's machine. fn may_start_daemon(args: &[String]) -> bool { flag_value(args, "--control-sock").is_none() } @@ -399,14 +250,10 @@ fn flag_value(args: &[String], flag: &str) -> Option<String> { None } -/// stderr only. In `--stdio` mode stdout belongs to the protocol, and the -/// `log` crate has no sink configured in this binary. fn log_stderr(args: std::fmt::Arguments<'_>) { eprintln!("tty7-server: {args}"); } -/// Honour `--config-dir <dir>` / `--config-dir=<dir>`, first occurrence wins — -/// the same contract (and the same first-call-wins `set_config_dir`) as the GUI. fn apply_config_dir_arg(args: &[String]) { if let Some(path) = flag_value(args, "--config-dir") { tty7_core::core::config::set_config_dir(path.into()); @@ -421,9 +268,6 @@ mod tests { args.iter().map(|a| a.to_string()).collect() } - /// A named socket suppresses the daemon start, in both spellings of the - /// flag. Without this guard every `--stdio` in the test suite that points - /// at a temp socket would start a real daemon on the developer's machine. #[test] fn a_named_control_socket_suppresses_starting_a_daemon() { assert!(may_start_daemon(&argv(&[]))); diff --git a/crates/tty7-server/tests/cli.rs b/crates/tty7-server/tests/cli.rs index 9ae58a12..9d89296a 100644 --- a/crates/tty7-server/tests/cli.rs +++ b/crates/tty7-server/tests/cli.rs @@ -1,17 +1,3 @@ -//! The `tty7-server` command line: the three subcommands, and the two shapes -//! `--stdio` takes. -//! -//! `stdio_conformance.rs` proves the *protocol* over `--stdio --serve`. This -//! file proves the argument handling and the byte bridge — the mode that carries -//! a connection to a control server that is already running, which is the path -//! an `ssh host tty7-server --stdio` takes on a machine with a live daemon and -//! which no amount of `Host` conformance would exercise. -//! -//! Everything `--stdio` is Unix-only — the flag is refused on Windows, where a -//! machine is reached over its own transport rather than by shipping a server -//! onto it. The plain argument handling below is not, and runs -//! everywhere. - use std::process::{Command, Stdio}; #[cfg(unix)] @@ -50,7 +36,6 @@ impl LinkShutdown for ServerProcess { } } -/// Start `tty7-server --stdio <args>` and connect a `RemoteHost` to its pipes. #[cfg(unix)] fn stdio_child(args: &[&str]) -> io::Result<Arc<RemoteHost>> { let mut child = Command::new(EXE) @@ -67,7 +52,6 @@ fn stdio_child(args: &[&str]) -> io::Result<Arc<RemoteHost>> { RemoteHost::connect_with(out, inp, Some(closer), "stdio:cli", &hello) } -/// A control server on a temp socket, for the bridge to reach. #[cfg(unix)] fn listening_server(dir: &tempfile::TempDir) -> PathBuf { let sock = dir.path().join("control.sock"); @@ -76,13 +60,6 @@ fn listening_server(dir: &tempfile::TempDir) -> PathBuf { sock } -/// **The bridge.** `--stdio --bridge` forwards bytes between its own pipes and a -/// control server that is already listening, parsing nothing on the way. -/// -/// That "parsing nothing" is the load-bearing part: the version handshake this -/// stream carries belongs to the client and the server at the far end, and a -/// bridge with an opinion about the protocol would become a third party to a -/// negotiation it is not qualified to join. #[cfg(unix)] #[test] fn the_bridge_carries_a_whole_session() { @@ -97,22 +74,15 @@ fn the_bridge_carries_a_whole_session() { host.write_file(&f, b"two hops").unwrap(); assert_eq!(host.read_file(&f, 1024).unwrap(), b"two hops"); - // A payload big enough that it cannot arrive in one read, so the bridge's - // copy loop is doing real work rather than passing a single buffer through. let big = host.join(sandbox.path(), "big.bin"); let body: Vec<u8> = (0..2 * 1024 * 1024u32).map(|i| (i % 251) as u8).collect(); host.write_file(&big, &body).unwrap(); assert!(host.read_file(&big, 8 * 1024 * 1024).unwrap() == body); - // Out-of-order replies survive the extra hop too: the bridge must not - // serialize what the server took care to keep concurrent. let entries = host.read_dir(sandbox.path(), None).unwrap(); assert_eq!(entries.len(), 2); } -/// `--bridge` with nowhere to bridge to fails rather than quietly serving -/// itself. An operator who asked for the bridge is telling us a server exists; -/// silently becoming that server would fork the machine's state in two. #[cfg(unix)] #[test] fn an_explicit_bridge_with_no_server_fails() { @@ -125,8 +95,6 @@ fn an_explicit_bridge_with_no_server_fails() { ); } -/// With neither flag, `--stdio` probes: nothing listening means serve here, so a -/// machine that has never run a daemon is still reachable over ssh. #[cfg(unix)] #[test] fn the_default_mode_serves_when_nothing_is_listening() { @@ -138,8 +106,6 @@ fn the_default_mode_serves_when_nothing_is_listening() { assert!(host.exists(sandbox.path())); } -/// ...and something listening means bridge to it, so a second `--stdio` session -/// joins the machine's existing server instead of standing up a rival. #[cfg(unix)] #[test] fn the_default_mode_bridges_when_a_server_is_listening() { @@ -153,7 +119,6 @@ fn the_default_mode_bridges_when_a_server_is_listening() { assert_eq!(std::fs::read(&f).unwrap(), b"ok"); } -/// Contradictory flags are refused rather than one silently winning. #[cfg(unix)] #[test] fn serve_and_bridge_together_are_refused() { @@ -170,12 +135,6 @@ fn serve_and_bridge_together_are_refused() { ); } -/// `agent-hook` runs the same emitter the GUI binary does, and stays quiet. -/// -/// Quiet is the requirement, not a nicety: this runs as a child of an agent's -/// hook runner, and anything it prints lands in the agent's own transcript. With -/// no controlling terminal there is nowhere to emit to, and it still has to -/// succeed — a hook that fails is a hook the agent reports as broken. #[test] fn agent_hook_is_quiet_and_succeeds() { let out = Command::new(EXE) @@ -187,8 +146,6 @@ fn agent_hook_is_quiet_and_succeeds() { assert!(out.stdout.is_empty(), "agent-hook wrote to stdout"); } -/// A malformed `agent-hook` invocation is still silent and still succeeds — the -/// emitter's whole contract is that it never becomes the agent's problem. #[test] fn agent_hook_without_arguments_still_succeeds() { let out = Command::new(EXE) @@ -214,7 +171,6 @@ fn version_and_help_report_on_stdout() { } } -/// No arguments is a usage error, not a process that sits there doing nothing. #[test] fn no_arguments_is_a_usage_error() { let out = Command::new(EXE).output().unwrap(); diff --git a/crates/tty7-server/tests/machine_tree.rs b/crates/tty7-server/tests/machine_tree.rs index 91b091e0..104179df 100644 --- a/crates/tty7-server/tests/machine_tree.rs +++ b/crates/tty7-server/tests/machine_tree.rs @@ -1,21 +1,3 @@ -//! The machine-owned workspace tree, end to end against a real `tty7-server` -//! child process. -//! -//! The client is the shipped `ControlClient`, the wire is the control dialect -//! over real pipes, and the server is the shipped binary owning its tree in a -//! file. What the process boundary buys here specifically: -//! -//! | | Why an in-process store would not do | -//! |---|---| -//! | The tree is on **the server's** disk | The whole design is "the daemon owns the structure"; a store in the test's address space proves the data type, not the ownership | -//! | `machine-tree` is advertised only when served | The capability bit is built from what the *binary* wires up | -//! | A delta reaches the **other** connection, never the writer | Origin exclusion is the contract that lets a client apply its own edit from the reply and everyone else's from the push | -//! -//! Every case gets its own `$TTY7_DATA_DIR`, so no case can be explained by -//! another's leftovers and nothing here can touch a developer's real tree. - -// Unix-only: the server under test is a `--stdio` child, and the two-client -// case stands up a control socket. #![cfg(unix)] use std::io; @@ -30,8 +12,6 @@ use tty7_core::daemon::control::{ feature, }; -/// The child, and the only way to end it — a process-backed link is reaped by -/// its `LinkShutdown`, exactly as in `stdio_conformance.rs`. struct ServerProcess { child: Mutex<Option<Child>>, } @@ -47,7 +27,6 @@ impl LinkShutdown for ServerProcess { } } -/// One connected client: the RPC channel, plus everything the server pushed. struct Client { control: ControlClient, events: Arc<Mutex<Vec<ControlEvent>>>, @@ -55,9 +34,6 @@ struct Client { } impl Client { - /// Wait for a `Layout` delta about `workspace` matching `want`, or fail - /// saying what did arrive. Polled because a push and the reply that caused - /// it race by construction. fn expect_delta(&self, workspace: WorkspaceId, want: impl Fn(&LayoutDelta) -> bool) { let key = workspace.to_string(); let deadline = Instant::now() + Duration::from_secs(10); @@ -90,9 +66,6 @@ impl Client { } } -/// Start a `tty7-server --stdio --serve` whose tree lives in `data_dir`, and -/// connect a client to it. `--serve` for the same reason as everywhere else in -/// these tests: a developer's real daemon must never be bridged into. fn connect(data_dir: &Path, token: &str) -> Client { let mut child = Command::new(env!("CARGO_BIN_EXE_tty7-server")) .args(["--stdio", "--serve"]) @@ -145,10 +118,6 @@ fn seed(pane: u64, cwd: &str) -> PaneSeed { } } -// --------------------------------------------------------------------------- - -/// The capability bit is the client's cue that the tree verbs are worth a -/// round trip, and it has to reflect what the shipped binary wired up. #[test] fn the_server_advertises_the_machine_tree() { let dir = data_dir(); @@ -163,15 +132,11 @@ fn the_server_advertises_the_machine_tree() { ); } -/// The semantic operations against a real server, and the tree ends up in a -/// file that server owns. This is "the daemon owns the structure" as a -/// syscall someone else made, not as a diagram. #[test] fn the_tree_is_built_by_operations_and_lives_in_the_servers_file() { let dir = data_dir(); let client = connect(dir.path(), "ops"); - // Build: a workspace, a tab, a split. let ws = match client .control .call(ControlRequest::WorkspaceCreate { @@ -208,7 +173,6 @@ fn the_tree_is_built_by_operations_and_lives_in_the_servers_file() { }) .expect("split"); - // Read back through the wire. let machine = match client.control.call(ControlRequest::MachineGet).unwrap() { ReplyOk::MachineTree(m) => *m, other => panic!("expected MachineTree, got {other:?}"), @@ -222,11 +186,9 @@ fn the_tree_is_built_by_operations_and_lives_in_the_servers_file() { "panes this server was told about in its own lifetime are live" ); - // The file is the server's: the test process never wrote it. let text = std::fs::read_to_string(machine_file(&dir)).expect("the server wrote its tree"); assert!(text.contains(&ws.id.to_string()), "{text}"); - // A refusal is a client-visible error, not a dropped reply. let missing = client .control .call(ControlRequest::WorkspaceTree { @@ -236,9 +198,6 @@ fn the_tree_is_built_by_operations_and_lives_in_the_servers_file() { assert_eq!(missing.kind(), io::ErrorKind::NotFound); } -/// **The revival contract, across a real restart.** A second server process -/// reads the first one's tree; every pane in it is dead (`live == false`), the -/// leaves still name them, and `PaneReplace` rebinds a leaf to a successor. #[test] fn a_new_server_process_reports_the_old_panes_dead_and_accepts_their_successors() { let dir = data_dir(); @@ -268,7 +227,6 @@ fn a_new_server_process_reports_the_old_panes_dead_and_accepts_their_successors( ws }; - // A brand-new server process over the same file. let second = connect(dir.path(), "second"); let machine = match second.control.call(ControlRequest::MachineGet).unwrap() { ReplyOk::MachineTree(m) => *m, @@ -291,7 +249,6 @@ fn a_new_server_process_reports_the_old_panes_dead_and_accepts_their_successors( "the leaf still names the dead pane — the revival slot" ); - // Revive: a fresh pane takes the leaf, the spent record goes. second .control .call(ControlRequest::PaneReplace { @@ -311,9 +268,6 @@ fn a_new_server_process_reports_the_old_panes_dead_and_accepts_their_successors( assert!(machine.panes.iter().all(|p| p.id != 7)); } -/// Two clients on one server. An operation by one reaches the other as a -/// `Layout` delta and never comes back to its author — the mechanism that -/// replaces whole-record last-writer-wins with edits that all land. #[test] fn an_operation_from_one_client_reaches_the_other_as_a_delta() { use tty7_core::host::local::LocalHost; @@ -342,8 +296,6 @@ fn an_operation_from_one_client_reaches_the_other_as_a_delta() { .iter() .any(|f| f == feature::MACHINE_TREE) ); - // Make sure the watcher's subscription is up (its server thread subscribes - // before answering its first request). watcher.control.call(ControlRequest::Ping).unwrap(); let ws = match writer @@ -379,8 +331,6 @@ fn an_operation_from_one_client_reaches_the_other_as_a_delta() { ws.id, |d| matches!(d, LayoutDelta::TabCreated { tab: t, .. } if t.id == tab.id), ); - // The created tab became active, and the *change of active tab* is its own - // delta — implicit activation must not be something a client re-derives. watcher.expect_delta( ws.id, |d| matches!(d, LayoutDelta::ActiveTabChanged { tab: t } if *t == tab.id), @@ -391,7 +341,6 @@ fn an_operation_from_one_client_reaches_the_other_as_a_delta() { "a client must not be pushed its own operation" ); - // …and the rule holds in the other direction. watcher .control .call(ControlRequest::TabRename { @@ -407,10 +356,6 @@ fn an_operation_from_one_client_reaches_the_other_as_a_delta() { assert_eq!(watcher.delta_count(), 3, "still only the writer's own ops"); } -/// Takeover semantics on the new tree, with **no record store served at -/// all**: the attach verbs predate the tree, and their contract — newcomer -/// wins, the displaced session is told, a stale detach cannot evict the -/// usurper — must survive the record store's retirement. #[test] fn attachment_rides_the_tree_when_no_record_store_is_served() { use tty7_core::host::local::LocalHost; @@ -452,8 +397,6 @@ fn attachment_rides_the_tree_when_no_record_store_is_served() { "the tree's own record says who holds the workspace" ); - // The newcomer wins, learns whom it displaced, and the displaced session - // is pushed a Preempted notice. match attach(&desktop).expect("takeover") { ReplyOk::Attached { took_over_from } => { assert_eq!(took_over_from.as_deref(), Some("laptop")); @@ -473,7 +416,6 @@ fn attachment_rides_the_tree_when_no_record_store_is_served() { std::thread::sleep(Duration::from_millis(20)); } - // The preempted session tidying up must not evict the usurper. laptop .control .call(ControlRequest::WorkspaceDetach { @@ -486,8 +428,6 @@ fn attachment_rides_the_tree_when_no_record_store_is_served() { ); } -/// A `--stdio --bridge` child connected to an already-listening control -/// socket — the two-hop shape a real multi-client machine has. fn bridged(sock: &Path, token: &str) -> Client { let hello = ControlHello::host_rpc(token, token); let mut child = Command::new(env!("CARGO_BIN_EXE_tty7-server")) diff --git a/crates/tty7-server/tests/remote_router.rs b/crates/tty7-server/tests/remote_router.rs index 0825d062..7238f08b 100644 --- a/crates/tty7-server/tests/remote_router.rs +++ b/crates/tty7-server/tests/remote_router.rs @@ -1,22 +1,3 @@ -//! The local daemon's [`RemoteRouter`] in front of a real `tty7-server`, and -//! the remote socket path the two sides have to agree on. -//! -//! `stdio_conformance.rs` proves the protocol over `--stdio`; `cli.rs` proves -//! the server's own byte bridge. This file proves the hop *before* both of -//! them — the one where a GUI's local connection is handed to a machine that is -//! not this one — over the `--stdio` fallback, which is the transport a host -//! with `AllowStreamLocalForwarding no` gets and the only one that can be -//! exercised without an sshd. -//! -//! The `direct-streamlocal` half deliberately has no test here: it needs a -//! running sshd with the option flipped both ways. What *is* testable is the -//! decision between them, which lives in `remote_link::choose_entry` and is -//! unit-tested there. - -// Unix-only: the hub this stands up is a Unix-domain socket, which is also the -// only shape the remote side of a routed connection takes. The -// Windows client reaches a *remote* server the same way; it is the local hop -// that differs, and `daemon::router` covers that with its own `cfg`. #![cfg(unix)] use std::io::{BufRead, BufReader}; @@ -31,17 +12,12 @@ use tty7_core::host::remote::RemoteHost; const EXE: &str = env!("CARGO_BIN_EXE_tty7-server"); -/// **The fallback path, end to end.** A client connects to a local socket, -/// names a target, and gets a `Host` backed by a `tty7-server` process it never -/// spoke to directly — every byte of the control dialect crossing a router that -/// does not know what a control frame is. #[test] fn a_routed_connection_reaches_a_real_server() { let dir = tempfile::TempDir::new().unwrap(); let hub = dir.path().join("hub.sock"); let listener = UnixListener::bind(&hub).unwrap(); - // The local daemon's side: accept, read the route header, forward forever. let router = std::thread::spawn(move || { let (stream, _) = listener.accept().unwrap(); let mut reader = stream.try_clone().unwrap(); @@ -51,8 +27,6 @@ fn a_routed_connection_reaches_a_real_server() { RemoteRouter::route(stream, &header) }); - // The client's side: one extra frame in front of an otherwise ordinary - // control connection. let mut sock = UnixStream::connect(&hub).unwrap(); let missing = dir.path().join("nobody-here.sock"); let header = RouteHeader::local_stdio( @@ -73,9 +47,6 @@ fn a_routed_connection_reaches_a_real_server() { let host = RemoteHost::over_unix(sock, "routed:local-stdio", &hello) .expect("handshake through the router"); - // The handshake itself already crossed the router in both directions; these - // prove it keeps working for payloads that span many reads, which is where - // a router that buffered or reframed would come apart. let sandbox = tempfile::TempDir::new().unwrap(); let file = host.join(sandbox.path(), "through-the-router.txt"); host.write_file(&file, b"two hops and a pipe").unwrap(); @@ -86,20 +57,12 @@ fn a_routed_connection_reaches_a_real_server() { host.write_file(&big, &body).unwrap(); assert!(host.read_file(&big, 8 * 1024 * 1024).unwrap() == body); - // Out-of-order replies survive the hop: the router must not serialize what - // the server took care to keep concurrent. assert_eq!(host.read_dir(sandbox.path(), None).unwrap().len(), 2); drop(host); let _ = router.join().unwrap(); } -/// A route to a target that cannot be opened comes back as a *reason*. -/// -/// Without the ack the client would see a socket that closed with no -/// explanation, which for a remote workspace is the difference between "the -/// binary isn't installed on that box" and a bug report saying "it doesn't -/// work". #[test] fn an_unreachable_target_is_reported_not_dropped() { let dir = tempfile::TempDir::new().unwrap(); @@ -127,17 +90,8 @@ fn an_unreachable_target_is_reported_not_dropped() { assert!(router.join().unwrap().is_err()); } -/// **The two sides derive the same path.** `remote_link::remote_control_socket` -/// computes, from a remote's environment, the socket a `direct-streamlocal` -/// channel is pointed at; `host::server::control_socket_path` computes, in the -/// server process, the socket it binds. Nothing reconciles them at run time — -/// a mismatch is a connection that fails with `connect failed` and no hint -/// which side is wrong — so the agreement is checked against the real binary -/// rather than asserted in prose. #[test] fn the_derived_remote_socket_is_the_one_the_server_binds() { - // Both orders the server resolves: `$XDG_RUNTIME_DIR` when it has one, and - // `$HOME/.local/share` when it does not (macOS, minimal containers). let with_runtime = tempfile::TempDir::new().unwrap(); let home = tempfile::TempDir::new().unwrap(); let runtime_path = with_runtime.path().to_string_lossy().to_string(); @@ -162,8 +116,6 @@ fn the_derived_remote_socket_is_the_one_the_server_binds() { assert_eq!(derived.as_deref(), Some(bound.as_str())); } -/// Start `tty7-server --daemon` under a controlled environment and read back -/// the control socket it actually bound (it prints it on stderr), then stop it. fn bound_control_socket(runtime_dir: Option<&str>, home: &str) -> String { let config = tempfile::TempDir::new().unwrap(); let mut cmd = Command::new(EXE); @@ -187,9 +139,6 @@ fn bound_control_socket(runtime_dir: Option<&str>, home: &str) -> String { bound = Some(path.to_string()); break; } - // The listener reports its own failures on the same stream; a test that - // silently timed out here would be far harder to read than one that - // says what the server said. assert!( !line.contains("control listener unavailable"), "the server could not bind at all: {line}" diff --git a/crates/tty7-server/tests/routed_pane.rs b/crates/tty7-server/tests/routed_pane.rs index 58d5ce46..0953ce8b 100644 --- a/crates/tty7-server/tests/routed_pane.rs +++ b/crates/tty7-server/tests/routed_pane.rs @@ -1,32 +1,3 @@ -//! **A remote workspace's pane, end to end, with no sshd and no network.** -//! -//! `remote_router.rs` proves the *control* dialect crosses the router; this file -//! proves the other one — the pane protocol — which is the half a remote -//! workspace needs before it can run anything at all. Until it existed a remote -//! window opened, listed files, and could not spawn a terminal. -//! -//! ## What stands in for what -//! -//! | Real thing | Here | -//! |---|---| -//! | The GUI's `RemoteTerminal` | a `UnixStream` speaking `ClientMsg`/`DaemonMsg` | -//! | The user's local daemon | a `UnixListener` + `RemoteRouter::route` | -//! | The SSH channel | `RouteTarget::LocalStdio` → a child process | -//! | The remote `tty7-server --daemon` | `tty7-server --stdio --pane` bridging to one | -//! -//! Only the middle hop is faked, and it is faked with the same -//! `RemoteRouter::route` the daemon calls. Everything on the far side is the -//! real binary: a real `--daemon` process, a real PTY, a real shell. -//! -//! ## Why `--config-dir` per test -//! -//! The "remote" pane daemon this stands up is a *real* daemon on this machine. -//! Pointing it at a temp config dir gives it its own socket, so it can neither -//! see nor be seen by the developer's own tty7 — and `Shutdown` at the end of -//! each test reaps it rather than leaving one per CI run. - -// Unix-only for the same reason `remote_router.rs` is: the hop being tested is a -// Unix-domain socket, and `--stdio` is a Unix path by construction. #![cfg(unix)] use std::io::Read; @@ -39,9 +10,6 @@ use tty7_core::daemon::router::{RemoteRouter, RouteChannel, RouteHeader, negotia const EXE: &str = env!("CARGO_BIN_EXE_tty7-server"); -/// How long a test waits for a shell to say something. Generous: a cold daemon -/// launch plus a shell start on a loaded CI box is not instant, and a flaky -/// timeout here would read as a routing bug. const OUTPUT_TIMEOUT: Duration = Duration::from_secs(30); fn win() -> WinSize { @@ -53,8 +21,6 @@ fn win() -> WinSize { } } -/// A shell with no startup files, so what comes back is the command's output and -/// not somebody's prompt theme. fn plain_shell() -> ShellSpec { ShellSpec { program: "/bin/sh".to_string(), @@ -63,10 +29,6 @@ fn plain_shell() -> ShellSpec { } } -/// Stand up the local hop: a socket that routes one connection and then returns. -/// -/// One connection per hub, because that is exactly what the GUI does — a pane is -/// a connection, and `handle_conn` hands each one to the router separately. fn hub(dir: &Path, name: &str) -> (std::path::PathBuf, std::thread::JoinHandle<()>) { let path = dir.join(name); let listener = UnixListener::bind(&path).unwrap(); @@ -76,15 +38,11 @@ fn hub(dir: &Path, name: &str) -> (std::path::PathBuf, std::thread::JoinHandle<( let (kind, payload) = tty7_core::daemon::protocol::read_frame(&mut reader).unwrap(); assert_eq!(kind, tty7_core::daemon::router::ROUTE_KIND); let header = RouteHeader::decode(&payload).unwrap(); - // The far end outliving the near one is normal (the client hangs up - // first), so a closed pipe here is not a failure. let _ = RemoteRouter::route(stream, &header); }); (path, thread) } -/// The header a pane of a remote workspace writes, with this machine standing in -/// for the remote. fn pane_header(config_dir: &Path) -> RouteHeader { RouteHeader::local_stdio( EXE, @@ -98,7 +56,6 @@ fn pane_header(config_dir: &Path) -> RouteHeader { .for_pane() } -/// Open a routed pane connection through a fresh hub. fn routed(dir: &Path, name: &str, config_dir: &Path) -> (UnixStream, std::thread::JoinHandle<()>) { let (path, thread) = hub(dir, name); let mut sock = UnixStream::connect(&path).unwrap(); @@ -107,11 +64,6 @@ fn routed(dir: &Path, name: &str, config_dir: &Path) -> (UnixStream, std::thread (sock, thread) } -/// Read frames until `needle` shows up in the accumulated PTY bytes. -/// -/// Accumulating rather than matching per frame is the point: a PTY splits output -/// wherever it likes, and a test that expected one frame per line would pass or -/// fail on scheduling. fn read_until(sock: &mut UnixStream, needle: &str) -> String { let deadline = Instant::now() + OUTPUT_TIMEOUT; let mut seen = String::new(); @@ -134,35 +86,23 @@ fn read_until(sock: &mut UnixStream, needle: &str) -> String { panic!("timed out waiting for {needle:?}\nsaw: {seen:?}"); } -/// Stop the daemon this test started, so it does not outlive the run. fn shutdown(dir: &Path, config_dir: &Path) { let (path, thread) = hub(dir, "shutdown.sock"); if let Ok(mut sock) = UnixStream::connect(&path) && negotiate(&mut sock, &pane_header(config_dir)).is_ok() { let _ = ClientMsg::Shutdown.encode(&mut sock); - // The daemon exits without replying, so read to EOF rather than - // expecting a frame. let _ = sock.read(&mut [0u8; 64]); } let _ = thread.join(); } -/// **The milestone's proof.** Open a pane on the "remote", type at it, see what -/// it printed, hang up, come back, and find the pane still there with its -/// scrollback. -/// -/// Every claim a remote workspace makes is in this one test: the pane exists on -/// the far machine (it survives the connection that made it), the hot path -/// crosses the router intact in both directions, and reattach finds the same -/// pane rather than a new one. #[test] fn a_routed_pane_spawns_takes_input_and_survives_a_reconnect() { let dir = tempfile::TempDir::new().unwrap(); let config = dir.path().join("remote-config"); std::fs::create_dir_all(&config).unwrap(); - // ---- connect, spawn --------------------------------------------------- let (mut sock, hub_thread) = routed(dir.path(), "pane-1.sock", &config); ClientMsg::Spawn { cwd: Some(dir.path().to_path_buf()), @@ -178,22 +118,15 @@ fn a_routed_pane_spawns_takes_input_and_survives_a_reconnect() { other => panic!("expected Spawned through the router, got {other:?}"), }; - // ---- input → output --------------------------------------------------- - // A marker no shell prompt would produce on its own, echoed by a command - // that exists in every POSIX shell. ClientMsg::Input(b"echo rou''ted-pane-alive\n".to_vec()) .encode(&mut sock) .unwrap(); read_until(&mut sock, "routed-pane-alive"); - // ---- disconnect ------------------------------------------------------- - // `Detach`, not `Kill`: the pane is meant to keep running on the far side, - // which is the entire proposition of a remote workspace. ClientMsg::Detach.encode(&mut sock).unwrap(); drop(sock); let _ = hub_thread.join(); - // ---- reconnect -------------------------------------------------------- let (mut back, hub_thread) = routed(dir.path(), "pane-2.sock", &config); ClientMsg::Attach { pane_id, @@ -202,11 +135,8 @@ fn a_routed_pane_spawns_takes_input_and_survives_a_reconnect() { .encode(&mut back) .unwrap(); - // The snapshot replays the ring the *remote* daemon kept, so the marker - // printed before the disconnect is still there. read_until(&mut back, "routed-pane-alive"); - // And it is live, not just a recording. ClientMsg::Input(b"echo st''ill-here\n".to_vec()) .encode(&mut back) .unwrap(); @@ -217,21 +147,12 @@ fn a_routed_pane_spawns_takes_input_and_survives_a_reconnect() { shutdown(dir.path(), &config); } -/// The pane channel and the control channel are **not** interchangeable. -/// -/// A header that forgets `for_pane()` reaches the control socket, where a -/// `Spawn` is an unknown frame. This is what "the window opens but nothing runs -/// in it" looked like, so it is pinned rather than left to the reader. #[test] fn the_channel_decides_which_dialect_the_route_carries() { let control = RouteHeader::local_stdio(EXE, &["--stdio"]); assert_eq!(control.channel, RouteChannel::Control); assert_eq!(control.clone().for_pane().channel, RouteChannel::Pane); - // The wire tag is what a *different* build matches on, so it is pinned - // rather than left to the variant name — and the default has to keep - // decoding as `control`, because that is what every header written before - // the field existed meant. let mut buf = Vec::new(); control.clone().for_pane().write(&mut buf).unwrap(); let (_, payload) = tty7_core::daemon::protocol::read_frame(&mut buf.as_slice()).unwrap(); @@ -243,11 +164,6 @@ fn the_channel_decides_which_dialect_the_route_carries() { assert_eq!(decoded.channel, RouteChannel::Control); } -/// A routed pane's `Kill` reaches the machine the pane is on. -/// -/// Pane ids are per-daemon, so this is not a convenience: an unrouted `Kill` -/// does not fail, it succeeds against whatever local pane happens to hold the -/// same number. #[test] fn a_routed_kill_reaches_the_pane_it_names() { let dir = tempfile::TempDir::new().unwrap(); @@ -271,7 +187,6 @@ fn a_routed_kill_reaches_the_pane_it_names() { drop(sock); let _ = hub_thread.join(); - // It is on the remote's registry... let (mut list, hub_thread) = routed(dir.path(), "list-1.sock", &config); ClientMsg::List.encode(&mut list).unwrap(); let before = match DaemonMsg::read(&mut list).unwrap() { @@ -282,7 +197,6 @@ fn a_routed_kill_reaches_the_pane_it_names() { drop(list); let _ = hub_thread.join(); - // ...and a routed Kill takes it off. let (mut kill, hub_thread) = routed(dir.path(), "kill-1.sock", &config); ClientMsg::Kill { pane_id }.encode(&mut kill).unwrap(); let _ = kill.shutdown(std::net::Shutdown::Write); diff --git a/crates/tty7-server/tests/stdio_conformance.rs b/crates/tty7-server/tests/stdio_conformance.rs index 622826ee..4cdf5a8f 100644 --- a/crates/tty7-server/tests/stdio_conformance.rs +++ b/crates/tty7-server/tests/stdio_conformance.rs @@ -1,30 +1,3 @@ -//! **The milestone's proof**: every `Host` conformance case, run against a real -//! `tty7-server --stdio` child process over real pipes. -//! -//! Not a mock, not an in-process socket pair, and — the part that matters — not -//! an sshd. The client is `RemoteHost`, the wire is the control dialect, the -//! server is the shipped binary answering out of its own address space, and the -//! only thing standing in for SSH is a pair of pipes. Everything between the -//! `Host` call and the syscall is the code a transcontinental workspace runs. -//! -//! That is what makes remote workspaces testable in CI at all. The alternative — -//! provisioning a machine, an sshd, a key, and a network for every pull request -//! — is expensive enough that in practice it does not get run, which means the -//! two `Host` implementations drift and nobody finds out until someone opens a -//! remote directory. Here the identical list of cases runs against `LocalHost` -//! in `tty7-core` and against this, and a divergence is a red test. -//! -//! # Shape -//! -//! One child process and one sandbox **per case**, via -//! [`host_conformance_suite!`](tty7_core::host_conformance_suite). Spawning -//! forty-six servers costs a few hundred milliseconds in total and buys complete -//! isolation: no case can be explained by another's leftover state, a hung -//! server fails exactly one case, and a crash names the behaviour that caused it. - -// Unix-only: every case here is a `--stdio` child, and `--stdio` is refused on -// Windows by design — a Windows machine is reached over its own transport, not -// by shipping a server onto it. #![cfg(unix)] use std::io; @@ -37,13 +10,6 @@ use tty7_core::host::SharedHost; use tty7_core::host::conformance::Sandbox; use tty7_core::host::remote::RemoteHost; -/// The child, and the only way to end it. -/// -/// `RemoteHost` closes its link through [`LinkShutdown`]; for a socket that is -/// `shutdown(2)`, and for a child process it is this. Without it, dropping the -/// host would leave the reader thread parked on a pipe the server has no reason -/// to write to and the server parked on a pipe the client has no reason to write -/// to — the exact standoff `LinkShutdown` exists to break, one transport over. struct ServerProcess { child: Mutex<Option<Child>>, } @@ -51,19 +17,14 @@ struct ServerProcess { impl LinkShutdown for ServerProcess { fn shutdown_link(&self) -> io::Result<()> { let Some(mut child) = self.child.lock().unwrap_or_else(|e| e.into_inner()).take() else { - return Ok(()); // already reaped; `close` and `Drop` both call this + return Ok(()); }; let _ = child.kill(); - // Reaped here rather than left to the OS: forty-six cases running in - // parallel would otherwise accumulate forty-six zombies for the life of - // the test binary. let _ = child.wait(); Ok(()) } } -/// A temp directory on the machine the server is on — which, this being the -/// stdio path, is also this one. struct TempSandbox(tempfile::TempDir); impl Sandbox for TempSandbox { @@ -84,23 +45,13 @@ impl Sandbox for TempSandbox { } } -/// Start a server and connect a `RemoteHost` to it. fn stdio_host() -> (SharedHost, TempSandbox) { let sandbox = TempSandbox(tempfile::TempDir::new().unwrap()); let mut child = Command::new(env!("CARGO_BIN_EXE_tty7-server")) - // `--serve` rather than letting the mode be probed: a developer running - // these tests may well have a real `tty7-server --daemon` up, and a - // bridge to *that* would be testing their machine's state instead of - // this build. .args(["--stdio", "--serve"]) - // The server opens its machine tree at startup. None of these cases - // touch it, but pointing it at the sandbox keeps forty-six child - // processes off the developer's real `~/.local/share/tty7`. .env("TTY7_DATA_DIR", sandbox.path()) .stdin(Stdio::piped()) .stdout(Stdio::piped()) - // The server's diagnostics are not this test's output. A failure shows - // up as a failed request, which names the case. .stderr(Stdio::null()) .spawn() .expect("could not start tty7-server --stdio"); @@ -118,13 +69,8 @@ fn stdio_host() -> (SharedHost, TempSandbox) { (host.into_shared(), sandbox) } -// Every case in `tty7-core`'s shared suite, over the pipes. This is the same -// list `LocalHost` runs; the point is that it is not a *similar* list. tty7_core::host_conformance_suite!(remote_stdio, stdio_host); -/// The suite above only proves the cases pass — it cannot prove they were the -/// whole suite. This checks the count the registry actually carries, so a case -/// silently dropped upstream shows up here as well as there. #[test] fn the_whole_suite_ran_against_the_server() { let names: Vec<&str> = tty7_core::host::conformance::CASES @@ -138,22 +84,14 @@ fn the_whole_suite_ran_against_the_server() { ); } -/// The server is a *separate process* answering out of its own memory. Easy to -/// lose by accident — an in-process fallback would keep every case above green -/// while testing nothing that this milestone is about. #[test] fn the_server_really_is_another_process() { let (host, sandbox) = stdio_host(); let marker = host.join(sandbox.path(), "written-over-the-wire.txt"); host.write_file(&marker, b"from the client").unwrap(); - // This side reads it with plain `std::fs`: if the bytes are there, they went - // out through a pipe and came back through a syscall someone else made. assert_eq!(std::fs::read(&marker).unwrap(), b"from the client"); - // And the reverse: a change this process makes with `std::fs` is visible to - // the server, so both ends really are looking at one filesystem through two - // different code paths. let from_here = sandbox.path().join("written-locally.txt"); std::fs::write(&from_here, b"from the test").unwrap(); assert_eq!( @@ -164,13 +102,9 @@ fn the_server_really_is_another_process() { assert!(host.is_connected()); } -/// Dropping the host kills the child. A test binary that leaked one server per -/// case would leave forty-six processes behind on every run. #[test] fn dropping_the_host_reaps_the_server() { let (host, sandbox) = stdio_host(); assert!(host.exists(sandbox.path())); drop(host); - // Nothing to assert beyond "this returns": the reap happens inside the drop, - // and a shutdown that did not wake the reader would hang here instead. } diff --git a/src/core/actions.rs b/src/core/actions.rs index 59190321..29b15891 100644 --- a/src/core/actions.rs +++ b/src/core/actions.rs @@ -1,37 +1,14 @@ -//! Menu / keyboard actions, defined in one place so both the application shell -//! (`app.rs`) and the terminal view (`terminal::view`) can reference them -//! without depending on each other. They drive the macOS menu bar and the -//! keymap, so a click and a shortcut go through exactly the same path. - use gpui::actions; actions!( tty7, [ NewTab, - // Create a workspace and the window that shows it. One workspace is - // shown by exactly one window and vice versa — there is deliberately no - // "new window on the same workspace", which would need two clients on - // one set of daemon panes (the daemon allows only one). NewWorkspace, - // Stop the current workspace: kill its sessions and close its window, - // keeping its layout on file so it can be started again. The deliberate - // opposite of a window close, which only detaches — hence the verb. StopWorkspace, - // Stop it *and* forget the layout. The only irreversible one. DeleteWorkspace, - // Rename the current workspace in place, from the title-bar chip. - // Until now `Workspace.name` could only ever be the derived repo name — - // there was no way for the user to set one. RenameWorkspace, - // Open the workspace switcher: every workspace on every machine, in one - // panel. The title-bar chip opens the same thing, so this is the - // keyboard's half of a control that is otherwise mouse-only. ToggleSwitcher, - // Show the Nth workspace in the Window menu's order (see - // `ui::windows::menu_order`). Unit actions rather than one - // parameterized action, matching `ActivateTab1..9` — it keeps them - // nameable in config/Settings like every other binding. SelectWorkspace1, SelectWorkspace2, SelectWorkspace3, @@ -42,58 +19,34 @@ actions!( SelectWorkspace8, SelectWorkspace9, CloseActiveTab, - // Tab operations that until now existed only as tab-context-menu rows, - // reachable by right-clicking the *right* chip. As actions they also - // reach the menu bar, the palette, and Settings → Keybindings; each acts - // on the active tab, which is what "this tab" means with no chip clicked. RenameTab, NewWorktreeTab, CloseOtherTabs, CloseTabsToTheRight, CopyWorkingDirectory, MarkTabUnread, - // Branch the coding-agent session running in this tab into a second, - // independent one by shelling the agent's own fork command (issue - // #211). Placement follows where the user asked from: the bare action — - // menu bar, palette, a bound key — and the tab context menu open a new - // tab, while the pane right-click menu offers the four split directions - // below, since a pane-level ask is a spatial one. ForkAgentSession, ForkAgentSessionRight, ForkAgentSessionLeft, ForkAgentSessionDown, ForkAgentSessionUp, - // Put the agent's *native* session id on the clipboard, beside "Copy - // Working Directory". Codex has no copy/duplicate subcommand, so - // "copy the session" means copying its id — paste it into `codex - // resume`, a bug report, or another tool. CopyAgentSessionId, SplitRight, SplitDown, FocusNextPane, FocusPrevPane, - // Directional pane focus (tmux `prefix ←/→/↑/↓`): move focus to the - // adjacent pane in that direction. FocusPaneLeft, FocusPaneRight, FocusPaneUp, FocusPaneDown, - // Grow (Right/Down) or shrink (Left/Up) the focused pane along the - // matching axis by nudging its nearest enclosing split's ratio. ResizePaneLeft, ResizePaneRight, ResizePaneUp, ResizePaneDown, - // Swap the focused pane with its next / previous sibling in leaf order - // (tmux `prefix }` / `prefix {`); focus follows the moved pane. SwapPaneNext, SwapPanePrev, - // Relative tab navigation (tmux `prefix n` / `prefix p`). NextTab, PrevTab, - // Jump straight to tab 1‑9 (⌘/Ctrl+1‑9, tmux `prefix 1‑9`). Unit actions - // rather than one parameterized action so config/Settings can index them - // by name like every other binding. ActivateTab1, ActivateTab2, ActivateTab3, @@ -110,68 +63,31 @@ actions!( ReopenClosedTab, ToggleMaximizePane, ToggleFullscreen, - // Switch the tab bar between the horizontal title-bar strip and the - // vertical left-side sidebar (persists `tab_bar_position`). ToggleTabSidebar, - // Collapse/expand the left tab sidebar in place (persists - // `sidebar_collapsed`). Unlike `ToggleTabSidebar` this does not switch - // the tab bar to the horizontal strip — the rail just goes away and - // comes back at the same width. ToggleLeftPanel, - // Show/hide the right detail panel — session info, working-tree changes, - // and the file tree (persists `right_panel_visible`). ToggleRightPanel, - // Jump straight to one of the right panel's tabs, opening the panel if - // it was closed. Unit actions rather than one parameterized action so - // config/Settings can bind them by name; unbound by default, since the - // panel's own tab row is the primary way in. ShowRightPanelInfo, ShowRightPanelOutline, ShowRightPanelChanges, ShowRightPanelFiles, OpenSettings, - // Open Settings straight to its Keybindings section — the Help menu's - // "Keyboard Shortcuts" and the palette's shortcut entry both land here, - // rather than making the user open Settings and then find the section. ShowKeyboardShortcuts, - // Open Settings on the About section. The macOS App menu's first item - // has to exist and has to be called "About tty7"; routing it to the - // section that already carries version/links keeps one About, not two. About, - // Run the same update check the app does at startup (see `core::update`) - // on demand, then report the outcome. Previously only the tray offered - // this, which is not where a Mac user looks for it. CheckForUpdates, - // Standard macOS App-menu items. gpui exposes the platform calls but - // binds nothing by default, so they need real actions to hang off. HideApp, HideOthers, ShowAll, - // Standard macOS Window-menu items. MinimizeWindow, ZoomWindow, - // Help menu destinations. Each opens a URL in the default browser; kept - // as separate actions (rather than one parameterized one) so they can be - // bound and searched by name like everything else. OpenDocumentation, OpenDiscord, ReportIssue, RestartDaemon, - // Show the detail panel's Files tab, which browses the focused pane's - // remote filesystem over SFTP when that pane is native SSH (WS5). ToggleSftp, - // Open the detail panel's Info tab on the focused native-SSH pane with - // the add-forward form expanded (WS4). The band itself is always on that - // tab; this is the way in that doesn't require the panel to be open. ShowSshForwards, - // Toggle the code panel: a full-body overlay of [file tree | editor] - // covering the terminal (settings-overlay style). ToggleCodePanel, - // Save the editor panel's active file (⌘S). EditorSave, - // Open the SSH profile manager/editor full-window page (WS6, FR-P1). OpenSshProfiles, - // Reconnect a dead native-SSH pane in place (WS6, FR-E4). RestartSshSession, SendTab, SendBackTab, diff --git a/src/core/agent_prompt.rs b/src/core/agent_prompt.rs index f9cd27ab..d1fb5e69 100644 --- a/src/core/agent_prompt.rs +++ b/src/core/agent_prompt.rs @@ -1,17 +1,5 @@ -//! Prompt builders that feed terminal context *back into* a running CLI coding -//! agent — the review-prompt / selection-range-prompt idea, sized to tty7: -//! take what the user is looking at (a selection in some -//! pane, the repo's `git diff`) and phrase it as one self-contained prompt to -//! paste into the agent's PTY. Pure string builders, unit-tested; the UI layer -//! owns finding the agent pane and writing the bytes. - -/// Cap on embedded context (selection or diff) so a pathological selection or -/// a giant diff can't flood the agent's input buffer. Anything longer is -/// truncated with an explicit note — the agent can always ask for more. const MAX_CONTEXT_BYTES: usize = 24 * 1024; -/// Truncate `text` to [`MAX_CONTEXT_BYTES`] on a char boundary, appending a -/// note when anything was cut. fn capped(text: &str) -> String { if text.len() <= MAX_CONTEXT_BYTES { return text.to_string(); @@ -26,8 +14,6 @@ fn capped(text: &str) -> String { ) } -/// A prompt asking the agent to look at terminal output the user selected -/// (a build error, a stack trace, a failing test). `cwd` locates the context. pub fn build_selection_prompt(selection: &str, cwd: Option<&str>) -> Option<String> { let selection = selection.trim_end(); if selection.trim().is_empty() { @@ -45,9 +31,6 @@ pub fn build_selection_prompt(selection: &str, cwd: Option<&str>) -> Option<Stri Some(prompt) } -/// A prompt asking the agent to review the working tree's diff. `diff` is the -/// combined `git diff` (+ `git diff --cached`) output, embedded so the agent -/// needn't re-run it; an empty diff yields `None` (nothing to review). pub fn build_diff_review_prompt(diff: &str, cwd: Option<&str>) -> Option<String> { let diff = diff.trim_end(); if diff.trim().is_empty() { @@ -67,11 +50,6 @@ pub fn build_diff_review_prompt(diff: &str, cwd: Option<&str>) -> Option<String> Some(prompt) } -/// The bytes that deliver `prompt` into an agent's PTY: a bracketed paste (so -/// multi-line prompts insert as one block instead of submitting line by line — -/// every recognized agent's TUI enables bracketed paste), followed by CR to -/// submit. ESC bytes inside the prompt are stripped, same as the clipboard -/// paste path, so embedded content can't fake the paste terminator. pub fn submit_bytes(prompt: &str) -> Vec<u8> { let mut bytes = b"\x1b[200~".to_vec(); bytes.extend(prompt.bytes().filter(|&b| b != 0x1b)); @@ -90,7 +68,6 @@ mod tests { assert!(p.contains("error[E0308]")); assert!(p.contains("/work/tty7")); assert!(p.contains("```")); - // Empty / whitespace selections build nothing. assert_eq!(build_selection_prompt(" \n", None), None); } @@ -115,7 +92,6 @@ mod tests { let bytes = submit_bytes("fix this\nplease"); assert!(bytes.starts_with(b"\x1b[200~")); assert!(bytes.ends_with(b"\x1b[201~\r")); - // Embedded ESC can't terminate the paste early. let sneaky = submit_bytes("a\x1b[201~; rm -rf /\nb"); let inner = &sneaky[6..sneaky.len() - 7]; assert!(!inner.contains(&0x1b)); diff --git a/src/core/config.rs b/src/core/config.rs index 6c0893c0..84d4dd5a 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -1,67 +1,19 @@ -//! The gpui-facing half of the configuration model. -//! -//! Every field, every default, every parse rule and all of the `config.json` IO -//! live in `tty7-core` — the daemon and the headless server read the same file -//! and must agree with the GUI byte for byte, and neither of them links gpui. -//! Two things are left here, and both exist only because they *are* gpui: -//! -//! 1. **[`Config`] as a global.** gpui keys its global map by type and -//! `gpui::Global` is a foreign trait, so it cannot be implemented for the -//! core struct from this crate. [`Config`] is therefore a transparent -//! newtype around [`tty7_core::core::config::Config`] that carries the -//! `Global` impl; it `Deref`s to the core struct, so `cx.global::<Config>() -//! .font_size` and friends read exactly as they always did. -//! 2. **[`gpui_font_features`]**, which converts the stored feature list into -//! the `gpui::FontFeatures` the text system wants. -//! -//! Everything else is re-exported unchanged, so `crate::core::config::…` still -//! resolves to the same items across the whole GUI. - -// Everything else — every enum, helper and constant — passes straight through, -// so `crate::core::config::…` resolves exactly as it did before the split. The -// `Config` this glob would bring in is shadowed by the newtype below. pub use tty7_core::core::config::*; -/// The core configuration struct, under a name this module's own [`Config`] -/// wrapper doesn't shadow. pub use tty7_core::core::config::Config as CoreConfig; -/// The app's live configuration, as gpui holds it: a newtype over -/// [`CoreConfig`] whose only job is to carry the `gpui::Global` impl the orphan -/// rule won't let us put on the core struct directly. -/// -/// It `Deref`s (and `DerefMut`s) to the core struct, so reads and writes go -/// through untouched — `cx.global::<Config>().font_size`, -/// `cx.global_mut::<Config>().window_blur = Some(on)`, -/// `cx.global::<Config>().save()`. Construct one with [`Config::load`], -/// `Config::default()`, or `Config(core_config)`. #[derive(Debug, Clone, Default)] pub struct Config(pub CoreConfig); impl gpui::Global for Config {} impl Config { - /// Load the config, falling back to defaults if the file is absent or - /// unreadable — see [`CoreConfig::load`]. pub fn load() -> Self { #[cfg(test)] assert_scratch_config_dir("Config::load"); Self(CoreConfig::load()) } - /// Test-only guard that shadows [`CoreConfig::save`]. - /// - /// `save` is a *full* overwrite of `config.json`, and the config dir is - /// resolved process-wide from `$HOME` unless a test pins it. A GUI test that - /// forgets to pin therefore doesn't just leak a file — it resets the - /// developer's entire live config to whatever the test built (this is not - /// hypothetical: the keybinding tests in `ui::app` did exactly that, which is - /// how this guard came to exist). An inherent method wins over the `Deref` to - /// [`CoreConfig`], so every `cfg.save()` in the crate routes through here - /// under `cargo test` and through the core method otherwise — no call site - /// has to opt in. - /// - /// Pin a scratch dir with [`pin_test_config_dir`] in the test's harness. #[cfg(test)] pub fn save(&self) { assert_scratch_config_dir("Config::save"); @@ -69,18 +21,11 @@ impl Config { } } -/// Whether `dir` is the platform's real per-user config dir — the one a -/// developer's own tty7 reads and writes. -/// -/// `None` (nothing resolves — no `$HOME`) is not "real": IO there is a no-op, so -/// there is nothing to protect. #[cfg(test)] fn is_real_user_config_dir(dir: Option<&std::path::Path>) -> bool { dir.is_some() && dir == default_config_dir().as_deref() } -/// Panic unless the config dir has been pinned away from the developer's real -/// one. See [`Config::save`] for why. #[cfg(test)] fn assert_scratch_config_dir(what: &str) { assert!( @@ -92,15 +37,6 @@ fn assert_scratch_config_dir(what: &str) { ); } -/// Point this process's config dir at a scratch directory, so config-dir IO in -/// tests can't reach the developer's real `~/.config/tty7`. -/// -/// Every test in the binary must pin **this same path**. `set_config_dir` is -/// first-call-wins and process-wide, so a test that pinned a scratch dir of its -/// own would silently redirect whichever tests lost the race away from the -/// directory they then read back — one shared path makes the race outcome -/// irrelevant. That is why this takes no name: the single call site for the -/// path is the point. #[cfg(test)] pub(crate) fn pin_test_config_dir() { let dir = std::env::temp_dir().join(format!("tty7-covtest-{}", std::process::id())); @@ -128,12 +64,6 @@ impl From<CoreConfig> for Config { } } -/// The configured OpenType features, in the shape gpui's text system takes. -/// -/// The stored form is a `tty7-core` replica of `gpui::FontFeatures` with an -/// identical wire format (see [`FontFeatures`]); this is the one place the two -/// meet, so the conversion — and the test below that pins their serializations -/// together — is all that keeps them honest. pub fn gpui_font_features(features: &FontFeatures) -> gpui::FontFeatures { gpui::FontFeatures(std::sync::Arc::new(features.tag_value_list().to_vec())) } @@ -143,40 +73,24 @@ mod tests { use super::*; use std::path::Path; - /// The guard behind [`Config::save`]: it has to recognize the real dir (so a - /// forgotten pin is caught) and clear a scratch one (so pinned tests run). - /// Testing the predicate rather than the panic keeps this independent of - /// which test pinned the process first — `set_config_dir` is first-call-wins, - /// so an unpinned state can't be staged once any test has run. #[test] fn the_real_config_dir_is_the_only_one_the_guard_rejects() { - // Whatever the platform resolves to for this user is exactly what tests - // must never write to. if let Some(real) = default_config_dir() { assert!(is_real_user_config_dir(Some(&real))); - // A scratch dir under it is still not *it* — the guard compares the - // dir itself, not an ancestor. assert!(!is_real_user_config_dir(Some(&real.join("scratch")))); } assert!(!is_real_user_config_dir(Some(Path::new( "/tmp/tty7-scratch" )))); - // Nothing resolves (no `$HOME`) → config IO is a no-op, nothing to guard. assert!(!is_real_user_config_dir(None)); } - /// The pin helper must land somewhere the guard accepts — otherwise every - /// harness that follows this advice would still panic. #[test] fn pinning_lands_outside_the_real_config_dir() { pin_test_config_dir(); assert!(!is_real_user_config_dir(config_dir_path().as_deref())); } - /// `font_features` is a real key in the user's `config.json`, and the type - /// backing it moved out of gpui when the core crate split off. The two must - /// still parse the same JSON to the same feature list and write it back - /// identically — otherwise the split silently rewrote user config. #[test] fn font_features_match_gpui_byte_for_byte() { const JSON: &str = r#"{"calt":true,"liga":1,"ss01":0,"zero":false,"bad":1,"kern":null}"#; diff --git a/src/core/keychain.rs b/src/core/keychain.rs index 92b8600b..7319518b 100644 --- a/src/core/keychain.rs +++ b/src/core/keychain.rs @@ -1,33 +1,10 @@ -//! The *storage* half of the SSH credential vault: the [`CredentialStore`] -//! trait, its OS-keychain backend and the in-memory test double. -//! -//! The naming half — [`CredentialKind`], [`CredentialRef`], [`endpoint_account`] -//! and the two service constants — lives one crate down in -//! `tty7_core::core::keychain` and is re-exported here, so every call site keeps -//! using `crate::core::keychain::…` for both halves. -//! -//! **Why the split.** `tty7-core` also builds the headless `tty7-server`, a -//! static binary meant to be small enough to push onto an arbitrary box. That -//! machine has no OS keychain and nothing in `tty7-core` ever reads a secret — -//! the daemon receives secrets already resolved by the GUI (see -//! `daemon::protocol`'s `NativeSshSpec`). Leaving `keyring` in the core manifest -//! made the server link `zbus` / `secret-service` and thirty-odd crates behind -//! them for code it can never call. So the store moved up here, where its callers -//! already were (`ui::ssh_prompt`, `ui::ssh_connect`, `ui::settings`, `ui::app`). -//! -//! Secrets are never logged. The typed helpers below deliberately keep secret -//! values out of `Debug`/log output. - pub use tty7_core::core::keychain::{ CredentialKind, CredentialRef, SERVICE_KEY_PASSPHRASE, SERVICE_PASSWORD, endpoint_account, key_account_from_contents, }; -/// A backend failure while talking to the credential store. Intentionally never -/// carries a secret value — only a human-readable reason from the backend. #[derive(Debug)] pub enum CredentialError { - /// The underlying store failed (keychain locked, access denied, IO error). Backend(String), } @@ -41,34 +18,19 @@ impl std::fmt::Display for CredentialError { impl std::error::Error for CredentialError {} -/// Result alias for credential-store operations. pub type CredentialResult<T> = Result<T, CredentialError>; -/// A secret store keyed by `(service, account)`. Implementors talk to a real OS -/// keychain or an in-memory map. -/// -/// Contract: -/// - `get` returns `Ok(None)` when the entry is absent (not an error). -/// - `delete` is idempotent: deleting an absent entry returns `Ok(())`. -/// - Implementors must never log secret values. pub trait CredentialStore: Send + Sync { - /// Fetch the secret for `(service, account)`, or `Ok(None)` if absent. fn get(&self, service: &str, account: &str) -> CredentialResult<Option<String>>; - /// Store `secret` under `(service, account)`, overwriting any existing value. fn set(&self, service: &str, account: &str, secret: &str) -> CredentialResult<()>; - /// Remove the entry at `(service, account)`. Absent entry ⇒ `Ok(())`. fn delete(&self, service: &str, account: &str) -> CredentialResult<()>; - // ── Typed endpoint/key helpers (default methods over get/set/delete) ────── - - /// The stored password for an endpoint, if any. fn password_for(&self, user: &str, host: &str, port: u16) -> CredentialResult<Option<String>> { self.get(SERVICE_PASSWORD, &endpoint_account(user, host, port)) } - /// Store a password for an endpoint and return the [`CredentialRef`] naming it. fn set_password( &self, user: &str, @@ -84,17 +46,14 @@ pub trait CredentialStore: Send + Sync { }) } - /// Delete the stored password for an endpoint (idempotent). fn delete_password(&self, user: &str, host: &str, port: u16) -> CredentialResult<()> { self.delete(SERVICE_PASSWORD, &endpoint_account(user, host, port)) } - /// The stored passphrase for a private key (keyed by its sha512-hex), if any. fn passphrase_for_key(&self, key_sha512_hex: &str) -> CredentialResult<Option<String>> { self.get(SERVICE_KEY_PASSPHRASE, key_sha512_hex) } - /// Store a passphrase for a private key and return the [`CredentialRef`]. fn set_key_passphrase( &self, key_sha512_hex: &str, @@ -104,36 +63,22 @@ pub trait CredentialStore: Send + Sync { Ok(CredentialRef::key_passphrase(key_sha512_hex.to_string())) } - // The three below are unused outside tests today. Unlike `tty7-core`, this is a - // *binary* crate, where `pub` does not escape and `dead_code` therefore fires - // on them; they are kept because the trait's five verbs (`password_*`, - // `*_key_passphrase`, `*_ref`) only make sense as a set — a store you can - // write a ref to but not read one back from is a trap for the next caller. - /// Delete the stored passphrase for a private key (idempotent). #[allow(dead_code)] fn delete_key_passphrase(&self, key_sha512_hex: &str) -> CredentialResult<()> { self.delete(SERVICE_KEY_PASSPHRASE, key_sha512_hex) } - /// Resolve a [`CredentialRef`] to its secret, or `Ok(None)` if absent. #[allow(dead_code)] fn get_ref(&self, cref: &CredentialRef) -> CredentialResult<Option<String>> { self.get(cref.service(), &cref.account) } - /// Delete the entry a [`CredentialRef`] names (idempotent). #[allow(dead_code)] fn delete_ref(&self, cref: &CredentialRef) -> CredentialResult<()> { self.delete(cref.service(), &cref.account) } } -/// The production store backed by the OS keychain via the `keyring` crate. -/// -/// `keyring` 4.x's default `v1` feature auto-selects the platform store on first -/// use, so this needs no per-platform wiring. A missing entry surfaces as -/// `Ok(None)`; every other failure becomes [`CredentialError::Backend`] with the -/// backend's message (never a secret). #[derive(Debug, Default, Clone, Copy)] pub struct OsCredentialStore; @@ -166,27 +111,18 @@ impl CredentialStore for OsCredentialStore { } } -/// An in-memory store for tests. Never touches the OS keychain. -/// -/// `#[cfg(test)]` because this crate is a binary: a test-only type left in a -/// normal build is dead code here, where in `tty7-core` (a library) `pub` alone -/// kept the lint quiet. #[cfg(test)] #[derive(Debug, Default)] pub struct InMemoryCredentialStore { - // Keyed by (service, account). Behind a Mutex so the store is `Sync` and can - // be shared like the real one. entries: std::sync::Mutex<std::collections::HashMap<(String, String), String>>, } #[cfg(test)] impl InMemoryCredentialStore { - /// A fresh, empty store. pub fn new() -> Self { Self::default() } - /// Number of stored entries (test introspection). pub fn len(&self) -> usize { self.entries .lock() @@ -194,7 +130,6 @@ impl InMemoryCredentialStore { .len() } - /// Whether the store holds no entries. pub fn is_empty(&self) -> bool { self.len() == 0 } @@ -234,10 +169,8 @@ mod tests { let store = InMemoryCredentialStore::new(); assert!(store.is_empty()); - // Absent → None (not an error). assert_eq!(store.password_for("deploy", "host", 22).unwrap(), None); - // Set returns a ref that resolves back to the secret. let cref = store.set_password("deploy", "host", 22, "hunter2").unwrap(); assert_eq!(cref, CredentialRef::password("deploy", "host", 22)); assert_eq!(store.get_ref(&cref).unwrap().as_deref(), Some("hunter2")); @@ -246,7 +179,6 @@ mod tests { Some("hunter2") ); - // Overwrite replaces in place (endpoint keying — one entry per endpoint). store.set_password("deploy", "host", 22, "newpass").unwrap(); assert_eq!(store.len(), 1); assert_eq!( @@ -254,7 +186,6 @@ mod tests { Some("newpass") ); - // Delete is idempotent. store.delete_password("deploy", "host", 22).unwrap(); assert_eq!(store.password_for("deploy", "host", 22).unwrap(), None); store.delete_password("deploy", "host", 22).unwrap(); @@ -275,7 +206,6 @@ mod tests { Some("s3cret") ); - // A password with the same account string does NOT collide (different service). store.set_password("deploy", "host", 22, "pw").unwrap(); assert_eq!(store.len(), 2); diff --git a/src/core/mod.rs b/src/core/mod.rs index 0c9dcc07..5e1e5449 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -1,20 +1,3 @@ -//! Domain core: the configuration model, session persistence, the action -//! vocabulary shared by the shell and the terminal view, and the streaming OSC -//! tokenizer shared by the daemon- and client-side output scanners. -//! -//! These modules are framework-light and depend on neither `ui` nor `terminal`, -//! so the dependency arrow always points *inward* to here. -//! -//! Most of it now lives one crate down, in `tty7-core`, so the headless -//! `tty7-server` can share it — the modules re-exported below are that crate's, -//! reachable under their original `crate::core::…` paths. What stays declared -//! here is either gpui-shaped outright (`actions`, `update`) or the gpui half -//! of a type whose data moved down (`config`, `session`, `window_state`). - -// A glob, so every module `tty7-core` grows is reachable here for free. The -// four `pub mod`s below deliberately shadow their glob-imported namesakes: each -// is a thin layer that re-exports the core module's contents itself — gpui for -// `config` / `session` / `window_state`, the OS keychain for `keychain`. pub use tty7_core::core::*; pub mod actions; diff --git a/src/core/session.rs b/src/core/session.rs index ea4e34d2..ea3a611f 100644 --- a/src/core/session.rs +++ b/src/core/session.rs @@ -1,28 +1,9 @@ -//! The gpui-facing half of view-state persistence. -//! -//! The on-disk model — [`WindowView`], [`WindowViews`] and the `views.json` -//! IO — lives in `tty7-core` beside the in-memory [`Session`] shapes. What is -//! left here is [`WorkspaceStore`], which is a gpui `Global` and threads every -//! mutation through `&mut App`. -//! -//! The store holds **no layout**. A workspace's tabs and panes live in its -//! machine's daemon-owned tree; this file remembers only what that tree cannot -//! — which workspaces this client knows, which machine each is on, window -//! geometry, the open flag, and focus recency. - pub use tty7_core::core::session::{ RemoteRef, RemoteTarget, Session, SessionAxis, SessionPane, SessionTab, WindowView, WindowViews, WorkspaceId, }; pub use tty7_core::host::HostId; -/// App-level owner of `views.json`, and the single writer to it. -/// -/// Windows never touch the file themselves. Each one pushes *its* view state -/// in and the store persists the merged whole — without that, two windows -/// doing read-modify-write on the shared file would have the last writer -/// clobber the other's entries. It also means a window that is closing can -/// record its final state after its own entity is already being torn down. pub struct WorkspaceStore { views: WindowViews, } @@ -30,32 +11,16 @@ pub struct WorkspaceStore { impl gpui::Global for WorkspaceStore {} impl WorkspaceStore { - /// Read `views.json` and install the result as the app global. Call once, - /// before the first window is built. pub fn init(cx: &mut gpui::App) { let views = WindowViews::load().unwrap_or_default(); cx.set_global(Self { views }); } - /// Install a store holding exactly `views`. - /// - /// Tests only, and it exists because [`init`](Self::init) reads the - /// developer's real `views.json`: a test that needs a workspace to be on - /// file must neither depend on what happens to be there nor risk writing to - /// it. Every mutating helper already no-ops without the global, so this is - /// the one thing a test cannot do for itself. #[cfg(test)] pub fn install_for_test(cx: &mut gpui::App, views: WindowViews) { cx.set_global(Self { views }); } - /// Every known workspace. Read-only — mutations go through the helpers so - /// the file stays in step. - /// - /// Reads as empty when the store was never installed. That is the headless - /// test harness, which builds windows directly rather than through - /// `ui::windows::open`; "no saved workspaces" is the correct reading there, - /// and it keeps a missing global from panicking a render. pub fn all(cx: &gpui::App) -> &WindowViews { static EMPTY: std::sync::OnceLock<WindowViews> = std::sync::OnceLock::new(); match cx.try_global::<Self>() { @@ -64,21 +29,12 @@ impl WorkspaceStore { } } - /// The store, or `None` when it was never installed (tests). Every mutating - /// helper goes through this so a headless window is a no-op rather than a - /// panic — and, importantly, so tests never write to a real `views.json`. fn try_store(cx: &mut gpui::App) -> Option<&mut Self> { cx.has_global::<Self>().then(|| cx.global_mut::<Self>()) } - /// Take over an existing workspace to show in a window, or mint a fresh one - /// when `id` is `None` / no longer on file (the "New Workspace" path). - /// Marks it open and returns its id. The layout is not this store's to - /// hand out — the window opens empty and the tree hydration fills it. pub fn claim(cx: &mut gpui::App, id: Option<WorkspaceId>) -> WorkspaceId { let Some(store) = Self::try_store(cx) else { - // No store (tests): hand back a detached identity so the window - // still builds, but nothing is persisted. return WorkspaceId::new(); }; let id = id.filter(|id| store.views.get(*id).is_some()); @@ -97,14 +53,6 @@ impl WorkspaceStore { claimed } - /// Record a window's geometry and persist. Called on every structural - /// change (the same funnel the tree sync rides), so reopening the - /// workspace lands where the user left it. - /// - /// The display hint rides along for the same reason the geometry does: it is - /// what the picker needs about a workspace whose machine is *not* answering, - /// and the moment to capture it is while it still is. Read before the store - /// is borrowed — the answer comes from another global. pub fn record_geometry( cx: &mut gpui::App, id: WorkspaceId, @@ -117,13 +65,9 @@ impl WorkspaceStore { return; }; let Some(view) = store.views.get_mut(id) else { - // The workspace was closed out from under us (its window is - // tearing down); nothing to record. return; }; view.window = Some(window); - // Only ever replaced by something better: a machine that has gone quiet - // must not blank the label it gave us while it was up. if let Some((label, subject)) = hint { view.label = Some(label); view.subject = subject; @@ -131,8 +75,6 @@ impl WorkspaceStore { store.views.save(); } - /// Mark the focused workspace, so the next launch restores focus to the - /// window the user was actually in. pub fn focus(cx: &mut gpui::App, id: WorkspaceId) { let Some(store) = Self::try_store(cx) else { return; @@ -142,27 +84,11 @@ impl WorkspaceStore { } store.views.active = Some(id); store.views.save(); - // The machine's tree keeps its own recency (its pickers order by it), - // so the focus is a fact to report there too. crate::ui::tree_sync::fire_workspace_op(cx, id, |ws| { tty7_core::daemon::control::ControlRequest::WorkspaceTouch { workspace: ws } }); } - /// Pick the one workspace launch will show, and detach every other one that - /// was still open at the last quit. - /// - /// The detaching is the point: `open` means "a window is showing this", and - /// launch is about to make that false for all but one of them. Leaving the - /// rest marked open would have the switcher badge them "open" with no window - /// to switch to, and would have the *next* quit believe they were on screen. - /// Their panes are untouched — this is exactly the state - /// [`close_window`](Self::close_window) leaves behind, reached in bulk. - /// - /// The workspace kept need not have been open at all: quitting with every - /// window closed comes back to the one closed last (see - /// [`WindowViews::workspace_to_restore`]). `None` means there are no saved - /// workspaces whatsoever — a first run. pub fn restore_one(cx: &mut gpui::App) -> Option<WorkspaceId> { let store = Self::try_store(cx)?; let keep = store.views.workspace_to_restore()?; @@ -184,13 +110,7 @@ impl WorkspaceStore { Some(keep) } - /// Detach a workspace: its window is gone, but the panes keep running in - /// the daemon and the entry stays for the picker to reopen. pub fn close_window(cx: &mut gpui::App, id: WorkspaceId) { - // The last moment this client can see what the machine calls the - // workspace — and a detached workspace is precisely what the picker - // lists, so the hint matters most here. Read before the borrow, as in - // [`record_geometry`](Self::record_geometry). let hint = Self::all(cx) .get(id) .and_then(|view| crate::ui::machine_mirror::display_hint(cx, view)); @@ -208,9 +128,6 @@ impl WorkspaceStore { store.views.save(); } - /// Forget a workspace entirely — the explicit "Close Workspace" action. - /// The caller is responsible for the machine-side half (killing panes, - /// `WorkspaceRemove`); this only drops the client's pointer. pub fn remove(cx: &mut gpui::App, id: WorkspaceId) { let Some(store) = Self::try_store(cx) else { return; @@ -222,26 +139,14 @@ impl WorkspaceStore { store.views.save(); } - // ----- the client / machine split ------------------- - - /// The machine a workspace's panes are on. `HostId::LOCAL` for a workspace - /// this client owns, and for an id that is no longer on file — a window - /// whose workspace vanished is showing nothing, and "nothing" is here. pub fn host_of(cx: &gpui::App, id: WorkspaceId) -> HostId { host_for(Self::all(cx), id) } - /// The remote a workspace points at, or `None` when it is a local one. pub fn remote_ref(cx: &gpui::App, id: WorkspaceId) -> Option<RemoteRef> { Self::all(cx).get(id).and_then(|w| w.host.clone()) } - /// Whether this client can reach the machine `id`'s panes are on *right - /// now*. - /// - /// A local workspace is always reachable: its daemon is this machine's, and - /// a gate that could answer otherwise for a local window would stop it - /// acting on its own workspace. pub fn machine_is_connected(cx: &mut gpui::App, id: WorkspaceId) -> bool { let Some(host) = Self::remote_ref(cx, id) else { return true; @@ -249,20 +154,6 @@ impl WorkspaceStore { crate::ui::remote_connect::HostLinks::get(cx, host.host_id()).is_some() } - /// The client-side entry for `host` — the existing one if this machine has - /// seen that workspace before, a fresh one otherwise. - /// - /// The two ids are deliberately different things: the entry has its own - /// [`WorkspaceId`] (this client's handle, what the window registry and the - /// Window menu key on), and `host.workspace` is the id **on the remote**, - /// which is what the machine-tree operations carry. Reusing - /// one id for both would collide the moment two machines minted the same - /// uuid, and would quietly make a client id meaningful off this machine. - /// - /// The entry is matched on the whole [`RemoteRef`], so the same workspace id - /// on two different machines is two entries, and reconnecting to one you - /// have opened before reuses its window geometry rather than cascading a new - /// window every time. pub fn claim_remote(cx: &mut gpui::App, host: RemoteRef) -> WorkspaceId { let Some(store) = Self::try_store(cx) else { return WorkspaceId::new(); @@ -287,24 +178,10 @@ impl WorkspaceStore { } } -/// The machine a window showing `id` is bound to. -/// -/// The whole of "one window, one machine" reduces to this being a *function*: a -/// window shows one workspace, a workspace names one host, so a window has one -/// host and there is no arrangement of the data in which it has two. Split out -/// from [`WorkspaceStore::host_of`] so it can be tested against a view set -/// built by hand, with no globals and nothing written to disk. -/// -/// An id that is not on file answers `LOCAL`: a window whose workspace was -/// deleted out from under it is showing nothing, and "nothing" is here — the -/// safe answer, because it is the one that refuses no local action. pub(crate) fn host_for(views: &WindowViews, id: WorkspaceId) -> HostId { views.get(id).map(|w| w.host_id()).unwrap_or(HostId::LOCAL) } -/// Whether rebinding a window from `previous` to `current` moved it to another -/// machine — the moment every piece of per-*window* state that outlived the -/// swap has to be reconsidered. pub(crate) fn crosses_machines(previous: HostId, current: HostId) -> bool { previous != current } @@ -313,18 +190,6 @@ pub(crate) fn crosses_machines(previous: HostId, current: HostId) -> bool { mod tests { use super::*; - /// **The window/host invariant, as a test.** - /// - /// A window is one machine. The inverse is listed under - /// *never do this*, and the M5 data layer spends that guarantee — a - /// workspace stores `host` once instead of per pane, and `sidebar_group` - /// stays a bare `PathBuf` — so it has to be nailed down rather than - /// believed. - /// - /// What is actually being asserted: for any view set containing local - /// and remote entries on several machines, the host a window binds to is a - /// *function* of the workspace it shows. Every id answers exactly one - /// machine, and no id answers two. #[test] fn a_window_binds_to_exactly_one_machine() { let build = RemoteTarget::Alias { @@ -344,7 +209,6 @@ mod tests { ..WindowViews::default() }; - // Three machines are represented, and they stay apart. let l = host_for(&views, local_id); let b1 = host_for(&views, build_a_id); let b2 = host_for(&views, build_b_id); @@ -355,24 +219,15 @@ mod tests { assert_ne!(b1, l); assert_ne!(g, l); - // The answer is stable: asking twice cannot give a window a second host. assert_eq!(host_for(&views, build_a_id), b1); - // And a window whose workspace was deleted underneath it falls back to - // local rather than to some other machine's id. assert_eq!(host_for(&views, WorkspaceId::new()), HostId::LOCAL); - // Only a host change is a machine change — the trigger for dropping the - // per-window state (the closed-tab stack) that could otherwise carry a - // tab across. assert!(!crosses_machines(b1, b2)); assert!(crosses_machines(l, b1)); assert!(crosses_machines(b1, g)); } - /// Two workspaces on one machine answer one `HostId`; a workspace on another - /// machine answers a different one. That equality is what every "is this the - /// same machine?" check in the window layer is built on. #[test] fn host_ids_group_by_machine_not_by_workspace() { let build = RemoteTarget::Alias { diff --git a/src/core/ssh_config.rs b/src/core/ssh_config.rs index 12444710..a78db670 100644 --- a/src/core/ssh_config.rs +++ b/src/core/ssh_config.rs @@ -1,14 +1,3 @@ -//! `~/.ssh/config` parsing: alias resolution for typed connects and the -//! Settings-page import (PRD §3.3). -//! -//! Saved profiles are the app's single listed source of SSH hosts; this module -//! never feeds a UI list directly. It resolves a *named* alias on demand -//! (`resolve_alias_to_profile`, used when a typed target names a config Host) -//! and turns the whole config into managed profiles on explicit import -//! (`import_profiles` + `merge_imported`, behind Settings → SSH → "Import -//! from ~/.ssh/config"). `Match` blocks and `canonicalize` are intentionally -//! not evaluated. - use std::collections::HashSet; use std::path::{Path, PathBuf}; @@ -17,9 +6,6 @@ use crate::core::ssh_profile::{ForwardKind, ForwardRule, HostPort, SshProfile as const MAX_INCLUDE_DEPTH: usize = 8; const MAX_CONFIG_FILES: usize = 256; -/// The `group` label stamped on profiles imported from `~/.ssh/config` (also the -/// marker used to recognize them). Newly imported entries get this; an existing -/// profile's group is preserved on re-import. pub const IMPORTED_GROUP: &str = "Imported from ssh_config"; fn home_dir() -> Option<PathBuf> { @@ -37,9 +23,6 @@ fn home_dir() -> Option<PathBuf> { } } -/// Expand `HostName` percent-tokens: `%h` → the alias being resolved, `%%` → a -/// literal `%`. Unknown tokens stay verbatim (matching -/// `expand_identity_placeholders`' policy). fn expand_hostname_tokens(hostname: &str, alias: &str) -> String { let mut out = String::with_capacity(hostname.len()); let mut chars = hostname.chars(); @@ -61,10 +44,6 @@ fn expand_hostname_tokens(hostname: &str, alias: &str) -> String { out } -/// OpenSSH's ssh_config has no trailing-comment syntax: `#` only starts a -/// comment at the beginning of a (whitespace-trimmed) line, and a `#` inside a -/// value (a `ProxyCommand` fragment, a filename) is literal. Truncating -/// mid-line would silently corrupt such values. fn strip_comment(line: &str) -> &str { if line.trim_start().starts_with('#') { "" @@ -178,43 +157,12 @@ fn glob_match(pattern: &str, text: &str) -> bool { inner(pattern.as_bytes(), text.as_bytes()) } -// ───────────────────────────────────────────────────────────────────────────── -// ssh_config → profile import (PRD §3.3) -// -// The code below resolves the russh-mappable fields of each concrete -// `Host` alias into a [`ManagedProfile`], so a config entry can connect natively -// (there is no system-ssh fallback). Scope, per PRD §3.3: -// -// - fields resolved onto the native spec: HostName, User, Port, IdentityFile -// (multiple), ProxyJump, ProxyCommand, ForwardAgent, ConnectTimeout, -// ServerAliveInterval, ServerAliveCountMax, Ciphers, MACs, KexAlgorithms, -// HostKeyAlgorithms, Compression, ForwardX11, StrictHostKeyChecking, and -// LocalForward / RemoteForward / DynamicForward; -// - first-match-wins per OpenSSH semantics, including wildcard `Host *` fallbacks; -// IdentityFile and the forward directives accumulate across matching blocks; -// - algorithm lists (`Ciphers`/`MACs`/…) are taken verbatim as an explicit list; -// OpenSSH's `+`/`-`/`^` modifier syntax is NOT applied (such values are dropped); -// - `Match` blocks and `canonicalize` are intentionally NOT evaluated, and there -// is no fallback for a config that needs them (explicit tradeoff — see the doc); -// - import is explicit and repeatable: re-importing an unchanged config is a -// no-op (existing profiles are matched by name and their ids/secrets/flags kept). -// ───────────────────────────────────────────────────────────────────────────── - -/// One imported alias: the resolved profile plus the raw `ProxyJump` target (if -/// any). Jump targets are strings here; mapping them to a profile id happens in -/// [`merge_imported`], once all imported profiles have ids. #[derive(Clone, Debug, PartialEq, Eq)] pub struct ImportedProfile { - /// The resolved profile (its `jump_host` is always `None` at this stage). pub profile: ManagedProfile, - /// The raw `ProxyJump` target as written (e.g. `bastion`, `me@jump:2222`), if - /// the alias set one. pub proxy_jump: Option<String>, } -/// Parse `~/.ssh/config` (following `Include`) and resolve every concrete `Host` -/// alias into an [`ImportedProfile`]. Returns an empty vec when no config exists. -// Consumed by the import UI (a later workstream); unused until that merges. #[allow(dead_code)] pub fn import_profiles() -> Vec<ImportedProfile> { let Some(home) = home_dir() else { @@ -223,12 +171,9 @@ pub fn import_profiles() -> Vec<ImportedProfile> { import_profiles_from(home.join(".ssh/config"), &home) } -/// [`import_profiles`] against an explicit root/home (for tests). pub fn import_profiles_from(root: PathBuf, home: &Path) -> Vec<ImportedProfile> { let blocks = parse_config_blocks(root, home); - // Collect concrete aliases in first-seen order (dedup, skip wildcards/negations - // and the synthetic pre-Host global block). let mut aliases: Vec<String> = Vec::new(); let mut seen = HashSet::new(); for block in &blocks { @@ -255,29 +200,17 @@ pub fn import_profiles_from(root: PathBuf, home: &Path) -> Vec<ImportedProfile> .collect() } -/// One alias resolved against `~/.ssh/config` into a transient in-memory profile, -/// plus the raw `ProxyJump` target the alias set (if any). Unlike an -/// [`ImportedProfile`], this is *not* persisted: it's built fresh per connect for -/// the native (russh) path, so it carries a new id, no group, and no credential -/// reference. The `proxy_jump` string is conveyed alongside because a transient -/// profile has no store to resolve a jump *profile* against — the caller resolves -/// the raw hop (another alias, or `user@host:port`) into the nested spec itself. #[derive(Clone, Debug, PartialEq, Eq)] pub struct ResolvedAlias { pub profile: ManagedProfile, pub proxy_jump: Option<String>, } -/// Resolve a single `~/.ssh/config` alias into a transient [`ManagedProfile`] for -/// a native connect (PRD §3.3). Returns `None` when nothing in the config applies -/// to `alias` (no matching `Host` block and no `HostName`), so the caller can fall -/// back to treating the alias string as a bare hostname. pub fn resolve_alias_to_profile(alias: &str) -> Option<ResolvedAlias> { let home = home_dir()?; resolve_alias_to_profile_from(home.join(".ssh/config"), &home, alias) } -/// [`resolve_alias_to_profile`] against an explicit root/home (for tests). pub fn resolve_alias_to_profile_from( root: PathBuf, home: &Path, @@ -286,7 +219,6 @@ pub fn resolve_alias_to_profile_from( let blocks = parse_config_blocks(root, home); let matched = blocks.iter().any(|block| block_matches(block, alias)); let resolved = resolve_alias(alias, &blocks); - // Nothing in the config touches this alias — let the caller use it as a host. if !matched && resolved.hostname.is_none() { return None; } @@ -298,13 +230,7 @@ pub fn resolve_alias_to_profile_from( }) } -/// Map a [`ResolvedHost`] onto `profile`'s connection/session/algorithm/forward -/// fields, returning the raw `ProxyJump` target (resolved to an id / nested spec -/// by the caller). Shared by the import path and the transient-alias resolver. fn apply_resolved(profile: &mut ManagedProfile, alias: &str, r: ResolvedHost) -> Option<String> { - // OpenSSH expands `%h` in HostName to the name given on the command line - // (the alias) — the common `Host *.corp` + `HostName %h.internal` pattern - // relies on it; taken verbatim it would try to resolve a literal `%h.…`. profile.host = r .hostname .map(|h| expand_hostname_tokens(&h, alias)) @@ -323,8 +249,6 @@ fn apply_resolved(profile: &mut ManagedProfile, alias: &str, r: ResolvedHost) -> profile.algorithms.mac = r.macs.unwrap_or_default(); profile.algorithms.kex = r.kex.unwrap_or_default(); profile.algorithms.hostkey = r.hostkey_algorithms.unwrap_or_default(); - // ssh_config `Compression yes` → offer the OpenSSH compression set; anything - // else leaves the list empty (russh defaults, i.e. no compression). profile.algorithms.compression = if r.compression == Some(true) { vec![ "zlib@openssh.com".to_string(), @@ -338,20 +262,8 @@ fn apply_resolved(profile: &mut ManagedProfile, alias: &str, r: ResolvedHost) -> r.proxy_jump } -/// Upsert `imported` into `existing`, matched by profile **name** (the alias). -/// -/// - New alias → pushed with a fresh id and the [`IMPORTED_GROUP`] label. -/// - Existing name → connection fields are overwritten (host/port/user/identity -/// files/proxy command/agent-forward/jump host); the user-owned id, group, -/// `credential_ref`, auth, forwards, and other flags are preserved. -/// -/// `ProxyJump` targets are resolved to `jump_host` ids in a second pass by -/// matching the jump alias against a profile name; unresolved targets leave -/// `jump_host` as `None`. -// Consumed by the import UI (a later workstream); unused until that merges. #[allow(dead_code)] pub fn merge_imported(existing: &mut Vec<ManagedProfile>, imported: Vec<ImportedProfile>) { - // Remember each imported alias's raw jump target for the resolve pass. let mut jump_targets: Vec<(String, String)> = Vec::new(); for entry in imported { @@ -364,7 +276,6 @@ pub fn merge_imported(existing: &mut Vec<ManagedProfile>, imported: Vec<Imported } match existing.iter_mut().find(|p| p.name == profile.name) { Some(current) => { - // Overwrite connection fields; keep everything user-owned. current.host = profile.host; current.port = profile.port; current.user = profile.user; @@ -376,7 +287,6 @@ pub fn merge_imported(existing: &mut Vec<ManagedProfile>, imported: Vec<Imported } } - // Second pass: resolve jump aliases → profile ids now that all names exist. for (name, raw) in jump_targets { let Some(target_alias) = jump_alias(&raw) else { continue; @@ -391,9 +301,7 @@ pub fn merge_imported(existing: &mut Vec<ManagedProfile>, imported: Vec<Imported } } -/// Extract the alias/host from a `ProxyJump` target, taking the first hop of a -/// comma-separated chain and stripping any `user@`/`:port` (bracketed IPv6 aware). -#[allow(dead_code)] // only reached via merge_imported (a later workstream's entry point) +#[allow(dead_code)] fn jump_alias(raw: &str) -> Option<String> { let first = raw.split(',').next().unwrap_or(raw).trim(); if first.is_empty() { @@ -402,13 +310,11 @@ fn jump_alias(raw: &str) -> Option<String> { crate::core::ssh_profile::parse_quick_connect(first).map(|q| q.host) } -/// A single `Host <patterns>` block with its option lines (keyword lowercased). struct HostBlock { patterns: Vec<String>, options: Vec<(String, String)>, } -/// The subset of resolved options an import cares about. #[derive(Default)] struct ResolvedHost { hostname: Option<String>, @@ -427,17 +333,11 @@ struct ResolvedHost { hostkey_algorithms: Option<Vec<String>>, compression: Option<bool>, forward_x11: Option<bool>, - /// `None` = follow the global setting; `Some(false)` = `StrictHostKeyChecking - /// no` (disable verification). Only "no" maps; `accept-new`/`yes`/default leave - /// this `None`. `strict_seen` gives first-match-wins over the tri-state. verify_host_keys: Option<bool>, strict_seen: bool, - /// LocalForward / RemoteForward / DynamicForward, in config order (accumulated). forwards: Vec<ForwardRule>, } -/// Walk the config (expanding `Include` inline so file order — and thus -/// first-match-wins — is preserved) into an ordered list of [`HostBlock`]s. fn parse_config_blocks(root: PathBuf, home: &Path) -> Vec<HostBlock> { let mut blocks = Vec::new(); let mut seen = HashSet::new(); @@ -465,11 +365,7 @@ fn parse_config_file( let base = path.parent().unwrap_or(home).to_path_buf(); let mut current: Option<HostBlock> = None; - // Options appearing before the first `Host` apply globally; model them as a - // synthetic `Host *` block so first-match-wins picks them up as a fallback. let mut global: Option<HostBlock> = None; - // Inside an (unsupported) `Match` block, ignore option lines until the next - // `Host`. let mut in_match = false; for line in text.lines() { @@ -491,7 +387,6 @@ fn parse_config_file( options: Vec::new(), }); } else if key.eq_ignore_ascii_case("match") { - // Match is not evaluated; flush the current block and skip its options. if let Some(block) = current.take() { blocks.push(block); } @@ -500,8 +395,6 @@ fn parse_config_file( if in_match { continue; } - // Flush the current block so included content sorts after it (close - // enough for first-match-wins; nested-within-a-Host includes are rare). if let Some(block) = current.take() { blocks.push(block); } @@ -535,8 +428,6 @@ fn parse_config_file( } } -/// Resolve one alias against the ordered blocks with first-match-wins semantics -/// (wildcard blocks included). `IdentityFile` accumulates across matching blocks. fn resolve_alias(alias: &str, blocks: &[HostBlock]) -> ResolvedHost { let mut r = ResolvedHost::default(); for block in blocks { @@ -568,7 +459,6 @@ fn resolve_alias(alias: &str, blocks: &[HostBlock]) -> ResolvedHost { } } "proxycommand" if r.proxy_command.is_none() => { - // A ProxyCommand is a whole command line — do not tokenize it. let v = val.trim(); if !v.is_empty() && !v.eq_ignore_ascii_case("none") { r.proxy_command = Some(v.to_string()); @@ -606,8 +496,6 @@ fn resolve_alias(alias: &str, blocks: &[HostBlock]) -> ResolvedHost { } "stricthostkeychecking" if !r.strict_seen => { r.strict_seen = true; - // Only an explicit "no" (disable) maps to a native override; - // accept-new / yes / ask / default leave it to the global check. if first_word(val).is_some_and(|v| { matches!(v.to_ascii_lowercase().as_str(), "no" | "off" | "false") }) { @@ -636,8 +524,6 @@ fn resolve_alias(alias: &str, blocks: &[HostBlock]) -> ResolvedHost { r } -/// Whether a block's pattern list matches `alias` (OpenSSH semantics: at least one -/// positive `*`/`?` glob matches and no negated `!pattern` matches). fn block_matches(block: &HostBlock, alias: &str) -> bool { let mut positive = false; for pat in &block.patterns { @@ -652,20 +538,14 @@ fn block_matches(block: &HostBlock, alias: &str) -> bool { positive } -/// The first whitespace-delimited word of a value, respecting quotes. fn first_word(value: &str) -> Option<String> { split_words(value).into_iter().next() } -/// Parse an OpenSSH yes/no-style boolean (case-insensitive; `true`/`false` too). fn yes_no(value: &str) -> bool { matches!(value.to_ascii_lowercase().as_str(), "yes" | "true") } -/// Parse a comma-separated algorithm list (`Ciphers`/`MACs`/`KexAlgorithms`/…) -/// into an explicit list. OpenSSH's `+`/`-`/`^` modifier syntax (append / remove / -/// move-to-front relative to the built-in defaults) is NOT applied — such values -/// are dropped (`None`) rather than mis-interpreted as an absolute list. fn parse_algorithm_list(value: &str) -> Option<Vec<String>> { let token = first_word(value)?; if token.starts_with(['+', '-', '^']) { @@ -679,9 +559,6 @@ fn parse_algorithm_list(value: &str) -> Option<Vec<String>> { (!list.is_empty()).then_some(list) } -/// Parse a `LocalForward`/`RemoteForward` (`[bind:]port host:hostport`) or a -/// `DynamicForward` (`[bind:]port`) value into a [`ForwardRule`]. Returns `None` -/// for a malformed line (so a single bad forward is skipped, never fatal). fn parse_forward_rule(kind: ForwardKind, value: &str) -> Option<ForwardRule> { let words = split_words(value); let (bind_host, bind_port) = parse_forward_endpoint(words.first()?)?; @@ -691,7 +568,7 @@ fn parse_forward_rule(kind: ForwardKind, value: &str) -> Option<ForwardRule> { ForwardKind::Local | ForwardKind::Remote => { let (target_host, target_port) = parse_forward_endpoint(words.get(1)?)?; if target_host.is_empty() { - return None; // a Local/Remote forward target needs a host + return None; } HostPort::new(target_host, target_port) } @@ -704,8 +581,6 @@ fn parse_forward_rule(kind: ForwardKind, value: &str) -> Option<ForwardRule> { }) } -/// Parse a `[host:]port` / `[ipv6]:port` forward endpoint. An omitted host yields -/// an empty string (the listen side may omit it). fn parse_forward_endpoint(token: &str) -> Option<(String, u16)> { if let Some(rest) = token.strip_prefix('[') { let close = rest.find(']')?; @@ -723,8 +598,6 @@ fn parse_forward_endpoint(token: &str) -> Option<(String, u16)> { } } -/// A listen-side bind host with the OpenSSH default (loopback) substituted for an -/// omitted address. fn forward_bind_host(host: String) -> String { if host.is_empty() { "127.0.0.1".to_string() @@ -804,15 +677,13 @@ mod tests { .unwrap(); let imported = import_profiles_from(ssh.join("config"), &root); - // Sorted by alias: bastion, prod. let names: Vec<_> = imported.iter().map(|i| i.profile.name.as_str()).collect(); assert_eq!(names, vec!["bastion", "prod"]); let prod = &imported[1]; assert_eq!(prod.profile.host, "10.0.0.5"); - assert_eq!(prod.profile.user, "deploy"); // specific block wins over Host * + assert_eq!(prod.profile.user, "deploy"); assert_eq!(prod.profile.port, 2222); - // IdentityFile accumulates: the profile's own, then the Host * fallback. assert_eq!( prod.profile.identity_files, vec!["~/.ssh/id_prod".to_string(), "~/.ssh/id_common".to_string()] @@ -823,7 +694,6 @@ mod tests { let bastion = &imported[0]; assert_eq!(bastion.profile.host, "jump.example.com"); - // No User set → falls back to Host *. assert_eq!(bastion.profile.user, "fallback-user"); assert_eq!( bastion.profile.proxy_command.as_deref(), @@ -843,7 +713,7 @@ mod tests { " HostName real.example.com\n", "Match host secure\n", " User should-be-ignored\n", - "Host web !web-staging\n", // negation-bearing pattern list (not concrete) + "Host web !web-staging\n", " HostName web.example.com\n", ), ) @@ -851,9 +721,7 @@ mod tests { let imported = import_profiles_from(ssh.join("config"), &root); let names: Vec<_> = imported.iter().map(|i| i.profile.name.as_str()).collect(); - // `secure` and `web` are concrete; `!web-staging` is a negation, not an alias. assert_eq!(names, vec!["secure", "web"]); - // The Match block's User must not leak onto `secure`. let secure = imported .iter() .find(|i| i.profile.name == "secure") @@ -875,7 +743,6 @@ mod tests { ) .unwrap(); - // A user already has a `prod` profile with a credential + custom auth. let mut existing = vec![{ let mut p = ManagedProfile::new("prod"); p.host = "old-host".to_string(); @@ -889,11 +756,9 @@ mod tests { let imported = import_profiles_from(ssh.join("config"), &root); merge_imported(&mut existing, imported); - assert_eq!(existing.len(), 2); // prod updated + bastion added + assert_eq!(existing.len(), 2); let prod = existing.iter().find(|p| p.name == "prod").unwrap(); - // Connection field overwritten... assert_eq!(prod.host, "10.0.0.5"); - // ...but id, group, credential, and auth preserved. assert_eq!(prod.id, prod_id); assert_eq!(prod.group.as_deref(), Some("My Servers")); assert_eq!(prod.auth, AuthMode::Password); @@ -902,8 +767,6 @@ mod tests { let bastion = existing.iter().find(|p| p.name == "bastion").unwrap(); assert_eq!(bastion.group.as_deref(), Some(IMPORTED_GROUP)); - // Re-import of the unchanged config is a no-op (idempotent): same ids, same - // count, same fields. let snapshot = existing.clone(); let imported_again = import_profiles_from(ssh.join("config"), &root); merge_imported(&mut existing, imported_again); @@ -929,7 +792,6 @@ mod tests { let bastion_id = existing.iter().find(|p| p.name == "bastion").unwrap().id; let prod = existing.iter().find(|p| p.name == "prod").unwrap(); - // The `me@bastion:2222` jump target resolves to the `bastion` profile's id. assert_eq!(prod.jump_host, Some(bastion_id)); } @@ -953,9 +815,6 @@ mod tests { #[test] fn hash_only_comments_whole_lines_not_values() { - // OpenSSH has no trailing-comment syntax: a `#` inside a value is - // literal (e.g. in a ProxyCommand), while a line starting with `#` - // (after leading whitespace) is a comment. let root = temp_root("resolve-hash"); let ssh = root.join(".ssh"); std::fs::create_dir_all(&ssh).unwrap(); @@ -1023,14 +882,12 @@ mod tests { assert_eq!(p.algorithms.mac, vec!["hmac-sha2-256"]); assert_eq!(p.algorithms.kex, vec!["curve25519-sha256"]); assert_eq!(p.algorithms.hostkey, vec!["ssh-ed25519"]); - assert!(!p.algorithms.compression.is_empty()); // Compression yes → offered + assert!(!p.algorithms.compression.is_empty()); assert!(p.x11); - assert_eq!(p.verify_host_keys, Some(false)); // StrictHostKeyChecking no - // Transient profile: fresh id, no group, no credential. + assert_eq!(p.verify_host_keys, Some(false)); assert!(p.group.is_none()); assert!(p.credential_ref.is_none()); - // Forwards: Local, Remote, Dynamic — in config order, loopback bind default. let forwards = &p.forwards; assert_eq!(forwards.len(), 3); assert_eq!(forwards[0].kind, ForwardKind::Local); @@ -1048,7 +905,6 @@ mod tests { let root = temp_root("resolve-modifiers"); let ssh = root.join(".ssh"); std::fs::create_dir_all(&ssh).unwrap(); - // A `+`-prefixed Ciphers list modifies the defaults; we don't apply it. std::fs::write( ssh.join("config"), "Host m\n HostName h\n Ciphers +aes256-gcm@openssh.com\n", @@ -1065,14 +921,11 @@ mod tests { std::fs::create_dir_all(&ssh).unwrap(); std::fs::write(ssh.join("config"), "Host *\n User fallback\n").unwrap(); - // A bare host matches `Host *`, so the fallback User applies and the host - // is the alias itself. let resolved = resolve_alias_to_profile_from(ssh.join("config"), &root, "example.com").unwrap(); assert_eq!(resolved.profile.host, "example.com"); assert_eq!(resolved.profile.user, "fallback"); - // With no config at all, resolution yields nothing → caller uses the alias. assert!(resolve_alias_to_profile_from(root.join("missing"), &root, "whatever").is_none()); } @@ -1089,7 +942,6 @@ mod tests { let prod = resolve_alias_to_profile_from(ssh.join("config"), &root, "prod").unwrap(); assert_eq!(prod.proxy_jump.as_deref(), Some("bastion")); - // The raw jump alias resolves against the same config into its own profile. let bastion = resolve_alias_to_profile_from(ssh.join("config"), &root, "bastion").unwrap(); assert_eq!(bastion.profile.host, "jump.example.com"); assert_eq!(bastion.profile.user, "jumper"); diff --git a/src/core/update.rs b/src/core/update.rs index 4dabbce2..52dd2c30 100644 --- a/src/core/update.rs +++ b/src/core/update.rs @@ -1,17 +1,3 @@ -//! Notify-only update check. -//! -//! On GUI startup (unless `config.check_for_updates` is off) we make one GET to -//! the GitHub releases API, compare the latest published version against the -//! running binary, and — if it's newer — stash an [`UpdateStatus`] global that -//! Settings → About reads to show a "download" prompt linking to the Releases -//! page. That's the whole feature: we never download, replace, or restart -//! anything. The user updates by hand (drag the new `.app`, unzip, …), exactly -//! as the README's Install section describes. -//! -//! Everything here fails soft: no network, a rate-limit, a private/renamed repo, -//! an unparseable tag — all collapse to "no prompt", logged at `debug` and never -//! surfaced. A terminal must open the same whether or not GitHub is reachable. - use anyhow::{Context as _, Result}; use gpui::http_client::{AsyncBody, HttpClient as _, HttpRequestExt as _, RedirectPolicy}; use gpui::{AnyWindowHandle, App, AsyncApp, Global, PromptLevel, Window, http_client}; @@ -22,32 +8,17 @@ use std::time::Duration; use crate::core::config::Config; -/// `owner/repo` the release check queries — matches the repository the binary is -/// published from (see `Cargo.toml`'s `repository`). const REPO: &str = "l0ng-ai/tty7"; -/// Where the "Download" prompt points. GitHub's `/releases/latest` alias always -/// resolves to the newest published (non-prerelease) build, so it never goes -/// stale as versions roll — no need to embed a specific tag. pub const RELEASES_URL: &str = "https://github.com/l0ng-ai/tty7/releases/latest"; -/// Overall wall-clock budget for the check. `ReqwestClient` only sets a *connect* -/// timeout, so without this a connected-but-stalled response could sit pending -/// for the whole session; racing a timer keeps the "fail soft" behavior -/// deterministic instead of open-ended. const CHECK_TIMEOUT: Duration = Duration::from_secs(15); -/// A newer release than the one currently running. #[derive(Clone, Debug, PartialEq, Eq)] pub struct AvailableUpdate { - /// The newer version, normalized without a leading `v` (e.g. `"0.3.1"`), for - /// display in the About panel. pub version: String, } -/// The result of the startup update check, stored as a GPUI global so the -/// Settings view can read it. Absent until the check completes; `available` is -/// `None` when we're already current (or the check failed / was skipped). #[derive(Clone, Debug, Default)] pub struct UpdateStatus { pub available: Option<AvailableUpdate>, @@ -55,15 +26,6 @@ pub struct UpdateStatus { impl Global for UpdateStatus {} -/// Kick off the background update check. Returns immediately; the network work -/// runs on a detached task and, if a newer version exists: -/// 1. writes the [`UpdateStatus`] global (the passive Settings → About prompt, -/// shown on every launch while outdated), and -/// 2. pops a one-time modal dialog for that version — but only the first -/// launch it's seen; the version is remembered in `update.json` so we never -/// nag twice for the same release. -/// -/// Honors `config.check_for_updates`: when off, we make no network call at all. pub fn spawn_check(cx: &mut App) { if !cx.global::<Config>().check_for_updates { return; @@ -71,14 +33,9 @@ pub fn spawn_check(cx: &mut App) { spawn_check_forced(cx); } -/// The same check, ignoring the `check_for_updates` toggle — for explicit -/// user-initiated checks (the tray's "Check for Updates…"), where "I asked" -/// overrides "don't ask on my behalf at startup". pub fn spawn_check_forced(cx: &mut App) { cx.spawn(async move |cx| { let current = env!("CARGO_PKG_VERSION"); - // Race the fetch against a timer so a stalled connection can't leave the - // task pending forever; whichever finishes first wins. let latest = match fetch_latest_version() .or(async { cx.background_executor().timer(CHECK_TIMEOUT).await; @@ -88,8 +45,6 @@ pub fn spawn_check_forced(cx: &mut App) { { Ok(v) => v, Err(e) => { - // `{e:#}` includes the anyhow context chain; kept at debug so a - // routine offline start doesn't spam the log. log::debug!("update check skipped: {e:#}"); return; } @@ -103,8 +58,6 @@ pub fn spawn_check_forced(cx: &mut App) { let version = latest.trim_start_matches('v').to_string(); log::info!("update available: {version} (running {current})"); - // Record it for the passive Settings → About prompt and repaint so an - // already-open About picks it up now rather than on the next interaction. cx.update(|cx| { cx.set_global(UpdateStatus { available: Some(AvailableUpdate { @@ -114,15 +67,10 @@ pub fn spawn_check_forced(cx: &mut App) { cx.refresh_windows(); }); - // Active modal: pop exactly once per version. If a previous launch - // already showed it for this version, stop here — About still carries - // the passive prompt. if UpdateState::load().last_prompted.as_deref() == Some(version.as_str()) { return; } - // The check can outrace the window-open task at startup; wait briefly - // for a window to host the modal before giving up. let Some(window) = wait_for_window(cx).await else { return; }; @@ -132,8 +80,6 @@ pub fn spawn_check_forced(cx: &mut App) { .is_ok() }); - // Persist only after the modal actually went up, so a version we never - // managed to show still gets its one prompt on a later launch. if shown { UpdateState { last_prompted: Some(version), @@ -144,11 +90,7 @@ pub fn spawn_check_forced(cx: &mut App) { .detach(); } -/// Poll (briefly) for the app's main window. Returns `None` if none appears -/// within the window — treated as "no host for the modal", so we simply skip it. async fn wait_for_window(cx: &mut AsyncApp) -> Option<AnyWindowHandle> { - // ~5s of 100ms ticks. The network round-trip almost always finishes after - // the window is already up, so this usually returns on the first poll. for _ in 0..50 { if let Some(handle) = cx.update(|cx| cx.windows().first().copied()) { return Some(handle); @@ -160,14 +102,11 @@ async fn wait_for_window(cx: &mut AsyncApp) -> Option<AnyWindowHandle> { None } -/// Show the one-time "update available" modal, and open the Releases page if the -/// user picks Download. Mirrors the window-close confirmation's prompt style. fn prompt_update(version: &str, window: &mut Window, cx: &mut App) { let detail = format!( "tty7 {version} is available — you're on {}. Open the download page to get it.", env!("CARGO_PKG_VERSION") ); - // Index 1 == "Download"; index 0 (Later) and a dismissed prompt do nothing. let answer = window.prompt( PromptLevel::Info, "Update available", @@ -183,8 +122,6 @@ fn prompt_update(version: &str, window: &mut Window, cx: &mut App) { .detach(); } -/// Open the GitHub Releases page with the OS default handler. Shared by the -/// modal's Download button and the Settings → About Download button. pub fn open_releases_page() { let opener = if cfg!(target_os = "macos") { "open" @@ -198,9 +135,6 @@ pub fn open_releases_page() { } } -/// Tiny persisted state for the update checker, stored at `update.json` in the -/// config dir (alongside `config.json` / `views.json`). Currently just the -/// last version we popped the modal for, so we never nag twice for one release. #[derive(Debug, Default, serde::Serialize, serde::Deserialize)] struct UpdateState { #[serde(default)] @@ -212,8 +146,6 @@ impl UpdateState { crate::core::config::config_path("update.json") } - /// Load persisted state; a missing / unreadable / malformed file all yield - /// the default (never prompted), so at worst we prompt once more. fn load() -> Self { let Some(path) = Self::path() else { return Self::default(); @@ -227,7 +159,6 @@ impl UpdateState { }) } - /// Persist state; IO / serialization errors are logged and swallowed. fn save(&self) { let Some(path) = Self::path() else { return; @@ -245,17 +176,12 @@ impl UpdateState { } } -/// The `tag_name` field of GitHub's release payload — the only piece we read. #[derive(serde::Deserialize)] struct LatestRelease { tag_name: String, } -/// GET the repo's latest release and return its raw tag (e.g. `"v0.3.1"`). async fn fetch_latest_version() -> Result<String> { - // GitHub rejects requests without a User-Agent; identify ourselves. The - // reqwest+rustls stack this rides on is already compiled into the app via - // `gpui-component-assets`, so constructing a client here is cheap. let client = ReqwestClient::user_agent(concat!("tty7/", env!("CARGO_PKG_VERSION"))) .context("building HTTP client")?; @@ -287,23 +213,8 @@ async fn fetch_latest_version() -> Result<String> { Ok(release.tag_name) } -/// Parse a version string into a `(major, minor, patch, is_release)` tuple, -/// tolerating a leading `v`. Missing minor/patch components read as `0`. -/// Returns `None` if the numeric core doesn't parse — the caller treats that -/// as "don't prompt". -/// -/// The trailing `is_release` flag implements semver's pre-release ordering -/// through plain tuple comparison: a `-nightly.20260716` (or `-rc.1`) suffix -/// makes it `false`, which sorts *below* the same core with `true` — so a -/// nightly binary counts as older than the stable release it previews, and the -/// prompt fires when that stable ships. Finer ordering *between* pre-releases -/// isn't needed: the check only ever compares against `/releases/latest`, -/// which never returns a pre-release. Build metadata (`+ci`) is ignored, as -/// semver says it should be. fn parse_version(s: &str) -> Option<(u64, u64, u64, bool)> { let trimmed = s.trim(); - // Strip a single optional `v` prefix. `strip_prefix` (not `trim_start_matches`) - // so a doubled `vv0.3.1` fails to parse instead of silently losing the extra v. let core = trimmed.strip_prefix('v').unwrap_or(trimmed); let is_release = !core.split('+').next().unwrap_or(core).contains('-'); let core = core.split(['-', '+']).next().unwrap_or(core); @@ -311,17 +222,12 @@ fn parse_version(s: &str) -> Option<(u64, u64, u64, bool)> { let major = parts.next()?.parse().ok()?; let minor = parts.next().unwrap_or("0").parse().ok()?; let patch = parts.next().unwrap_or("0").parse().ok()?; - // Reject extra components (`0.3.1.1`) rather than truncating them: an - // unrecognizable tag must never surface a bogus "update available". if parts.next().is_some() { return None; } Some((major, minor, patch, is_release)) } -/// Whether `latest` names a strictly newer version than `current`. If either -/// side fails to parse we return `false`: an unrecognizable tag should never -/// nag the user to "update" to something we can't even order. fn is_update_available(latest: &str, current: &str) -> bool { match (parse_version(latest), parse_version(current)) { (Some(latest), Some(current)) => latest > current, @@ -338,22 +244,16 @@ mod tests { assert_eq!(parse_version("v0.3.1"), Some((0, 3, 1, true))); assert_eq!(parse_version("0.3.1"), Some((0, 3, 1, true))); assert_eq!(parse_version(" 1.2.0 "), Some((1, 2, 0, true))); - // Missing components default to zero. assert_eq!(parse_version("v2"), Some((2, 0, 0, true))); assert_eq!(parse_version("v2.5"), Some((2, 5, 0, true))); - // A pre-release suffix keeps the core but sorts below the release… assert_eq!(parse_version("v0.4.0-rc.1"), Some((0, 4, 0, false))); assert_eq!( parse_version("26.7.1-nightly.20260716"), Some((26, 7, 1, false)) ); - // …while build metadata alone is still a release. assert_eq!(parse_version("0.4.0+ci.7"), Some((0, 4, 0, true))); - // Garbage yields None. assert_eq!(parse_version("nightly"), None); assert_eq!(parse_version(""), None); - // Malformed cores are rejected, not truncated into a bogus version: - // extra components or a doubled prefix must not parse. assert_eq!(parse_version("v0.3.1.1"), None); assert_eq!(parse_version("0.3.1.0"), None); assert_eq!(parse_version("vv0.3.1"), None); @@ -364,7 +264,6 @@ mod tests { assert!(is_update_available("v0.3.1", "0.3.0")); assert!(is_update_available("v1.0.0", "0.9.9")); assert!(is_update_available("0.4.0", "0.3.99")); - // CalVer jump over the old 0.x tags still orders correctly. assert!(is_update_available("v26.7.0", "0.17.0")); } @@ -377,15 +276,9 @@ mod tests { #[test] fn nightly_binaries_prompt_when_their_stable_ships() { - // A nightly previews the next stable: same core, sorts below it. assert!(is_update_available("v26.7.1", "26.7.1-nightly.20260716")); - // …but the stable it was built *after* is not an update. assert!(!is_update_available("v26.7.0", "26.7.1-nightly.20260716")); - // A stable binary is never downgraded to a pre-release of itself. assert!(!is_update_available("v26.7.1-rc.1", "26.7.1")); - // Nightly-to-nightly is deliberately not an update: pre-releases with - // the same core compare equal, and the check only ever sees - // /releases/latest, which is never a pre-release anyway. assert!(!is_update_available( "26.7.1-nightly.20260717", "26.7.1-nightly.20260716" @@ -396,30 +289,24 @@ mod tests { fn unparseable_tag_never_prompts() { assert!(!is_update_available("garbage", "0.3.0")); assert!(!is_update_available("v0.3.1", "garbage")); - // A malformed newer-looking tag must not surface a bogus update. assert!(!is_update_available("v0.4.0.1", "0.3.0")); assert!(!is_update_available("vv0.4.0", "0.3.0")); } #[test] fn update_state_round_trips_and_defaults() { - // Pin a throwaway config dir (first-call-wins; same scheme the session - // tests use, so the whole test binary shares one temp dir). crate::core::config::pin_test_config_dir(); let path = UpdateState::path().expect("config dir pinned"); - // Missing file → default (never prompted), so we'd prompt. let _ = std::fs::remove_file(&path); assert_eq!(UpdateState::load().last_prompted, None); - // A recorded version round-trips, so a second launch skips the modal. UpdateState { last_prompted: Some("0.4.0".into()), } .save(); assert_eq!(UpdateState::load().last_prompted.as_deref(), Some("0.4.0")); - // Don't leak state into other runs sharing the pinned dir. let _ = std::fs::remove_file(&path); } } diff --git a/src/core/window_state.rs b/src/core/window_state.rs index 0436b37a..95d5c14c 100644 --- a/src/core/window_state.rs +++ b/src/core/window_state.rs @@ -1,24 +1,9 @@ -//! The gpui-facing half of [`WindowState`]. -//! -//! The struct itself, its `window.json` IO, and the "is this geometry sane" -//! guard live in `tty7-core` — `views.json` embeds the geometry in each -//! [`WindowView`](crate::core::session::WindowView), which is defined there. -//! What is left here is the only part that genuinely needs gpui: turning the -//! four stored `f32`s into a [`Bounds<Pixels>`] and back. - use gpui::{Bounds, Pixels, point, px}; pub use tty7_core::core::window_state::WindowState; -/// Conversions between the stored geometry and gpui's window bounds. -/// -/// An extension trait rather than inherent methods because the type lives in -/// `tty7-core`; bring it into scope and `WindowState::from_bounds(..)` / -/// `state.bounds()` read exactly as they did before the crate split. pub trait WindowGeometry: Sized { - /// Capture a window's current bounds for persisting. fn from_bounds(bounds: Bounds<Pixels>) -> Self; - /// The bounds to reopen a window at. fn bounds(&self) -> Bounds<Pixels>; } diff --git a/src/daemon.rs b/src/daemon.rs index cba2d29c..263b65c8 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -1,15 +1 @@ -//! The persistent terminal daemon, as the GUI sees it. -//! -//! The daemon itself — PTY ownership, replay rings, fan-out, the wire protocol, -//! the native SSH engine — moved wholesale into `tty7-core` when the headless -//! `tty7-server` needed to run exactly the same code on a machine with no -//! display. Nothing about it was GUI-shaped to begin with (it has never -//! referenced gpui, `terminal`, or `ui`), so the move was a relocation, not a -//! rewrite. -//! -//! This re-export keeps the GUI's call sites reading `crate::daemon::protocol`, -//! `crate::daemon::spawn::ensure_running`, and so on, exactly as before. The -//! client-side terminal that talks the protocol is still -//! `terminal::remote::RemoteTerminal`. - pub use tty7_core::daemon::*; diff --git a/src/main.rs b/src/main.rs index b1669e7e..8e2dc575 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,3 @@ -// Hide the console window on Windows release builds; keep it in debug builds -// so println!/eprintln! output remains visible while developing. #![cfg_attr( all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows" @@ -15,10 +13,6 @@ use crate::ui::assets::Assets; use crate::ui::keymap; use gpui::*; -/// Register the bundled Hack monospace faces with gpui's text system so the -/// default `font_family` ("Hack") renders identically on every machine, with no -/// dependency on the user having the font installed (the app bundles its -/// own copy). The four faces cover regular / bold / italic / bold-italic. fn register_bundled_fonts(cx: &mut App) { use std::borrow::Cow; let fonts = vec![ @@ -32,63 +26,28 @@ fn register_bundled_fonts(cx: &mut App) { } } -/// Watch `config.json` and hot-reload the app when it changes on disk, so -/// hand-edits (or an external tool rewriting the file) take effect live — no -/// restart. We watch the config *directory*, not the file: editors and our own -/// [`Config::save`] replace `config.json` via a temp-file + rename (atomic -/// write), which severs any watch bound to the original inode. Watching the -/// parent and filtering to `config.json` events survives the swap. -/// -/// The `notify` callback fires on a background OS thread, which can't touch GPUI -/// state. We bridge to the app (main) thread the same way the daemon reader does -/// (see `terminal::remote`): a `smol::channel` carries a bare "something changed" -/// ping, and a `cx.spawn` task on the foreground executor drains it and does the -/// reload with a real `&mut App`. -/// -/// Scope note: this re-applies theme + colors live (via `apply_theme`, which -/// reads the freshly-loaded `Config` global). Font size / line height / font -/// family are cached in `Tty7App`'s fields and pushed into each `TerminalView`, -/// so a live change to *those* keys needs a hook in `ui::app` (owned elsewhere); -/// they still take effect for newly-opened tabs and on restart. Font *family* -/// changes need no font re-registration: `add_fonts` is only for bundled/custom -/// face files (we ship Hack, registered once at startup); any other family is a -/// system font gpui resolves by name at render time. fn spawn_config_watcher(cx: &mut App) { use notify::{RecursiveMode, Watcher}; - // Resolve the file we care about and the directory we actually watch. If the - // config dir doesn't resolve (no override/env/$HOME) there's nothing to do. let Some(config_file) = crate::core::config::config_path("config.json") else { return; }; let Some(dir) = crate::core::config::config_dir_path() else { return; }; - // The dir may not exist yet on a first run; watching a missing path errors. - // Create it so the watch attaches (harmless — the daemon/save would too). let _ = std::fs::create_dir_all(&dir); - // Coalesce a save's burst of events (truncate → write → rename can fire - // several times) into a single reload: on the first ping we wait out a short - // quiet period, drain anything queued, then reload once. const DEBOUNCE: std::time::Duration = std::time::Duration::from_millis(200); let (tx, rx) = smol::channel::unbounded::<()>(); let watched_file = config_file.clone(); let handler = move |res: notify::Result<notify::Event>| { let Ok(event) = res else { return }; - // React to events that touch our `config.json`, or a theme file dropped - // into the `themes/` subfolder — both feed the same registry reload below. - // Everything else in the dir (`views.json`, `history`, the daemon - // socket, and our own `.config.json.tmp.<pid>` / `*.yaml.tmp.<pid>` atomic - // scratch files, whose extensions aren't theme extensions) is ignored. let hit = event .paths .iter() .any(|p| p.file_name() == watched_file.file_name() || is_theme_file(p)); if hit { - // try_send: a full channel just means a reload is already pending; - // one ping is enough to trigger the (idempotent) reload. let _ = tx.try_send(()); } }; @@ -100,8 +59,6 @@ fn spawn_config_watcher(cx: &mut App) { return; } }; - // Recursive so the `themes/` subfolder is covered too (it may not exist yet; - // FSEvents picks up subdirs created later). The handler filters the noise. if let Err(e) = watcher.watch(&dir, RecursiveMode::Recursive) { log::warn!( "config hot-reload disabled: failed to watch {}: {e}", @@ -109,58 +66,25 @@ fn spawn_config_watcher(cx: &mut App) { ); return; } - // The `RecommendedWatcher` owns the background watch thread; dropping it stops - // watching. It has to live for the whole app, so we intentionally leak it - // rather than thread a handle through app state (there's exactly one, for the - // process lifetime, so a one-off leak is the simplest correct choice). Box::leak(Box::new(watcher)); cx.spawn(async move |cx| { while rx.recv().await.is_ok() { - // Debounce: let the save settle, then swallow the rest of the burst so - // we reload exactly once. cx.background_executor().timer(DEBOUNCE).await; while rx.try_recv().is_ok() {} cx.update(|cx| { - // `Config::load` clamps/validates and falls back to defaults on a - // parse error (a half-written file mid-edit), so a bad reload can - // never crash the renderer — worst case we momentarily show - // defaults until the next (valid) save re-triggers this. cx.set_global(Config::load()); - // Reload the theme registry too, so edits to (or new) theme files - // in the themes folder take effect on the same hot-reload path. crate::ui::presets::load_registry(cx); crate::ui::theme::apply_cursor_hide_mode(cx); - // Re-paint theme + colors from the new config. We have no window - // handle in this global task, but `apply_theme` accepts `None`: - // it still updates the `Theme`/palette globals (what actually - // repaints). The window-bound effects — the Transparent↔Blurred - // background flip and traffic-light re-pinning — are covered by - // `Tty7App::reload_from_config`, which observes the `Config` - // global with its window and re-runs `apply_theme(Some(window))`. crate::ui::theme::apply_theme(None, cx); - // Schedule every window to redraw so the new palette shows at once. cx.refresh_windows(); }); } - // Loop only ends if every `Sender` drops — but the sole sender lives in - // the leaked watcher's handler, so in practice this runs for the app's - // lifetime. }) .detach(); - - // Note on feedback loops: our own `Config::save` (theme toggle, font zoom) - // rewrites `config.json` and will trip this watcher. That's benign — the - // reload reads back the same content we just wrote and re-applies it - // idempotently, so it can't oscillate; it's at worst one redundant repaint. } -/// Whether `p` is a theme file living directly in the `themes/` subfolder — a -/// `*.yaml` / `*.yml` / `*.itermcolors` whose parent directory is named `themes`. -/// The parent check keeps a stray yaml elsewhere in the config dir from tripping -/// a theme reload, and the extension check excludes the `*.tmp.<pid>` scratch -/// files atomic writes leave behind mid-save. fn is_theme_file(p: &std::path::Path) -> bool { p.parent().and_then(|d| d.file_name()) == Some(std::ffi::OsStr::new("themes")) && p.extension().and_then(|e| e.to_str()).is_some_and(|e| { @@ -170,9 +94,6 @@ fn is_theme_file(p: &std::path::Path) -> bool { }) } -/// Parse `--config-dir <path>` (or `--config-dir=<path>`) from the CLI and pin -/// it as the process config directory before anything reads config. Lets a dev -/// build keep its state in a throwaway folder — see the `dev` cargo alias. fn apply_config_dir_arg() { let mut args = std::env::args().skip(1); while let Some(arg) = args.next() { @@ -189,8 +110,6 @@ fn apply_config_dir_arg() { } } -/// Merge two `:`-separated PATH lists, primary entries first, deduped, empties -/// dropped. Pure so it can be unit-tested; the env write stays in the caller. #[cfg(unix)] fn merge_paths(primary: &str, secondary: &str) -> String { let mut seen = std::collections::HashSet::new(); @@ -202,19 +121,9 @@ fn merge_paths(primary: &str, secondary: &str) -> String { .join(":") } -/// GUI apps launched from Finder/Dock inherit Launch Services' minimal PATH -/// (`/usr/bin:/bin:/usr/sbin:/sbin`), not the user's shell PATH — so the -/// completion engine's `$PATH` scan (`terminal::completion`) can't see -/// Homebrew/cargo/… executables and command candidates silently vanish. Ask the -/// user's login shell for its PATH once and merge it in front of ours (current -/// entries are kept: terminal launches may carry extras like direnv paths). -/// Login-but-not-interactive (`-l -c`) keeps it cheap: zsh reads .zprofile, not -/// .zshrc. Shells spawned by the daemon are unaffected either way — they are -/// login shells and rebuild PATH themselves. #[cfg(unix)] fn enrich_path_from_login_shell() { let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into()); - // fish prints `$PATH` space-separated; ask it to join with ':' explicitly. let cmd = if std::path::Path::new(&shell).file_name() == Some("fish".as_ref()) { "string join ':' $PATH" } else { @@ -239,15 +148,9 @@ fn enrich_path_from_login_shell() { return; } let merged = merge_paths(&login_path, &std::env::var("PATH").unwrap_or_default()); - // SAFETY: called from `main` before any thread is spawned, so no concurrent - // getenv can race the write. unsafe { std::env::set_var("PATH", merged) }; } -/// A bare (non-bundled) binary — `cargo dev` / `cargo run` — has no -/// `Info.plist` pointing the Dock at `tty7.icns`, so macOS shows the generic -/// executable icon. Feed the Dock the bundled logo at runtime in that case; -/// launches from the real `.app` keep the `.icns` and skip this. #[cfg(target_os = "macos")] fn set_dock_icon_for_bare_binary() { use objc2::{AnyThread, MainThreadMarker}; @@ -261,16 +164,12 @@ fn set_dock_icon_for_bare_binary() { if bundled { return; } - // gpui's `run` closure executes on the main thread; bail defensively - // rather than panic if that ever stops holding. let Some(mtm) = MainThreadMarker::new() else { return; }; static ICON_PNG: &[u8] = include_bytes!("../assets/app-icon.png"); let data = NSData::with_bytes(ICON_PNG); if let Some(image) = NSImage::initWithData(NSImage::alloc(), &data) { - // SAFETY: passing a valid NSImage on the main thread; AppKit copies the - // reference, no ownership transferred. unsafe { NSApplication::sharedApplication(mtm).setApplicationIconImage(Some(&image)); } @@ -278,11 +177,6 @@ fn set_dock_icon_for_bare_binary() { } fn main() { - // Agent-hook mode: `tty7 agent-hook <agent> <event>` is the tiny emitter - // Claude Code's hooks invoke (see `core::agent_hooks`). It reads the hook - // payload from stdin, writes one OSC sequence to the controlling terminal, - // and exits — never touching config, the daemon, or the GUI. Checked first - // so a hook can never accidentally boot a window. { let args: Vec<String> = std::env::args().skip(1).take(3).collect(); if args.first().map(String::as_str) == Some("agent-hook") { @@ -293,35 +187,16 @@ fn main() { } } - // Resolve the config directory override (if any) up front, before any code - // path touches config/session/history files (the daemon socket path resolves - // under this dir too, so the order matters). apply_config_dir_arg(); - // Panics inside gpui's `extern "C"` input callbacks abort instead of - // unwinding, and the OS crash report then holds the abort rather than the - // panic — no message, no location. Record those to `crash.log` in the config - // dir. Installed here, right after the config dir resolves, so both the GUI - // and the daemon below are covered from their first line of real work. let role = if std::env::args().any(|a| a == "--daemon") { "daemon" } else { "gui" }; crate::core::crash::install(role); - // And the ordinary `log::` records, which otherwise go nowhere at all — - // the daemon's stdio is `/dev/null` by the time it is detached. Off unless - // `TTY7_LOG` asks for it; see `core::logfile`. crate::core::logfile::install(role); - // Daemon mode: when launched with `--daemon` we run the headless persistent - // terminal server and never open a window. This is the backing process the GUI - // auto-spawns and reconnects to; it owns all PTYs + child shells and outlives - // the GUI. It is the *same* daemon `tty7-server --daemon` runs on a remote - // box — panes plus the control dialect — because a local machine and a - // remote one are the same thing seen from different distances, and the - // workspace tree both serve lives behind control. Run to completion (the - // accept loop blocks until killed) then return. if std::env::args().any(|a| a == "--daemon") { if let Err(e) = crate::daemon::server::run_daemon() { log::error!("daemon exited with error: {e}"); @@ -329,33 +204,14 @@ fn main() { return; } - // Stop mode: `--stop-daemon` shuts the persistent daemon down (hanging up - // every shell) and returns without ever opening a window. On Windows the - // detached daemon is the running image of `tty7.exe`, so it locks the file - // and blocks an upgrade/uninstall from replacing it; the installer runs this - // first to release the lock. Harmless when no daemon is running. if std::env::args().any(|a| a == "--stop-daemon") { crate::daemon::spawn::stop(); return; } - // GUI path: repair the starved Launch Services PATH before anything reads it - // (completion scans it per keystroke; the daemon we spawn below inherits it). #[cfg(unix)] enrich_path_from_login_shell(); - // Make sure the persistent daemon is up before we open a window, so the - // very first RemoteTerminal can connect. This auto-spawns a detached - // daemon if none is running (sharing our config dir). Failure is non-fatal — - // we log and continue; a still-absent daemon will surface later when a - // RemoteTerminal fails to connect, rather than blocking startup here. - // - // When session restore is off, start the daemon *fresh* instead of reusing a - // live one: this launch won't re-attach to the previous session's panes, so - // reusing the daemon would leave those shells running orphaned (unreachable, - // never hung up). `restart()` hangs up every old shell then spawns a clean - // daemon — and is safe (equivalent to a plain spawn) when none is running. - // Read straight off disk; the `Config` global isn't set until inside `run`. let restore_session = crate::core::config::Config::load().restore_session; let daemon_result = if restore_session { crate::daemon::spawn::ensure_running() @@ -366,8 +222,6 @@ fn main() { log::error!("failed to ensure daemon is running: {e}"); } - // Register the bundled icon/font asset source so gpui-component `Icon`s - // (tab glyphs, sidebar icons, etc.) can actually load their SVGs. gpui_platform::application() .with_assets(Assets) .run(move |cx| { @@ -376,63 +230,22 @@ fn main() { cx.activate(true); #[cfg(target_os = "macos")] set_dock_icon_for_bare_binary(); - // Load user config once and stash it as a global for views to read. cx.set_global(Config::load()); - // Seed the cached OS light/dark appearance before anything resolves a - // theme from it. It has to be read here, off the appearance-observer - // path — see `ui::theme::SystemAppearance`. crate::ui::theme::refresh_system_appearance(cx); - // Read `views.json` before any window is built: windows claim - // their workspace from this store rather than each parsing the - // file themselves. crate::core::session::WorkspaceStore::init(cx); - // The window registry has to exist before the first window opens — - // `ui::windows::open` registers into it. crate::ui::windows::WindowRegistry::init(cx); - // Build the theme registry (built-ins + user theme files) before the - // first window paints its theme. crate::ui::presets::load_registry(cx); - // Honor `mouse_hide_while_typing` from the start. crate::ui::theme::apply_cursor_hide_mode(cx); - // Start watching `config.json` so edits hot-reload theme/colors live. spawn_config_watcher(cx); - // Ask GitHub (once, in the background) whether a newer release exists; - // if so, Settings → About surfaces a download prompt. Fails soft and - // is a no-op when `check_for_updates` is disabled. crate::core::update::spawn_check(cx); - // If any agent's installed hooks point at a moved/stale tty7 - // binary (the app updated or relocated since they were - // installed), rewrite them in place — off the startup path, since - // it reads (and rarely writes) the agents' config files. No-op in - // debug builds and when hooks are absent or already current. cx.background_executor() .spawn(async { crate::core::agent_hooks::refresh_hooks_at_launch(); }) .detach(); keymap::init(cx); - // Hold a control link to this machine's own daemon, exactly as a - // remote machine gets one: the daemon owns the workspace tree and - // serves it over control, so the local GUI is a control client - // like any other. Supervised on its own forever loop — see - // `ui::local_link`. crate::ui::local_link::LocalLink::install(cx); - // Come up on the *one* workspace the user was last in, at its own - // remembered geometry (`ui::windows` owns that logic, since "New - // Workspace" and the workspace picker need the identical path). - // - // Deliberately one window, not one per workspace that was open at - // quit: see `WindowViews::workspace_to_restore` for why, and - // `WorkspaceStore::restore_one` for what happens to the others (they - // are detached, not forgotten — panes keep running and the switcher - // lists them). Closing every window before quitting is *not* a - // reason to come up empty: those workspaces still hold running - // panes, so launch reattaches the one closed last. - // - // `None` is therefore a first run only, and it opens a single window - // on a fresh workspace holding one terminal — exactly as every - // pre-multi-window build did. let reopen = crate::core::session::WorkspaceStore::restore_one(cx); crate::ui::windows::open(cx, reopen); }); @@ -448,7 +261,6 @@ mod tests { merge_paths("/opt/homebrew/bin:/usr/bin", "/usr/bin:/bin:"), "/opt/homebrew/bin:/usr/bin:/bin" ); - // A starved LS PATH gains the login entries up front. assert_eq!( merge_paths( "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin", diff --git a/src/terminal/boxdraw.rs b/src/terminal/boxdraw.rs index 61d1cd92..9b64419b 100644 --- a/src/terminal/boxdraw.rs +++ b/src/terminal/boxdraw.rs @@ -1,51 +1,11 @@ -//! Native box-drawing: the U+2500–U+257F box characters and U+2580–U+259F -//! block elements, drawn as geometry sized to the actual cell instead of as -//! font glyphs. -//! -//! Why the font can't do this job: a glyph fills (at most) the font's own line -//! height, but the cell it paints into is `font_size × Config::line_height` — -//! 1.4 by default. At any line height above 1.0 a `│` covers only the middle of -//! its cell, so every vertical run of box characters breaks into dashes with a -//! gap at each row boundary: a two-line shell prompt's `╭`/`╰` no longer -//! connect, a TUI frame is perforated down both sides. Horizontal continuity -//! has the same problem in miniature whenever a fallback face's advance -//! disagrees with the cell width. -//! -//! Drawing the range natively pins every stroke to the cell's real edges, so -//! adjacent cells join seamlessly at any line height, any font, any fallback -//! chain. This is the same special case every terminal with a line-height -//! setting ships (kitty, alacritty, WezTerm, iTerm2), and the same approach the -//! Powerline separators in `element.rs` already use — they skip fonts entirely. -//! -//! [`glyph`] returns the character's ink as rectangles and filled paths in cell -//! coordinates; `paint_glyphs` fills them with the cell's foreground. A char -//! outside the range returns `None` and falls back to the font. - use gpui::{Bounds, Pixels, point, px, size}; -/// One paintable piece of a box-drawing glyph. pub(crate) enum Ink { - /// A solid rectangle in the cell's foreground color. Rect(Bounds<Pixels>), - /// A rectangle at a fraction of the foreground's alpha — the ░▒▓ shades, - /// which fake their dither by translucency exactly as WezTerm does. Shade(Bounds<Pixels>, f32), - /// A filled path — rounded corners and diagonals, the two shapes a - /// rectangle can't express. Path(gpui::Path<Pixels>), } -/// The ink for `c` sized to `bounds`, or `None` for anything that isn't a -/// box-drawing/block character (which then renders through the font). -/// -/// `scale` is the window's device scale factor. Every straight stroke is -/// snapped to the *device pixel* grid it implies — not for crispness alone, -/// but for continuity: a cell boundary at a fractional device pixel gets an -/// antialiasing ramp on both sides, and two abutting 50%-coverage edges -/// composite to 75% opacity, which perforated every multi-row `│` with a -/// lighter band at each row boundary. Snapped edges rasterize with no ramp at -/// all, so adjacent cells butt into one continuous solid — the same reason -/// kitty's cell-aligned box bitmaps tile seamlessly. pub(crate) fn glyph(c: char, bounds: Bounds<Pixels>, scale: f32) -> Option<Vec<Ink>> { if !('\u{2500}'..='\u{259f}').contains(&c) { return None; @@ -61,7 +21,6 @@ pub(crate) fn glyph(c: char, bounds: Bounds<Pixels>, scale: f32) -> Option<Vec<I .or_else(|| g.blocks(c)) } -/// The weight of one arm (centre → edge) of a box character. #[derive(Clone, Copy, PartialEq)] enum Arm { None, @@ -69,8 +28,6 @@ enum Arm { Heavy, } -/// Cell geometry in f32, plus the light stroke thickness `t` (see -/// [`light_thickness`] for how that one is chosen). struct Cell { x0: f32, y0: f32, @@ -82,24 +39,6 @@ struct Cell { scale: f32, } -/// The light stroke thickness for a cell `cell_width` wide, in logical pixels. -/// -/// Two rules, in order: -/// -/// 1. Derive from the cell *width* — a pure font-size proxy — never the height: -/// the height carries the line-height stretch, and a `─` that fattens when -/// the user opens up their line spacing would look broken. -/// 2. Then quantise so the result covers a whole number of device pixels. -/// -/// Rule 2 keeps the nominal weight and the painted weight in agreement: -/// [`Cell::vstroke`] lays a stroke off in whole device pixels, and everything -/// positioned relative to `t` (the arm overshoot, the double-line separation, -/// `heavy = 2 × light`) should be reasoning about the same value the rasteriser -/// will actually produce. -/// -/// Rounding the logical value *first* is what keeps 1x and 2x byte-identical to -/// what this module shipped with — those are the scales it was tuned and -/// visually verified at, so the fractional-scale fix must not disturb them. fn light_thickness(cell_width: f32, scale: f32) -> f32 { let logical = (cell_width * 0.15).round().max(1.); (logical * scale).round().max(1.) / scale @@ -124,16 +63,10 @@ impl Cell { } } - /// Snap a logical coordinate onto the device pixel grid. fn snap(&self, v: f32) -> f32 { (v * self.scale).round() / self.scale } - /// A rectangle with every edge snapped to device pixels (see [`glyph`]). - /// Snapping the two edges — not origin + size — is what keeps a shared - /// cell boundary shared: both cells snap the same coordinate to the same - /// pixel line, so consecutive `│` cells tile with zero gap and zero - /// overlap whatever the window position. fn rectb(&self, x: f32, y: f32, w: f32, h: f32) -> Bounds<Pixels> { let (sx0, sy0) = (self.snap(x), self.snap(y)); let (sx1, sy1) = (self.snap(x + w), self.snap(y + h)); @@ -144,31 +77,10 @@ impl Cell { Ink::Rect(self.rectb(x, y, w, h)) } - /// A logical thickness as a whole number of device pixels, back in logical - /// units. Never zero: a stroke that rounds away is worse than one that is - /// a touch too thick. fn stroke_px(&self, w: f32) -> f32 { (w * self.scale).round().max(1.) / self.scale } - /// A vertical stroke of logical width `w`, centred on `x`, spanning - /// `ya..yb`. - /// - /// The two *ends* snap like any other edge, so a stroke that runs to a cell - /// boundary still shares that boundary exactly with the cell beyond it — - /// the tiling property [`rectb`](Self::rectb) exists for. - /// - /// The *width* is deliberately not a second pair of independent snaps. Two - /// edges `w` apart land `w × scale` device pixels apart, and unless that is - /// exactly a whole number the two `round`s straddle it — rounding apart in - /// some cells and together in others, which made vertical rules alternate - /// thin/thick across the columns of a TUI table at Windows' default 125% / - /// 150% scaling. [`light_thickness`] picks `w` so the product is integral, - /// but `f32` cannot always represent it exactly (a `1.5×` scale gives - /// `2/1.5 × 1.5 = 2.0000001`), and a coordinate landing on a `.5` tie then - /// rounds whichever way the error points. Laying the width off from the - /// snapped near edge sidesteps the tie entirely: same weight everywhere, - /// by construction rather than by luck. fn vstroke(&self, x: f32, w: f32, ya: f32, yb: f32) -> Ink { let (x0, y0, y1) = (self.snap(x - w / 2.), self.snap(ya), self.snap(yb)); Ink::Rect(Bounds::new( @@ -177,8 +89,6 @@ impl Cell { )) } - /// A horizontal stroke of logical width `w`, centred on `y`, spanning - /// `xa..xb`. See [`vstroke`](Self::vstroke). fn hstroke(&self, y: f32, w: f32, xa: f32, xb: f32) -> Ink { let (y0, x0, x1) = (self.snap(y - w / 2.), self.snap(xa), self.snap(xb)); Ink::Rect(Bounds::new( @@ -187,14 +97,6 @@ impl Cell { )) } - /// The light/heavy arm combinations: one rectangle per arm, each running - /// from its cell edge to just past the centre. - /// - /// The overshoot (`m`, half the thickest arm) is what makes a corner: two - /// perpendicular strokes that merely *meet* at the centre point leave a - /// notch at the outside of the turn. Same-color opaque overlap costs - /// nothing, so every arm overshoots by the same amount and any combination - /// of weights joins solid. fn arms(&self, u: Arm, d: Arm, l: Arm, r: Arm) -> Vec<Ink> { let w = |a: Arm| match a { Arm::None => 0., @@ -219,20 +121,9 @@ impl Cell { ink } - /// The double-line set (U+2550–U+256C), spelled out stroke by stroke. - /// - /// Doubles can't reuse the [`arms`](Self::arms) overshoot trick: their - /// junctions are *open* — ╬ is four corner pieces around a hole, ╠'s inner - /// stroke breaks where the branch leaves — so each character lists exactly - /// the segments the Unicode chart draws, with endpoints snapped half a - /// stroke past the line they join so corners close without crossing the - /// gap. fn doubles(&self, c: char) -> Option<Vec<Ink>> { let t = self.t; let h = t / 2.; - // The parallel strokes sit at centre ± d. At the 1px thickness of - // ordinary font sizes this leaves a 3px gap — wide enough to survive - // subpixel placement without the two strokes bleeding into one. let d = (t * 1.5).max(2.0); let (x0, x1, y0, y1, cx, cy) = (self.x0, self.x1, self.y0, self.y1, self.cx, self.cy); let (va, vb) = (cx - d, cx + d); @@ -326,17 +217,6 @@ impl Cell { }) } - /// The rounded corners ╭ ╮ ╯ ╰ — two straight stubs to the cell edges plus - /// a quarter-circle band between them. `sx`/`sy` name the quadrant the arms - /// leave through: ╭ runs down (+1) and right (+1). - /// - /// The band is a fan of small convex quads, one per arc step, NOT a single - /// outer-arc/inner-arc outline. That outline is concave, and gpui fills a - /// path as a triangle fan from its first vertex — a concave contour gets - /// its whole hollow covered, which rendered every corner as a solid - /// quarter-disc blob the first time around. Each quad is convex, so each - /// fills exactly itself, and at stroke widths of a few pixels twelve steps - /// are indistinguishable from a true arc. fn rounded(&self, c: char) -> Option<Vec<Ink>> { let (sx, sy): (f32, f32) = match c { '╭' => (1., 1.), @@ -346,16 +226,9 @@ impl Cell { _ => return None, }; let h = self.t / 2.; - // The largest radius that keeps the arc inside the cell on its short - // axis; the straight stubs cover whatever the long axis has left over. let r = ((self.x1 - self.x0).min(self.y1 - self.y0) / 2.).max(h * 2.); let (cx, cy) = (self.cx, self.cy); let mut ink = Vec::new(); - // Straight stubs from the arc's ends to the cell edges (zero-length - // when the radius already spans the half-axis). Each stub reaches one - // device pixel *into* the arc band: the stub is pixel-snapped, the arc - // isn't, and without the overlap that mismatch reopens a hairline - // seam exactly where they hand off. let lap = 1. / self.scale; if sy > 0. { ink.push(self.vstroke(cx, self.t, cy + r - lap, self.y1)); @@ -367,31 +240,6 @@ impl Cell { } else { ink.push(self.hstroke(cy, self.t, self.x0, cx - r + lap)); } - // The arc band, from the vertical stub (θ=0) to the horizontal one - // (θ=π/2) around the arc centre one radius into the quadrant. - // - // How this renders decides whether the corner looks like kitty's or - // not, and gpui's pipeline dictates the shape (learned the hard way, - // twice): - // - // * A path contour is filled as a triangle FAN from its start vertex, - // and coverage in the intermediate texture only accumulates — there - // is no winding cancellation. A whole-band outline is concave, so - // its fan covered the hollow and every corner rendered as a solid - // quarter-disc blob. Each contour must therefore be *star-shaped - // from its start vertex*: 30° slices of a thin band are, a 90° band - // is not. - // * All contours ride in ONE Path. Paths composite as premultiplied - // sprites, so two separately painted segments overlap their - // antialiased edges at 75% opacity — the seam at every joint of the - // first polyline attempt. Within a single path the 4x-MSAA samples - // partition cleanly across shared edges instead. - // * The outer edge is a real quadratic (`curve_to`), which the shader - // antialiases *analytically* (Loop–Blinn signed distance) — the - // smooth continuous ramp kitty gets from supersampling. The inner - // edge can't be a curve: with no winding, a concave-side bulge can - // only over-cover. It is a fine polyline instead, whose chord error - // at 7.5° steps (< 0.1px at cell sizes) hides inside the MSAA. let (ax, ay) = (cx + sx * r, cy + sy * r); let at = |radius: f32, theta: f32| { let (x, y) = ( @@ -415,8 +263,6 @@ impl Cell { } None => path.insert(gpui::Path::new(start)), }; - // Control point at the tangents' intersection: the exact - // quadratic through both endpoints for this arc slice. let ctrl = at((r + h) / (step / 2.).cos(), (t0 + t1) / 2.); p.curve_to(at(r + h, t1), ctrl); p.line_to(at(r - h, t1)); @@ -430,9 +276,6 @@ impl Cell { Some(ink) } - /// The dashed lines: n dashes, each 70% of its slot, centred. Deliberately - /// *not* edge-to-edge — a dashed line is supposed to read as broken, and - /// this matches how the font glyphs space them. fn dashed(&self, c: char) -> Option<Vec<Ink>> { let (n, heavy, vertical) = match c { '╌' => (2, false, false), @@ -470,10 +313,6 @@ impl Cell { Some(ink) } - /// The diagonals ╱ ╲ ╳ as corner-to-corner parallelograms. The offset is - /// vertical (not perpendicular) so every vertex stays inside the cell; its - /// length is scaled so the *perpendicular* stroke width still comes out at - /// the light thickness. fn diagonal(&self, c: char) -> Option<Vec<Ink>> { let (w, hgt) = (self.x1 - self.x0, self.y1 - self.y0); let v = self.t * (w * w + hgt * hgt).sqrt() / w; @@ -493,9 +332,6 @@ impl Cell { }) } - /// The block elements U+2580–U+259F: eighths, halves, quadrants, and the - /// ░▒▓ shades (a full-cell wash at a quarter / half / three quarters of the - /// foreground's alpha). fn blocks(&self, c: char) -> Option<Vec<Ink>> { let (x0, x1, y0, y1, cx, cy) = (self.x0, self.x1, self.y0, self.y1, self.cx, self.cy); let (w, hgt) = (x1 - x0, y1 - y0); @@ -506,13 +342,11 @@ impl Cell { let lr = || r(cx, cy, x1 - cx, y1 - cy); Some(match c { '▀' => vec![r(x0, y0, w, hgt / 2.)], - // ▁ (1/8) through █ (the full block): lower k eighths. '▁'..='█' => { let k = (c as u32 - 0x2580) as f32; let hh = hgt * k / 8.; vec![r(x0, y1 - hh, w, hh)] } - // ▉ (7/8) through ▏ (1/8): left k eighths. '▉'..='▏' => { let k = (0x2590 - c as u32) as f32; vec![r(x0, y0, w * k / 8., hgt)] @@ -538,9 +372,6 @@ impl Cell { } } -/// Decode the light/heavy arm combinations: the solid lines, corners, tees and -/// crosses of U+2500–U+254B, and the half/mixed lines of U+2574–U+257F. Order -/// is (up, down, left, right). fn arms_of(c: char) -> Option<(Arm, Arm, Arm, Arm)> { use Arm::{Heavy as H, Light as L, None as N}; Some(match c { @@ -632,13 +463,10 @@ fn arms_of(c: char) -> Option<(Arm, Arm, Arm, Arm)> { mod tests { use super::*; - /// A cell with the proportions the bug shipped in: a 15px font's ~9px - /// advance stretched to a 21px line by `line_height: 1.4`. fn cell() -> Bounds<Pixels> { Bounds::new(point(px(10.), px(20.)), size(px(9.), px(21.))) } - /// min_x / max_x / min_y / max_y over every rect corner and path vertex. fn extents(ink: &[Ink]) -> (f32, f32, f32, f32) { let (mut nx, mut xx, mut ny, mut xy) = (f32::MAX, f32::MIN, f32::MAX, f32::MIN); let mut visit = |x: f32, y: f32| { @@ -664,10 +492,6 @@ mod tests { (nx, xx, ny, xy) } - /// Every character in U+2500–U+259F must decode to native ink — one that - /// silently falls through to the font reintroduces the row-boundary gap - /// for exactly that character, which is worse than uniform behavior in - /// either direction. #[test] fn the_whole_range_is_covered() { for cp in 0x2500u32..=0x259f { @@ -679,13 +503,6 @@ mod tests { } } - /// Nothing may paint outside its own cell: box characters tile, and one - /// cell's overshoot is its neighbor's artifact. - /// - /// The tolerance is half a pixel, not exact: a quadratic's *control point* - /// sits slightly outside the ink it bounds (tangent-intersection, ~3.5% - /// past the arc radius), and `extents` reads raw vertices. The curve - /// itself never leaves the cell. #[test] fn ink_stays_inside_the_cell() { let b = cell(); @@ -702,10 +519,6 @@ mod tests { } } - /// The regression this module exists for: every arm must reach its cell - /// edge *exactly*, so vertical runs connect across the line-height gap and - /// horizontal runs connect across cells. Checked for the whole arms table - /// — including the mixed and half lines — not just `│`. #[test] fn arms_reach_their_edges() { let b = cell(); @@ -732,14 +545,11 @@ mod tests { } } - /// Same edge guarantee for the shapes that aren't plain arms: the doubles, - /// the rounded corners, and the diagonals all tile too. #[test] fn doubles_rounded_and_diagonals_reach_their_edges() { let b = cell(); let (x0, y0) = (b.origin.x.as_f32(), b.origin.y.as_f32()); let (x1, y1) = (x0 + b.size.width.as_f32(), y0 + b.size.height.as_f32()); - // (char, up, down, left, right) let expect = [ ('═', false, false, true, true), ('║', true, true, false, false), @@ -771,8 +581,6 @@ mod tests { } } - /// ╬ is four corner pieces around an open centre — the one double junction - /// where "just extend everything through the middle" would visibly lie. #[test] fn double_cross_keeps_its_open_centre() { let b = cell(); @@ -791,8 +599,6 @@ mod tests { } } - /// Blocks: the full block is the full cell, the halves are exact halves, - /// and the shades wash the whole cell at their nominal alpha. #[test] fn blocks_cover_their_nominal_area() { let b = cell(); @@ -804,8 +610,6 @@ mod tests { (x0, x0 + w, y0, y0 + h), "█ isn't the full cell" ); - // Interior edges (the half-cell split) may sit up to half a device - // pixel from nominal after snapping; the outer edges stay exact. let (_, _, ny, xy) = extents(&glyph('▀', b, 1.).unwrap()); assert_eq!(ny, y0, "▀ doesn't reach the top"); assert!((xy - (y0 + h / 2.)).abs() <= 0.5, "▀ isn't the top half"); @@ -823,8 +627,6 @@ mod tests { } } - /// Heavy strokes must actually be heavier than light ones, and a light - /// stroke never vanishes (≥ 1px) however small the cell. #[test] fn stroke_weights_are_ordered_and_visible() { let light = { @@ -841,7 +643,6 @@ mod tests { }; assert!(light >= 1., "light stroke thinner than a pixel"); assert!(heavy > light, "heavy stroke isn't heavier"); - // A pathologically narrow cell still yields visible ink. let tiny = Bounds::new(point(px(0.), px(0.)), size(px(2.), px(4.))); let Ink::Rect(r) = &glyph('│', tiny, 1.).unwrap()[0] else { panic!() @@ -849,22 +650,16 @@ mod tests { assert!(r.size.width.as_f32() >= 1.); } - /// The seam regression: with the window at a fractional device-pixel - /// offset, every straight stroke must still land on whole device pixels. - /// An unsnapped edge rasterizes an antialiasing ramp, and two abutting - /// ramps composite to 75% opacity — the perforated `│` runs this module - /// was reported for a second time over. #[test] fn straight_strokes_snap_to_device_pixels() { let scale = 2.0; - // Deliberately misaligned: fractional origin and cell width. let b = Bounds::new(point(px(10.37), px(20.11)), size(px(9.03), px(21.))); let on_grid = |v: f32| ((v * scale).round() - v * scale).abs() < 1e-3; for cp in 0x2500u32..=0x259f { let c = char::from_u32(cp).unwrap(); for i in glyph(c, b, scale).unwrap() { let (Ink::Rect(r) | Ink::Shade(r, _)) = i else { - continue; // arcs and diagonals antialias on purpose + continue; }; let (x, y) = (r.origin.x.as_f32(), r.origin.y.as_f32()); let (x2, y2) = (x + r.size.width.as_f32(), y + r.size.height.as_f32()); @@ -875,28 +670,14 @@ mod tests { ); } } - // And two vertically adjacent `│` cells must share their boundary - // exactly — same coordinate in, same snapped pixel line out. let below = Bounds::new(point(px(10.37), px(41.11)), size(px(9.03), px(21.))); let bottom = extents(&glyph('│', b, scale).unwrap()).3; let top = extents(&glyph('│', below, scale).unwrap()).2; assert_eq!(bottom, top, "adjacent │ cells no longer tile"); } - /// Every column must draw `│` at the *same* weight, and every row must draw - /// `─` at the same weight, at any scale factor — not just the integer ones. - /// - /// Note what the test above does *not* catch: it asserts each edge lands on - /// the device grid, which a 1-device-pixel stroke and a 2-device-pixel - /// stroke both satisfy. Windows' default 125%/150% display scaling put a - /// 1-logical-pixel stroke a non-integer number of device pixels wide, and - /// the two independent edge snaps then rounded apart in some columns and - /// together in others: vertical rules alternated thin/thick across a TUI - /// table, horizontal rules alternated down it. Both 1x and 2x are blind to - /// it by construction, so the earlier fixtures could never have failed. #[test] fn stroke_weight_is_uniform_across_cells_at_any_scale() { - // Realistic cell metrics: a 13/15/16px font's advance, line_height 1.4. for (cw, lh) in [(7.8f32, 18.0f32), (9.03, 21.0), (9.6, 22.0), (10.8, 25.0)] { for scale in [1.0f32, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0] { let widths: Vec<f32> = (0..24) @@ -941,9 +722,6 @@ mod tests { } } - /// Quantising the thickness in device space must not change what 1x and 2x - /// already rendered — those are the two scales the module was tuned and - /// visually verified at. #[test] fn integer_scales_keep_their_previous_thickness() { for (cw, lh) in [(7.8f32, 18.0f32), (9.03, 21.0), (9.6, 22.0), (10.8, 25.0)] { @@ -954,7 +732,6 @@ mod tests { previous, "cell_width {cw} at scale {scale} changed weight" ); - // And heavy stays exactly twice light, as `arms` assumes. let b = Bounds::new(point(px(0.), px(0.)), size(px(cw), px(lh))); let Ink::Rect(l) = &glyph('│', b, scale).unwrap()[0] else { panic!() diff --git a/src/terminal/cmd_editor.rs b/src/terminal/cmd_editor.rs index 24c02ea2..69f795c2 100644 --- a/src/terminal/cmd_editor.rs +++ b/src/terminal/cmd_editor.rs @@ -1,37 +1,13 @@ -//! A small, self-contained command-line editor buffer for the prompt. -//! -//! Why not reuse `gpui_component::InputState`? Because it claims `tab`, `up`, -//! `down`, and other keys in its `"Input"` key context, and gpui dispatches -//! keybinding actions *before* `on_key_down` listeners — so an ancestor can't -//! intercept those keys to drive Tab completion / history recall. To own every -//! key at the prompt (the prerequisite for completion, history, syntax -//! highlighting and ghost suggestions) we keep keyboard focus on the terminal and -//! run our own line editor here. -//! -//! The buffer is a `Vec<char>` with a char-index cursor, so cursor arithmetic and -//! word motion never split a multi-byte UTF-8 scalar. It is deliberately -//! editing-only (no rendering, no key mapping); the view owns those. - -/// An editable single line plus a cursor position (a char index in `0..=len`), -/// and an optional selection anchor (the selection spans `anchor..cursor`). #[derive(Default)] pub struct CmdEditor { chars: Vec<char>, cursor: usize, anchor: Option<usize>, - /// Undo / redo stacks of `(chars, cursor)` snapshots. Each mutating edit - /// records the pre-edit state (deduplicated by content) onto `undo`; undo/redo - /// shuttle states between the two. undo: Vec<(Vec<char>, usize)>, redo: Vec<(Vec<char>, usize)>, - /// What the last *kill* removed, for [`Self::yank`] to put back — readline's - /// kill ring, one slot deep. Only the word/line kills (⌃W, ⌃U, ⌃K, ⌥D and - /// the arrow-key spellings of them) write here; a plain character delete is - /// not a kill and leaves it untouched. kill: String, } -/// Cap on undo history, so a long editing session can't grow it without bound. const UNDO_LIMIT: usize = 200; impl CmdEditor { @@ -39,7 +15,6 @@ impl CmdEditor { Self::default() } - /// The current line as a `String`. pub fn text(&self) -> String { self.chars.iter().collect() } @@ -48,30 +23,20 @@ impl CmdEditor { self.chars.is_empty() } - /// Number of chars in the line (cursor is in `0..=len`). pub fn len(&self) -> usize { self.chars.len() } - /// Cursor position as a char index (`0..=len`). Used by tests and the - /// upcoming completion increment. #[allow(dead_code)] pub fn cursor(&self) -> usize { self.cursor } - /// Cursor position as a byte offset into `text()`, for callers that need to - /// slice the rendered string (e.g. to split it at the caret). #[allow(dead_code)] pub fn cursor_byte(&self) -> usize { self.chars[..self.cursor].iter().map(|c| c.len_utf8()).sum() } - // ---- Undo / redo ---- - - /// Record the current state onto the undo stack (deduplicated by content) and - /// clear redo. Called at the start of every mutating edit; nested calls within - /// one edit collapse to a single entry via the content check. fn checkpoint(&mut self) { if self.undo.last().map(|(c, _)| c.as_slice()) != Some(self.chars.as_slice()) { self.undo.push((self.chars.clone(), self.cursor)); @@ -83,15 +48,6 @@ impl CmdEditor { } pub fn undo(&mut self) { - // Skip "phantom" checkpoints whose text already equals the current buffer. - // A no-op edit (Backspace at column 0, Ctrl-K at line end, Ctrl-W at - // column 0, …) still calls `checkpoint()`, recording the pre-edit state — - // which for a no-op is identical to the current one. Undoing that entry - // would be a dead keypress that "restores" the same text instead of the - // real edit before it. Drop past any such entries to the first checkpoint - // that actually changes the text. Comparing text alone is enough: plain - // caret motion never checkpoints, so a top entry matching the current text - // can only be a no-op's phantom, never a cursor-only undo target. while self .undo .last() @@ -116,8 +72,6 @@ impl CmdEditor { } } - /// Insert a string at the cursor, advancing past it. Replaces the selection - /// first if there is one. Used for typed text and IME-committed text alike. pub fn insert_str(&mut self, s: &str) { self.checkpoint(); self.delete_selection(); @@ -127,10 +81,6 @@ impl CmdEditor { } } - /// Insert a string at the start of the line, leaving the caret (and any - /// selection) on the characters they were on — their indices shift by the - /// inserted length. Used to adopt gap typeahead, which was typed - /// chronologically before the editor's current content. pub fn prepend_str(&mut self, s: &str) { if s.is_empty() { return; @@ -144,7 +94,6 @@ impl CmdEditor { self.anchor = self.anchor.map(|a| a + n); } - /// Delete the char before the cursor (Backspace), or the selection if any. pub fn backspace(&mut self) { self.checkpoint(); if self.delete_selection() { @@ -156,7 +105,6 @@ impl CmdEditor { } } - /// Delete the char at the cursor (Delete), or the selection if any. pub fn delete(&mut self) { self.checkpoint(); if self.delete_selection() { @@ -167,18 +115,7 @@ impl CmdEditor { } } - // ---- Selection ---- - - /// The selected range as normalized `(start, end)` char indices, or `None` - /// when there's no (non-empty) selection. pub fn selection(&self) -> Option<(usize, usize)> { - // Clamp both endpoints to the current length. A delete that shrinks the - // buffer without touching the anchor (delete_word_left/right, - // delete_to_start/end never clear it) can leave the anchor past the new - // end; slicing `chars[a..cursor]` on that stale anchor then panics - // (reachable from real input: shift-select, Alt+Delete, then Cmd+C / Cmd+X, - // which read `selected_text()`). Clamping is a no-op for every valid state - // and collapses a deleted-region selection to `None`. let n = self.chars.len(); let a = self.anchor?.min(n); let c = self.cursor.min(n); @@ -189,7 +126,6 @@ impl CmdEditor { } } - /// The selected text, if any. pub fn selected_text(&self) -> Option<String> { let (s, e) = self.selection()?; Some(self.chars[s..e].iter().collect()) @@ -199,15 +135,12 @@ impl CmdEditor { self.anchor = None; } - /// Start a selection at the current cursor if none is active (used before an - /// extending, shift-modified motion). pub fn begin_selection(&mut self) { if self.anchor.is_none() { self.anchor = Some(self.cursor); } } - /// Delete the selection if there is one; returns whether anything was deleted. pub fn delete_selection(&mut self) -> bool { if let Some((s, e)) = self.selection() { self.checkpoint(); @@ -221,37 +154,19 @@ impl CmdEditor { } } - /// Select the whole line. pub fn select_all(&mut self) { self.anchor = Some(0); self.cursor = self.chars.len(); } - /// Bounds `(start, end)` of the word containing char index `idx`: the run - /// of chars that are neither whitespace nor in `separators` (the - /// configured word-separator set, shared with the grid's semantic - /// selection). A separator char is its own one-char word, matching the - /// grid; on whitespace the run collapses and the leftward walk snaps to - /// the previous word's start. - /// - /// `smart` mirrors `Config::smart_select`: with it off this is exactly - /// [`Self::plain_word_bounds`], so the Settings toggle governs the prompt - /// editor and the grid alike. pub fn word_bounds(&self, idx: usize, separators: &str, smart: bool) -> (usize, usize) { let idx = idx.min(self.chars.len()); if !smart { return self.plain_word_bounds(idx, separators, smart); } - // A bracket or quote selects through its match, same as the grid. - // Checked before CJK segmentation so full-width `()`/`“”` pair - // instead of being segmented as lone punctuation tokens. Only for - // the double-click itself — drags use `plain_word_bounds` so the - // selection doesn't lurch when the pointer crosses a quote. if let Some((s, e)) = super::smart_select::pair_range(&self.chars, idx) { return (s, e + 1); } - // CJK prose has no separators between words: segment it with the - // platform dictionary instead of selecting the whole unbroken run. if let Some(&c) = self.chars.get(idx) && super::smart_select::is_cjk(c) { @@ -263,11 +178,6 @@ impl CmdEditor { self.plain_word_bounds(idx, separators, smart) } - /// [`Self::word_bounds`] without the pair/segmentation smarts: the plain - /// separator-walk word. Used for word-granular drags, where pair matching - /// would make the selection jump around as the pointer crosses a quote. - /// `smart` still governs the mixed-script narrowing, so a drag matches - /// what the double-click that started it selected. fn plain_word_bounds(&self, idx: usize, separators: &str, smart: bool) -> (usize, usize) { let idx = idx.min(self.chars.len()); if let Some(&c) = self.chars.get(idx) @@ -285,8 +195,6 @@ impl CmdEditor { while e < self.chars.len() && !boundary(self.chars[e]) { e += 1; } - // Mixed-script runs (a Latin word glued to CJK text) shrink to the - // clicked char's script class — same correction as the grid's. if smart && idx < e { let (ns, ne) = super::smart_select::narrow_to_script(&self.chars, idx, s, e - 1); return (ns, ne + 1); @@ -294,18 +202,12 @@ impl CmdEditor { (s, e) } - /// Select the word containing char index `idx` (see [`Self::word_bounds`]). pub fn select_word_at(&mut self, idx: usize, separators: &str, smart: bool) { let (s, e) = self.word_bounds(idx, separators, smart); self.anchor = Some(s); self.cursor = e; } - /// Extend a word-granular drag (double-click then drag) to char index `idx`, - /// keeping the whole anchor word `anchor_start..anchor_end` selected. The - /// selection grows by whole words: dragging past the anchor word selects - /// forward to the far edge of the word under `idx`, dragging before it selects - /// backward to that word's near edge. The cursor sits at the moving edge. pub fn extend_word_to( &mut self, anchor_start: usize, @@ -324,8 +226,6 @@ impl CmdEditor { } } - /// Move the cursor to char index `idx` (clamped), extending the selection from - /// the existing anchor (starting one at the old cursor if needed). For drags. pub fn extend_to(&mut self, idx: usize) { self.begin_selection(); self.cursor = idx.min(self.chars.len()); @@ -341,9 +241,6 @@ impl CmdEditor { } } - /// Char index of the start of the logical line containing `idx` — just after - /// the preceding `'\n'`, or `0`. A multi-line buffer (from a pasted command) - /// keeps its `'\n'`s inline; Home / Ctrl-A act within the current line. pub fn line_start(&self, idx: usize) -> usize { let mut s = idx.min(self.chars.len()); while s > 0 && self.chars[s - 1] != '\n' { @@ -352,8 +249,6 @@ impl CmdEditor { s } - /// Char index of the end of the logical line containing `idx` — the next - /// `'\n'`, or the buffer end. pub fn line_end(&self, idx: usize) -> usize { let mut e = idx.min(self.chars.len()); while e < self.chars.len() && self.chars[e] != '\n' { @@ -362,26 +257,18 @@ impl CmdEditor { e } - /// Move to the start of the current logical line (Home / Ctrl-A). On a - /// single-line buffer this is column 0, unchanged. pub fn move_home(&mut self) { self.cursor = self.line_start(self.cursor); } - /// Place the cursor at char index `idx` (clamped to the line length). Used to - /// reposition the caret from a mouse click. pub fn set_cursor(&mut self, idx: usize) { self.cursor = idx.min(self.chars.len()); } - /// Move to the end of the current logical line (End / Ctrl-E). On a - /// single-line buffer this is the buffer end, unchanged. pub fn move_end(&mut self) { self.cursor = self.line_end(self.cursor); } - /// Move left to the start of the previous word (skip trailing whitespace, then - /// the word). Word = run of non-whitespace. pub fn move_word_left(&mut self) { while self.cursor > 0 && self.chars[self.cursor - 1].is_whitespace() { self.cursor -= 1; @@ -391,7 +278,6 @@ impl CmdEditor { } } - /// Move right to the end of the next word. pub fn move_word_right(&mut self) { let n = self.chars.len(); while self.cursor < n && self.chars[self.cursor].is_whitespace() { @@ -402,29 +288,18 @@ impl CmdEditor { } } - /// Shift the selection anchor to account for the removal of chars `[s, e)`, - /// exactly as any editor adjusts marks across an edit: an anchor past the - /// hole moves left by its width, one inside collapses to its start. Without - /// this, a range delete that doesn't reach the buffer end leaves the anchor - /// pointing at *shifted* text — `selection()`'s clamp then reports a - /// phantom selection over chars the user never selected (and ⌘C copies it). fn shift_anchor_for_removal(&mut self, s: usize, e: usize) { if let Some(a) = self.anchor { self.anchor = Some(if a <= s { a } else { a.max(e) - (e - s) }); } } - /// Remove `s..e` and stash it as the kill ring's contents. The four chords - /// below are readline *kills*, not deletes: what they take is meant to come - /// back out under [`Self::yank`]. fn kill_range(&mut self, s: usize, e: usize) { self.kill = self.chars[s..e].iter().collect(); self.chars.drain(s..e); self.shift_anchor_for_removal(s, e); } - /// Delete the word after the cursor (Alt+Delete): skip following whitespace, - /// then the word. pub fn delete_word_right(&mut self) { self.checkpoint(); let n = self.chars.len(); @@ -438,7 +313,6 @@ impl CmdEditor { self.kill_range(self.cursor, e); } - /// Delete the word before the cursor (Ctrl+W / Alt+Backspace). pub fn delete_word_left(&mut self) { self.checkpoint(); let end = self.cursor; @@ -446,7 +320,6 @@ impl CmdEditor { self.kill_range(self.cursor, end); } - /// Delete from the cursor to the start of the line (Ctrl+U / Cmd+Backspace). pub fn delete_to_start(&mut self) { self.checkpoint(); let end = self.cursor; @@ -454,15 +327,12 @@ impl CmdEditor { self.kill_range(0, end); } - /// Delete from the cursor to the end of the line (Ctrl+K). pub fn delete_to_end(&mut self) { self.checkpoint(); let end = self.chars.len(); self.kill_range(self.cursor, end); } - /// Reinsert the most recent kill at the cursor (Ctrl+Y). A no-op — undo - /// checkpoint included — when nothing has been killed yet. pub fn yank(&mut self) { if self.kill.is_empty() { return; @@ -472,7 +342,6 @@ impl CmdEditor { self.kill = kill; } - /// Clear the line and reset the cursor and undo history (after submit). pub fn clear(&mut self) { self.chars.clear(); self.cursor = 0; @@ -481,8 +350,6 @@ impl CmdEditor { self.redo.clear(); } - /// Replace the whole line, putting the cursor at the end. Used by history - /// recall and completion acceptance. pub fn set(&mut self, text: &str) { self.checkpoint(); self.chars = text.chars().collect(); @@ -490,9 +357,6 @@ impl CmdEditor { self.anchor = None; } - /// Replace the whole line with `text` and place the cursor at char index - /// `cursor` (clamped). Used to apply a completion built against a saved - /// original line, and to restore that original on cancel. pub fn set_with_cursor(&mut self, text: &str, cursor: usize) { self.checkpoint(); self.chars = text.chars().collect(); @@ -514,21 +378,17 @@ mod tests { #[test] fn prepend_str_keeps_caret_and_selection_on_their_chars() { - // Adopting gap typeahead: the seed was typed chronologically *before* - // whatever is already in the editor, so it lands at the start while - // the caret (and any selection) stays on the characters it was on. - let mut e = ed("etty", 2); // caret between "et" and "ty" + let mut e = ed("etty", 2); e.prepend_str("cd g"); assert_eq!(e.text(), "cd getty"); assert_eq!(e.cursor(), 6, "caret still between 'et' and 'ty'"); - // One undo removes the adopted seed again. e.undo(); assert_eq!(e.text(), "etty"); let mut e = ed("tty", 3); e.set_cursor(1); e.begin_selection(); - e.extend_to(3); // selects "ty" + e.extend_to(3); e.prepend_str("ge"); assert_eq!(e.text(), "getty"); assert_eq!(e.selection(), Some((3, 5)), "selection still covers 'ty'"); @@ -559,7 +419,6 @@ mod tests { assert_eq!((e.text().as_str(), e.cursor()), ("ac", 1)); e.delete(); assert_eq!((e.text().as_str(), e.cursor()), ("a", 1)); - // Backspace at start is a no-op. let mut s = ed("x", 0); s.backspace(); assert_eq!(s.text(), "x"); @@ -571,11 +430,11 @@ mod tests { e.move_left(); assert_eq!(e.cursor(), 0); e.move_left(); - assert_eq!(e.cursor(), 0); // clamped + assert_eq!(e.cursor(), 0); e.move_end(); assert_eq!(e.cursor(), 2); e.move_right(); - assert_eq!(e.cursor(), 2); // clamped + assert_eq!(e.cursor(), 2); e.move_home(); assert_eq!(e.cursor(), 0); } @@ -584,17 +443,15 @@ mod tests { fn word_motion_and_delete() { let mut e = ed("git push origin", 15); e.move_word_left(); - assert_eq!(e.cursor(), 9); // start of "origin" + assert_eq!(e.cursor(), 9); e.move_word_left(); - assert_eq!(e.cursor(), 4); // start of "push" + assert_eq!(e.cursor(), 4); let mut d = ed("git push origin", 15); d.delete_word_left(); assert_eq!(d.text(), "git push "); assert_eq!(d.cursor(), 9); } - /// The four readline *kill* chords stash what they removed so ⌃Y can put it - /// back; the ring holds the most recent kill only. #[test] fn kills_fill_the_kill_buffer_and_yank_puts_it_back() { let mut e = ed("git push origin", 15); @@ -623,8 +480,6 @@ mod tests { assert_eq!(d.text(), "git push origin"); } - /// A plain character delete is not a kill — readline keeps the two apart, - /// so backspacing must not clobber the word ⌃W stashed a moment ago. #[test] fn character_deletes_leave_the_kill_buffer_alone() { let mut e = ed("git push origin", 15); @@ -636,8 +491,6 @@ mod tests { assert_eq!(e.text(), "git pushorigin"); } - /// Nothing killed yet: ⌃Y leaves the line and the caret exactly as they - /// were rather than inserting an empty string. #[test] fn yank_without_a_kill_does_nothing() { let mut e = ed("hello", 3); @@ -658,11 +511,11 @@ mod tests { #[test] fn multibyte_byte_offset() { let mut e = CmdEditor::new(); - e.insert_str("你好"); // 2 chars, 6 bytes + e.insert_str("你好"); assert_eq!(e.cursor(), 2); assert_eq!(e.cursor_byte(), 6); e.move_left(); - assert_eq!(e.cursor_byte(), 3); // after first char (3 bytes) + assert_eq!(e.cursor_byte(), 3); e.backspace(); assert_eq!(e.text(), "好"); } @@ -680,14 +533,14 @@ mod tests { e.set_cursor(2); assert_eq!(e.cursor(), 2); e.set_cursor(99); - assert_eq!(e.cursor(), 5); // clamped to len + assert_eq!(e.cursor(), 5); } #[test] fn selection_basics_and_delete() { let mut e = ed("hello world", 0); e.begin_selection(); - e.set_cursor(5); // select "hello" + e.set_cursor(5); assert_eq!(e.selection(), Some((0, 5))); assert_eq!(e.selected_text().as_deref(), Some("hello")); assert!(e.delete_selection()); @@ -699,19 +552,18 @@ mod tests { fn typing_replaces_selection() { let mut e = ed("abc def", 0); e.begin_selection(); - e.set_cursor(3); // select "abc" + e.set_cursor(3); e.insert_str("XY"); assert_eq!((e.text().as_str(), e.cursor()), ("XY def", 2)); assert_eq!(e.selection(), None); } - /// The default word-separator set (mirrors `Config::word_separators`). const SEPS: &str = ",│`|:\"' ()[]{}<>\t"; #[test] fn select_word_and_all() { let mut e = ed("git push origin", 6); - e.select_word_at(6, SEPS, true); // cursor on "push" + e.select_word_at(6, SEPS, true); assert_eq!(e.selected_text().as_deref(), Some("push")); e.select_all(); assert_eq!(e.selection(), Some((0, 15))); @@ -719,15 +571,13 @@ mod tests { #[test] fn select_word_stops_at_separators() { - // Quotes and commas bound a word; a separator char is its own word. let mut e = ed("echo 'a,b'", 0); - e.select_word_at(6, SEPS, true); // on "a" + e.select_word_at(6, SEPS, true); assert_eq!(e.selected_text().as_deref(), Some("a")); - e.select_word_at(7, SEPS, true); // on the comma itself + e.select_word_at(7, SEPS, true); assert_eq!(e.selected_text().as_deref(), Some(",")); - e.select_word_at(5, SEPS, true); // on the opening quote: pairs to the close + e.select_word_at(5, SEPS, true); assert_eq!(e.selected_text().as_deref(), Some("'a,b'")); - // `/ . - _ =` are not separators: a path stays one word. let mut e = ed("cat ./a-b/c_d.txt", 0); e.select_word_at(8, SEPS, true); assert_eq!(e.selected_text().as_deref(), Some("./a-b/c_d.txt")); @@ -738,27 +588,22 @@ mod tests { let mut e = ed("abcdef", 2); e.extend_to(5); assert_eq!(e.selection(), Some((2, 5))); - e.extend_to(0); // drag back past the anchor + e.extend_to(0); assert_eq!(e.selection(), Some((0, 2))); } #[test] fn extend_word_to_grows_by_whole_words_both_directions() { - // Double-click "push" (chars 4..8), then drag over later/earlier words. let mut e = ed("git push origin main", 4); e.select_word_at(6, SEPS, true); - let (s, a) = e.selection().unwrap(); // (4, 8) == "push" + let (s, a) = e.selection().unwrap(); assert_eq!((s, a), (4, 8)); - // Drag forward into "origin": selection reaches that word's far edge. e.extend_word_to(s, a, 10, SEPS, true); assert_eq!(e.selected_text().as_deref(), Some("push origin")); - // Drag on into "main": grows to its end. e.extend_word_to(s, a, 18, SEPS, true); assert_eq!(e.selected_text().as_deref(), Some("push origin main")); - // Drag backward before the anchor word into "git": anchor flips to the - // word's far edge, selection covers "git push". e.extend_word_to(s, a, 1, SEPS, true); assert_eq!(e.selected_text().as_deref(), Some("git push")); } @@ -776,7 +621,6 @@ mod tests { assert_eq!(e.text(), "a"); e.redo(); assert_eq!(e.text(), "ab"); - // A fresh edit clears the redo stack. e.insert_str("X"); e.redo(); assert_eq!(e.text(), "abX"); @@ -784,33 +628,26 @@ mod tests { #[test] fn no_op_edit_does_not_swallow_the_first_undo() { - // A no-op deletion (Backspace with the caret at column 0) used to push a - // checkpoint equal to the current buffer, so the next Undo was a dead press - // that "restored" the same text instead of undoing the real edit before it. - let mut e = ed("x", 0); // buffer "x"; one real edit sits on the undo stack - e.backspace(); // no-op: nothing before the caret - e.undo(); // must undo the real insert ("x" -> ""), not the phantom no-op + let mut e = ed("x", 0); + e.backspace(); + e.undo(); assert_eq!(e.text(), ""); } #[test] fn no_op_edit_between_real_edits_is_not_a_dead_undo_step() { - // Same defect via a different no-op path (Ctrl-K at end of line) sitting - // between two real edits: one Undo must still step back over a real edit. let mut e = CmdEditor::new(); e.insert_str("a"); - e.insert_str("b"); // buffer "ab", caret at end - e.delete_to_end(); // no-op: the caret is already at the end + e.insert_str("b"); + e.delete_to_end(); e.undo(); assert_eq!(e.text(), "a"); } #[test] fn undo_restores_the_pre_edit_cursor_position() { - // A mid-line edit then Undo puts the caret back where the edit began, - // not at the end of the line. let mut e = ed("git push", 3); - e.insert_str("XY"); // "gitXY push", caret 5 + e.insert_str("XY"); assert_eq!((e.text().as_str(), e.cursor()), ("gitXY push", 5)); e.undo(); assert_eq!((e.text().as_str(), e.cursor()), ("git push", 3)); @@ -820,19 +657,13 @@ mod tests { #[test] fn select_word_at_snaps_left_from_whitespace_and_clamps() { - // A double-click on the gap right after a word snaps left and selects - // that word — the same left-scan that makes a double-click at the end - // of the line select the last word. let mut e = ed("ab cd", 0); - e.select_word_at(2, SEPS, true); // the space between the words + e.select_word_at(2, SEPS, true); assert_eq!(e.selected_text().as_deref(), Some("ab")); - // Index at/past the end selects the trailing word, clamped. e.select_word_at(99, SEPS, true); assert_eq!(e.selected_text().as_deref(), Some("cd")); - // On a gap wider than one cell there is no adjacent word to the left of - // the clicked cell: the empty range collapses to no selection. let mut e = ed("ab cd", 0); - e.select_word_at(3, SEPS, true); // second space: both neighbours are whitespace + e.select_word_at(3, SEPS, true); assert_eq!(e.selection(), None); } @@ -842,7 +673,6 @@ mod tests { e.clear(); assert!(e.is_empty()); assert_eq!(e.cursor(), 0); - // Undo after clear (post-submit) must not resurrect the shipped line. e.undo(); assert!(e.is_empty()); } @@ -857,29 +687,17 @@ mod tests { #[test] fn forward_word_delete_with_selection_leaves_no_out_of_range_slice() { - // Shift-select " cd" leftward (anchor=5, cursor=2), then Alt+Delete - // (delete_word_right) drains exactly that region, shrinking "ab cd" -> "ab" - // but historically leaving the anchor at 5. selected_text() (Cmd+C / Cmd+X) - // then sliced chars[2..5] on a length-2 Vec and panicked, crashing the app. let mut e = ed("ab cd", 5); e.extend_to(2); assert_eq!(e.selection(), Some((2, 5))); e.delete_word_right(); assert_eq!(e.text(), "ab"); - // RED before the fix: selection() returns Some((2, 5)) and selected_text() - // panics slicing out of range. GREEN: the stale anchor is clamped away. assert_eq!(e.selection(), None); assert_eq!(e.selected_text(), None); } #[test] fn mid_buffer_word_delete_shifts_the_anchor_instead_of_faking_a_selection() { - // Regression: shift-select "def" leftward in "abc def x" (anchor=7, - // cursor=4), then Alt+Delete removes exactly that word *mid-buffer*. - // Clamping alone left anchor=7 → clamped to 6 → a phantom (4,6) - // selection over " x", text the user never selected (and ⌘C copied). - // Shifting the anchor across the removed range collapses it onto the - // cursor: no selection survives the deletion of its own text. let mut e = ed("abc def x", 7); e.extend_to(4); assert_eq!(e.selected_text().as_deref(), Some("def")); @@ -891,22 +709,17 @@ mod tests { #[test] fn deletions_before_a_selection_keep_it_on_the_same_text() { - // A range delete strictly before the selection shifts it left as a - // block, so it keeps covering the same characters. let mut e = ed("one two THREE", 13); - e.extend_to(8); // select "THREE" (anchor=13, cursor=8) + e.extend_to(8); assert_eq!(e.selected_text().as_deref(), Some("THREE")); - e.set_cursor(8); // collapse cursor at the selection start… keep anchor - e.delete_to_start(); // Ctrl+U wipes "one two " before it + e.set_cursor(8); + e.delete_to_start(); assert_eq!(e.text(), "THREE"); assert_eq!(e.selected_text().as_deref(), Some("THREE")); } #[test] fn forward_word_delete_preserves_a_selection_it_did_not_touch() { - // Rightward selection "ab" (anchor=0, cursor=2); Alt+Delete removes the - // *following* word (" cd"), which doesn't overlap the selection, so the - // still-valid "ab" selection must survive (clamping is a no-op here). let mut e = ed("ab cd", 0); e.extend_to(2); assert_eq!(e.selection(), Some((0, 2))); @@ -917,24 +730,19 @@ mod tests { #[test] fn home_end_are_logical_line_relative_in_a_multiline_buffer() { - // A pasted multi-line command keeps its '\n's inline; Home/End act within - // the line the caret sits on, not the whole buffer. - let mut e = ed("one\ntwo\nthree", 5); // caret in "two" (after 't', 'w') + let mut e = ed("one\ntwo\nthree", 5); e.move_home(); assert_eq!(e.cursor(), 4, "start of the 'two' line"); e.move_end(); assert_eq!(e.cursor(), 7, "end of the 'two' line (before the '\\n')"); - // First line: Home is column 0, End is just before the first '\n'. e.set_cursor(1); e.move_home(); assert_eq!(e.cursor(), 0); e.move_end(); assert_eq!(e.cursor(), 3); - // Last line has no trailing '\n': End is the buffer end. e.set_cursor(10); e.move_end(); assert_eq!(e.cursor(), 13); - // A single-line buffer is unaffected: Home/End are the buffer edges. let mut s = ed("git push", 4); s.move_home(); assert_eq!(s.cursor(), 0); @@ -948,6 +756,6 @@ mod tests { e.set_with_cursor("git status", 3); assert_eq!((e.text().as_str(), e.cursor()), ("git status", 3)); e.set_with_cursor("hi", 99); - assert_eq!((e.text().as_str(), e.cursor()), ("hi", 2)); // clamped + assert_eq!((e.text().as_str(), e.cursor()), ("hi", 2)); } } diff --git a/src/terminal/completion.rs b/src/terminal/completion.rs index 1e81ecc9..c3677097 100644 --- a/src/terminal/completion.rs +++ b/src/terminal/completion.rs @@ -1,33 +1,8 @@ -//! A small, self-contained completion engine for the command editor — tty7's own -//! engine, not the shell's `compsys`. -//! -//! It offers three sources, each candidate carrying the exact char range it -//! replaces: -//! - **command** — builtins + `$PATH` executables, in command position; -//! - **path** — files / directories, elsewhere (replace just the word); -//! - **remote path** — the same, for a pane whose filesystem is on the far -//! end of an SSH connection. The listing itself is a network round-trip the -//! view owns, so this module only splits the word into a request -//! ([`remote_path_request`]) and turns the answer into candidates -//! ([`remote_path_candidates`]) — both pure, both unit-tested. -//! -//! History deliberately does *not* feed the menu: -//! whole-line recall belongs to the inline ghost text (frecency-ranked, cwd -//! aware — accepted with → / Ctrl+F) and Ctrl+R search. Mixing recalled lines -//! into the Tab menu buried the precise completions under near-duplicate path -//! variants of past commands. -//! -//! Pure and side-effect-free apart from reading the filesystem / `$PATH`, so the -//! word-parsing and path logic are unit-tested directly. - use std::collections::BTreeSet; use std::path::{Path, PathBuf}; use super::signature::{self, Arg, CmdNode, Signature}; -/// A word candidate before it's placed at a range. Signature-derived candidates -/// carry a `description` and possibly an `icon` (a raw Fig icon string — emoji or -/// `fig://…`); `$PATH` and path candidates carry neither. struct WordCand { text: String, kind: CandidateKind, @@ -36,7 +11,6 @@ struct WordCand { } impl WordCand { - /// A candidate with no signature metadata — the command and path sources. fn plain(text: String, kind: CandidateKind) -> Self { Self { text, @@ -47,36 +21,22 @@ impl WordCand { } } -/// What a completion candidate refers to — drives both the trailing `/` for -/// directories and the menu's leading icon. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CandidateKind { - /// A command name (builtin or `$PATH` executable). Command, - /// A directory. Dir, - /// A regular file. File, - /// A command flag / option (e.g. `--message`), from a command signature. Flag, - /// A subcommand or argument value, from a command signature. Value, } -/// A single completion candidate: the replacement text, its kind, and the char -/// range `[start, end)` in the original line that it replaces — just the word -/// under the cursor (`word_start..cursor`). #[derive(Debug, Clone, PartialEq, Eq)] pub struct Candidate { pub text: String, pub kind: CandidateKind, pub start: usize, pub end: usize, - /// A one-line hint shown in a second column — the flag/subcommand's - /// description from its command signature; `None` for path/command candidates. pub description: Option<String>, - /// Raw Fig icon string (emoji or `fig://…`) for signature candidates; the - /// view interprets it, falling back to a per-kind glyph. `None` otherwise. pub icon: Option<String>, } @@ -86,53 +46,27 @@ impl Candidate { } } -/// The result of completing at a cursor: the word candidates, each with its own -/// replacement range, plus any *dynamic* generators the position declares. -/// -/// Generators can't be run here — this module is pure and synchronous, while a -/// generator is a child process — so the sync candidates come back immediately -/// and each pending script rides along for the view to execute on a background -/// thread and [`CompletionSession::merge`] into the live menu. A position that -/// declares generators is a completion even when `candidates` is empty (an SSH -/// host list, a git branch list) — returning `Some` here is what stops the caller -/// from falling back to filesystem paths, the bug behind `ssh <Tab>` listing the -/// cwd (#51). #[derive(Debug)] pub struct Completion { pub candidates: Vec<Candidate>, pub pending: Vec<PendingGenerator>, } -/// A dynamic generator awaiting execution: the shell `script` (the spec's token -/// list joined with single spaces, ready for `/bin/sh -c`). The view runs it off -/// the main thread and merges its stdout-derived candidates into the open menu. #[derive(Debug, Clone, PartialEq, Eq)] pub struct PendingGenerator { pub script: String, } -/// Common shell builtins / keywords, offered in command position. Not exhaustive, -/// but covers what `$PATH` scanning misses (builtins aren't files). const BUILTINS: &[&str] = &[ "cd", "echo", "exit", "export", "pwd", "alias", "unalias", "source", "set", "unset", "history", "jobs", "fg", "bg", "kill", "which", "type", "read", "local", "return", "eval", "exec", "test", "true", "false", "printf", "let", "declare", "typeset", "shift", "trap", "wait", "umask", ]; -/// Cap on candidates returned, so a bare prefix that matches thousands of files -/// (or `$PATH` entries) can't blow up the UI or the cycle. const MAX_CANDIDATES: usize = 400; -/// Commands whose arguments are directories, never files. They have no Fig -/// signature (shell builtins), so the generic path fallback handles them — -/// which must not offer files (`cd tar` completing to `tar.exe` is never -/// right). const DIR_ONLY_COMMANDS: &[&str] = &["cd", "pushd", "popd", "rmdir"]; -/// The command name the cursor's word is an argument of: the first token of -/// the current simple command (after the last shell separator), reduced to its -/// basename so `/bin/rmdir` matches like `rmdir`. `None` when there is no -/// command token before the word. fn current_command(chars: &[char], word_start: usize) -> Option<String> { let prefix: String = chars[..word_start].iter().collect(); let seg_start = prefix @@ -147,18 +81,10 @@ fn current_command(chars: &[char], word_start: usize) -> Option<String> { (!base.is_empty()).then(|| base.to_string()) } -/// Compute completions for `line` at char position `cursor`, resolving relative -/// paths against `cwd`: command names in command position, filesystem paths -/// elsewhere. Returns `None` when there's nothing to offer. -/// -/// `cwd` is `None` when the pane has no directory on *this* machine — a remote -/// pane. Command completion still runs; everything that would touch the local -/// filesystem is skipped rather than answered from the wrong machine. pub fn complete(line: &str, cursor: usize, cwd: Option<&Path>) -> Option<Completion> { let chars: Vec<char> = line.chars().collect(); let cursor = cursor.min(chars.len()); - // The word under completion is the run of non-whitespace ending at the cursor. let mut word_start = cursor; while word_start > 0 && !chars[word_start - 1].is_whitespace() { word_start -= 1; @@ -169,30 +95,10 @@ pub fn complete(line: &str, cursor: usize, cwd: Option<&Path>) -> Option<Complet let (word_cands, pending) = if is_command && !word.contains('/') { (complete_command(&word), Vec::new()) } else { - // In argument position, prefer a per-command signature (flags, - // subcommands, typed args) when the command has one; otherwise fall - // back to filesystem paths. A signature slot that declares suggestions - // or generators owns the position: it returns `Some` (possibly with no - // sync candidates but pending scripts) rather than ceding to paths. - // - // A missing `cwd` means a remote pane, and it disables only the parts - // that read *this* machine: paths and generators (see - // [`complete_signature`]). The rest of a signature is static text — - // `git push`, `--verbose` — and is just as true on the remote, so it is - // still offered. Withholding it too would make every Tab in a remote - // pane a no-match, and a no-match hands the line to the shell, which - // costs the user the inline editor for that prompt. match complete_signature(&chars, word_start, &word, cwd) { Some(sig) => (sig.cands, sig.pending), None => match cwd { - // No signature and no local filesystem to fall back on. Offering - // this machine's names would insert them into a remote command - // line where they do not exist; returning nothing instead lets - // the caller hand the Tab to the remote's own completion, which - // can actually see that filesystem. None => (Vec::new(), Vec::new()), - // No signature: generic paths, narrowed to directories when - // the command only takes those (`cd`, `pushd`, …). Some(cwd) => { let dirs_only = current_command(&chars, word_start) .is_some_and(|c| DIR_ONLY_COMMANDS.contains(&c.as_str())); @@ -223,9 +129,6 @@ pub fn complete(line: &str, cursor: usize, cwd: Option<&Path>) -> Option<Complet } } -/// Command-name completion: builtins plus `$PATH` executables starting with -/// `word`. An empty word returns nothing (we don't dump every command on a bare -/// Tab in command position). Ordered by closeness. fn complete_command(word: &str) -> Vec<WordCand> { if word.is_empty() { return Vec::new(); @@ -259,9 +162,6 @@ fn complete_command(word: &str) -> Vec<WordCand> { .collect() } -/// Order strings by closeness to what the user typed: since every candidate -/// shares the typed prefix, the edit distance is just the length still to fill -/// in — so shorter completions come first, ties broken alphabetically. fn sort_by_closeness(items: &mut [String]) { items.sort_by(|a, b| { a.chars() @@ -271,10 +171,6 @@ fn sort_by_closeness(items: &mut [String]) { }); } -/// Order candidates in place by closeness — shorter completions first, ties -/// alphabetical — the same ordering path and signature completion use, applied -/// across the merged set so asynchronously-arriving generator results settle -/// into the menu's existing sort rather than piling up at the end. fn sort_candidates_by_closeness(cands: &mut [Candidate]) { cands.sort_by(|a, b| { a.text @@ -285,56 +181,22 @@ fn sort_candidates_by_closeness(cands: &mut [Candidate]) { }); } -/// What a path-position Tab in a remote pane needs listed on the *far side*, -/// produced by [`remote_path_request`] and consumed by -/// [`remote_path_candidates`] once the listing comes back. -/// -/// Split in two because the listing is a network round-trip: nothing here -/// touches a filesystem, so both halves stay pure and testable while the view -/// owns the async middle. #[derive(Debug, Clone, PartialEq, Eq)] pub struct RemotePathRequest { - /// Absolute directory to list on the remote. pub dir: String, - /// What an entry's name must start with to be offered. pub prefix: String, - /// The typed text up to and including the last `/`, re-prepended to every - /// candidate so the path the user typed is preserved (as [`complete_path`] - /// does locally). pub dir_part: String, - /// Char range in the line the candidates replace. pub word_start: usize, pub cursor: usize, - /// Drop file entries — the command only takes directories. pub dirs_only: bool, } -/// One entry of a remote directory listing, reduced to what completion cares -/// about. Keeps this module free of the daemon's SFTP protocol types; the view -/// converts (and is where "a symlink to a directory counts as a directory" -/// gets decided, since only the protocol knows the link target). #[derive(Debug, Clone, PartialEq, Eq)] pub struct RemoteEntry { pub name: String, pub is_dir: bool, } -/// The remote directory a path-position Tab wants listed, or `None` when the -/// caret isn't somewhere a remote path listing could help. -/// -/// `remote_cwd` is the pane's cwd *in the remote's namespace* — the caller must -/// have established that the pane really is remote. Declines: -/// - **command position** (a bare first word): those complete from `$PATH`, -/// and this machine's `$PATH` is the wrong answer for a remote anyway — -/// that's [`complete_command`]'s call, not a filesystem question. -/// - **`~`-prefixed words**: expanding one needs the remote's `$HOME`, which -/// no OSC reports. Declining hands the Tab to the remote shell, which can -/// expand it. -/// - a **relative `remote_cwd`**: nothing to resolve against. -/// -/// Separators are `/` only — deliberately not [`std::path::is_separator`], -/// which also accepts `\` on Windows. A Windows host talking to a POSIX remote -/// must not treat a backslash in the *remote's* path as a separator. pub fn remote_path_request( line: &str, cursor: usize, @@ -363,10 +225,6 @@ pub fn remote_path_request( Some(i) => (&word[..=i], &word[i + 1..]), None => ("", word.as_str()), }; - // An absolute `dir_part` stands alone; anything else resolves against the - // remote cwd. `.`/`..` inside the path are left for the far side to - // resolve — an SFTP server handles them, and we have no remote filesystem - // to normalize against here. let dir = if dir_part.starts_with('/') { dir_part.to_string() } else if dir_part.is_empty() { @@ -386,13 +244,6 @@ pub fn remote_path_request( }) } -/// Turn a remote directory listing into candidates for `req`. Mirrors -/// [`complete_path`]'s rules exactly — hidden entries only when the prefix asks -/// for them, `dirs_only` filtering, closeness ordering, the same cap — so a -/// remote Tab behaves like a local one. -/// -/// `.` and `..` are dropped: a local `read_dir` never yields them, and offering -/// them here would make the two panes feel different. pub fn remote_path_candidates(req: &RemotePathRequest, entries: &[RemoteEntry]) -> Vec<Candidate> { let mut out: Vec<Candidate> = Vec::new(); for entry in entries { @@ -429,16 +280,7 @@ pub fn remote_path_candidates(req: &RemotePathRequest, entries: &[RemoteEntry]) out } -/// Filesystem path completion. Splits `word` into the directory part (kept -/// verbatim in each candidate so the typed path prefix is preserved) and the -/// final-segment prefix to match in that directory. Ordered by closeness. -/// `dirs_only` drops file entries — for commands / argument slots that only -/// accept directories. fn complete_path(word: &str, cwd: &Path, dirs_only: bool) -> Vec<WordCand> { - // Split on the last path separator. `is_separator` is `/` on Unix and both - // `/` and `\` on Windows, so a `C:\Users\me\f`-style word splits correctly - // under the (future) Windows line editor; separators are ASCII so the byte - // slice boundaries are valid. let (dir_part, prefix) = match word.rfind(std::path::is_separator) { Some(i) => (&word[..=i], &word[i + 1..]), None => ("", word), @@ -451,16 +293,12 @@ fn complete_path(word: &str, cwd: &Path, dirs_only: bool) -> Vec<WordCand> { let mut out: Vec<WordCand> = Vec::new(); for entry in rd.flatten() { let name = entry.file_name().to_string_lossy().into_owned(); - // Hidden entries only when the prefix explicitly starts with a dot. if name.starts_with('.') && !prefix.starts_with('.') { continue; } if !name.starts_with(prefix) { continue; } - // Follow symlinks when classifying: a symlink to a directory must count - // as one (it both takes the trailing `/` and survives a dirs-only - // filter — `cd` into a linked dir is routine). let is_dir = entry .file_type() .is_ok_and(|t| t.is_dir() || (t.is_symlink() && entry.path().is_dir())); @@ -487,41 +325,17 @@ fn complete_path(word: &str, cwd: &Path, dirs_only: bool) -> Vec<WordCand> { out } -/// The sync half of a signature-driven completion: candidates ready now, plus -/// the dynamic generators whose output the view will merge in later. Returned as -/// one unit so the caller can tell "this slot is a completion (don't fall back to -/// paths)" from "no signature here" via the `Option` around it. struct SigResult { cands: Vec<WordCand>, pending: Vec<PendingGenerator>, } -/// Signature-driven completion in argument position. Tokenizes the text before -/// the word into an argv, and — if the current command has a signature — offers -/// flags, subcommands, or typed-argument suggestions for the cursor's position, -/// alongside any dynamic generators that position declares. -/// -/// Returns `None` (so the caller falls back to path completion) when the command -/// has no signature, or when the position yields nothing useful and isn't a flag, -/// value, suggestion, or generator slot (so a bare argument still lists files). -/// A slot with generators returns `Some` even with zero sync candidates — its -/// results are still inbound, and falling back to paths there is exactly #51. -/// -/// `cwd` is `None` for a remote pane, which suppresses the two things that would -/// answer with *this* machine's state: path completion, and generators. The -/// generator exclusion matters more than it looks — a generator is a local -/// `/bin/sh -c` (see [`super::generator`]), so `git checkout <Tab>` against a -/// remote would offer the branches of whatever repo the *local* cwd happens to -/// sit in. Wrong filenames are obvious when they fail; wrong branch names look -/// plausible and land in a real command. fn complete_signature( chars: &[char], word_start: usize, word: &str, cwd: Option<&Path>, ) -> Option<SigResult> { - // Only the current simple command matters: start after the last shell - // separator so `foo | git <tab>` completes `git`, not `foo`. let prefix: String = chars[..word_start].iter().collect(); let seg_start = prefix .rfind(['|', '&', ';', '\n', '(']) @@ -533,8 +347,6 @@ fn complete_signature( let (node, pending_value) = walk_signature(&sig, &tokens[1..]); - // Flag position: options of the current node whose spelling extends `word`. - // Flags never carry generators. if word.starts_with('-') { let mut out = Vec::new(); for opt in node.options() { @@ -558,7 +370,6 @@ fn complete_signature( }); } - // Value position: the previous token was an option taking an argument. if let Some(arg) = pending_value { let mut out = Vec::new(); push_arg_suggestions(&mut out, arg, word); @@ -571,9 +382,6 @@ fn complete_signature( Some(_) => collect_generators(arg), None => Vec::new(), }; - // A slot that declares suggestions or generators owns the position even - // when nothing matches yet; only a truly featureless value slot cedes to - // path completion. if out.is_empty() && pending.is_empty() && arg.suggestions.is_empty() { return None; } @@ -583,7 +391,6 @@ fn complete_signature( }); } - // Fresh token: subcommands of the current node plus its first positional arg. let mut out = Vec::new(); for sub in node.subcommands() { if sub.hidden { @@ -612,8 +419,6 @@ fn complete_signature( if cwd.is_some() { pending = collect_generators(arg); } - // Suggestions/generators mean this positional owns the slot: don't cede - // to paths just because the sync list came back empty. claims_slot = !arg.suggestions.is_empty() || !pending.is_empty(); } if out.is_empty() && !claims_slot { @@ -626,10 +431,6 @@ fn complete_signature( } } -/// Join each of an argument's dynamic generators into a runnable `/bin/sh -c` -/// command string. The converter word-split original string scripts, so joining -/// with single spaces and letting the shell re-parse restores pipes, quoting, -/// and `bash -c "…"`-style entries. fn collect_generators(arg: &Arg) -> Vec<PendingGenerator> { arg.generators .iter() @@ -640,17 +441,12 @@ fn collect_generators(arg: &Arg) -> Vec<PendingGenerator> { .collect() } -/// Walk the argv after the command name, descending into matched subcommands and -/// skipping options (and the value tokens of value-taking ones). Returns the -/// deepest node reached, and — when the final prior token is a value-taking -/// option — the argument the cursor is now positioned to complete. fn walk_signature<'a>(sig: &'a Signature, rest: &[&str]) -> (&'a dyn CmdNode, Option<&'a Arg>) { let mut node: &dyn CmdNode = sig; let mut i = 0; while i < rest.len() { let tok = rest[i]; if tok.starts_with('-') { - // Skip the flag, and its value token when it takes one inline-`=`-free. if node.find_option(tok).is_some_and(|o| o.takes_arg()) && !tok.contains('=') { i += 2; } else { @@ -661,11 +457,9 @@ fn walk_signature<'a>(sig: &'a Signature, rest: &[&str]) -> (&'a dyn CmdNode, Op if let Some(sub) = node.find_subcommand(tok) { node = sub; } - // A non-matching bare token is a positional arg; the node is unchanged. i += 1; } - // Is the cursor sitting on a value-taking option's value? let pending = rest.last().and_then(|last| { (last.starts_with('-') && !last.contains('=')) .then(|| node.find_option(last)) @@ -676,7 +470,6 @@ fn walk_signature<'a>(sig: &'a Signature, rest: &[&str]) -> (&'a dyn CmdNode, Op (node, pending) } -/// Append an argument's static value suggestions matching `word`. fn push_arg_suggestions(out: &mut Vec<WordCand>, arg: &Arg, word: &str) { for sug in &arg.suggestions { for name in &sug.names { @@ -692,8 +485,6 @@ fn push_arg_suggestions(out: &mut Vec<WordCand>, arg: &Arg, word: &str) { } } -/// Dedupe by replacement text and order by closeness (shorter first, then -/// alphabetical) — the same ordering path completion uses. fn finish(mut out: Vec<WordCand>) -> Vec<WordCand> { out.sort_by(|a, b| { a.text @@ -706,8 +497,6 @@ fn finish(mut out: Vec<WordCand>) -> Vec<WordCand> { out } -/// Resolve the directory portion of a path word to an absolute directory to list: -/// handles `~` expansion, absolute paths, and paths relative to `cwd`. fn resolve_dir(dir_part: &str, cwd: &Path) -> PathBuf { if dir_part.is_empty() { return cwd.to_path_buf(); @@ -726,39 +515,20 @@ fn resolve_dir(dir_part: &str, cwd: &Path) -> PathBuf { if p.is_absolute() { p } else { cwd.join(p) } } -/// The user's home directory: `$HOME` on Unix, falling back to `%USERPROFILE%` -/// on Windows (where `HOME` is usually unset). fn home_dir() -> Option<PathBuf> { std::env::var_os("HOME") .or_else(|| std::env::var_os("USERPROFILE")) .map(PathBuf::from) } -/// One open completion menu: a *picker* over the candidates gathered -/// when it opened. Moving the highlight (Tab / ↑ / ↓) never touches the editor -/// line — the line changes only when a candidate is accepted (Enter) or when Tab -/// fills the candidates' common prefix. Typing re-filters the same candidate set -/// via [`CompletionSession::refilter`]; the session ends once the word stops -/// extending the one it opened on. Fields are `pub(super)` so the terminal view -/// can render the menu. pub(super) struct CompletionSession { - /// Char index where the word under completion starts — the fixed left edge - /// of the range an accept replaces (the right edge is the live caret). pub(super) word_start: usize, - /// The word as typed when the menu opened (before any common-prefix fill). - /// Backspacing below it closes the menu. pub(super) open_word: String, - /// Every candidate from open time; `filtered` holds indices into this. pub(super) all: Vec<Candidate>, - /// Indices into `all` still prefix-matching the live word, in order. pub(super) filtered: Vec<usize>, - /// Highlighted row (an index into `filtered`). pub(super) index: Option<usize>, } -/// A splice to apply to the command editor: replace chars `[start, end)` of -/// `orig` with `text`. Used by the view's accept / prefix-fill paths; kept -/// separate so the pure string edit is testable without a live editor. pub(super) struct Replacement { pub(super) orig: String, pub(super) start: usize, @@ -767,9 +537,6 @@ pub(super) struct Replacement { } impl Replacement { - /// Perform the splice: returns the new line and the caret position (just after - /// the inserted text). Char-indexed and clamped, so out-of-range candidate - /// offsets can never panic. pub(super) fn apply(&self) -> (String, usize) { let mut chars: Vec<char> = self.orig.chars().collect(); let start = self.start.min(chars.len()); @@ -782,8 +549,6 @@ impl Replacement { } impl CompletionSession { - /// Open a menu over `all` with the first row highlighted (a default - /// preselection, so a bare Enter accepts the top pick). pub(super) fn new(word_start: usize, open_word: String, all: Vec<Candidate>) -> Self { let filtered = (0..all.len()).collect(); Self { @@ -795,15 +560,12 @@ impl CompletionSession { } } - /// The highlighted candidate, if any. pub(super) fn selected(&self) -> Option<&Candidate> { self.index .and_then(|i| self.filtered.get(i)) .map(|&i| &self.all[i]) } - /// Move the highlight to the next (`forward`) or previous row, wrapping. - /// Selection is visual only — the editor line changes on accept. pub(super) fn select(&mut self, forward: bool) { let n = self.filtered.len(); if n == 0 { @@ -817,10 +579,6 @@ impl CompletionSession { }); } - /// Re-filter for the live `word`. Returns `false` when the menu should - /// close: the word no longer extends the one it opened on (backspaced past - /// it) or nothing matches any more. A highlighted candidate that survives - /// the filter keeps its highlight; one filtered away falls back to the top. pub(super) fn refilter(&mut self, word: &str) -> bool { if !word.starts_with(self.open_word.as_str()) { return false; @@ -837,19 +595,6 @@ impl CompletionSession { true } - /// Merge asynchronously-produced generator candidates into the open menu. - /// - /// Called on the main thread when a background generator finishes: dedupe the - /// new candidates by text against everything already gathered, append the - /// survivors, re-sort the whole set by closeness, then re-run the prefix - /// filter against `live_word` — the word as it stands *now*, which may have - /// grown while the generator ran. A highlighted candidate that survives the - /// re-filter keeps its highlight (matched by text, since the sort renumbers - /// `all`); otherwise the top row takes over. - /// - /// Unlike [`Self::refilter`] this never signals "close": a generator whose - /// results don't match the live word (or that returned nothing) just leaves - /// the menu as it was — the session lives or dies on the user's own edits. pub(super) fn merge(&mut self, new: Vec<Candidate>, live_word: &str) { let selected_text = self .index @@ -876,8 +621,6 @@ impl CompletionSession { }; } - /// Longest common prefix (in chars) of the filtered candidates — what Tab - /// fills before it starts moving the highlight. pub(super) fn common_prefix(&self) -> Option<String> { let mut texts = self.filtered.iter().map(|&i| self.all[i].text.as_str()); let mut lcp: Vec<char> = texts.next()?.chars().collect(); @@ -911,8 +654,6 @@ mod tests { } } - /// The candidate texts `complete` returns for `line` with the cursor at the - /// end, or an empty vec when it offers nothing. fn texts(line: &str) -> Vec<String> { complete(line, line.chars().count(), Some(Path::new("/"))) .map(|c| c.candidates.into_iter().map(|c| c.text).collect()) @@ -924,7 +665,6 @@ mod tests { let t = texts("git "); assert!(t.iter().any(|s| s == "commit"), "git subcommands: {t:?}"); assert!(t.iter().any(|s| s == "status")); - // Descriptions ride along for the menu's second column. let c = complete("git ", 4, Some(Path::new("/"))).unwrap(); let commit = c.candidates.iter().find(|c| c.text == "commit").unwrap(); assert_eq!(commit.kind, CandidateKind::Value); @@ -951,7 +691,6 @@ mod tests { #[test] fn signature_resolves_nested_subcommands() { - // docker compose was grafted in via loadSpec; its subcommands complete. let t = texts("docker compose "); assert!( t.iter().any(|s| s == "up"), @@ -961,10 +700,6 @@ mod tests { #[test] fn generator_arg_pends_scripts_and_suppresses_path_fallback() { - // `git checkout <arg>` declares branch/tag generators (dynamic) with no - // `filepaths` template. Pre-#51 the empty-static-match path fell through - // to filesystem completion and listed the cwd; now the slot owns the - // position — it returns the generator scripts and no path candidates. let dir = temp_tree("gen-checkout", &[("sentinel.txt", false), ("subdir", true)]); let line = "git checkout "; let c = complete(line, line.chars().count(), Some(dir.as_path())) @@ -980,7 +715,6 @@ mod tests { "one pending script is the joined git-branch listing: {:?}", c.pending ); - // Crucially, no filesystem entry from the cwd leaked into the menu. assert!( c.candidates .iter() @@ -992,8 +726,6 @@ mod tests { #[test] fn generator_script_tokens_join_with_single_spaces() { - // The converter word-split original string scripts; joining restores a - // single `/bin/sh -c` command. let c = complete("git checkout ", 13, Some(Path::new("/"))).unwrap(); let branch = c .pending @@ -1008,26 +740,19 @@ mod tests { #[test] fn merge_dedupes_resorts_and_refilters_to_live_word() { - // Open on "f" with one static candidate, then a generator lands two - // branches; the merged set is deduped, closeness-sorted, and filtered to - // the live word. let mut s = CompletionSession::new( 0, "f".into(), vec![cand("feature", CandidateKind::Value, 0, 1)], ); let new = vec![ - cand("feature", CandidateKind::Value, 0, 1), // dup by text — dropped + cand("feature", CandidateKind::Value, 0, 1), cand("fix", CandidateKind::Value, 0, 1), - cand("main", CandidateKind::Value, 0, 1), // filtered out by live word "f" + cand("main", CandidateKind::Value, 0, 1), ]; s.merge(new, "f"); let texts: Vec<&str> = s.filtered.iter().map(|&i| s.all[i].text.as_str()).collect(); - // "main" gone (doesn't start with "f"); "feature" not duplicated; closeness - // puts the shorter "fix" first. assert_eq!(texts, vec!["fix", "feature"]); - // The default open-highlight was on "feature"; it survives the merge and - // follows the candidate to its new sorted slot rather than snapping to top. assert_eq!(s.selected().unwrap().text, "feature"); } @@ -1041,17 +766,14 @@ mod tests { cand("branch-b", CandidateKind::Value, 0, 1), ], ); - s.select(true); // highlight "branch-b" + s.select(true); assert_eq!(s.selected().unwrap().text, "branch-b"); s.merge(vec![cand("bugfix", CandidateKind::Value, 0, 1)], "b"); - // The highlighted candidate survives the merge/re-sort and keeps focus. assert_eq!(s.selected().unwrap().text, "branch-b"); } #[test] fn dir_only_commands_complete_only_directories() { - // `cd tar` must offer `target/`, never `tar.gz` (#136) — same for the - // other dir-only builtins, and for absolute spellings by basename. let dir = temp_tree("dironly", &[("target", true), ("tar.gz", false)]); let only_dirs = |line: &str| { complete(line, line.chars().count(), Some(dir.as_path())) @@ -1061,11 +783,8 @@ mod tests { assert_eq!(only_dirs("cd tar"), vec!["target"]); assert_eq!(only_dirs("pushd tar"), vec!["target"]); assert_eq!(only_dirs("/bin/rmdir tar"), vec!["target"]); - // Only the current simple command counts: `cd` after a pipe governs. assert_eq!(only_dirs("foo | cd tar"), vec!["target"]); - // A bare argument slot narrows too. assert_eq!(only_dirs("cd "), vec!["target"]); - // A generic command keeps offering files alongside directories. let both = only_dirs("frobnicate tar"); assert!(both.contains(&"tar.gz".to_string()), "{both:?}"); assert!(both.contains(&"target".to_string()), "{both:?}"); @@ -1073,7 +792,6 @@ mod tests { #[test] fn unknown_command_falls_back_to_paths() { - // A command with no signature still path-completes (no panic, no menu here). let dir = temp_tree("fallback", &[("readme.md", false)]); let c = complete( "frobnicate read", @@ -1121,28 +839,25 @@ mod tests { #[test] fn select_moves_the_highlight_and_wraps_without_touching_candidates() { let mut s = session(&["aa", "ab", "ac"]); - assert_eq!(s.index, Some(0)); // first row preselected on open + assert_eq!(s.index, Some(0)); s.select(true); assert_eq!(s.index, Some(1)); s.select(true); s.select(true); - assert_eq!(s.index, Some(0)); // wraps forward + assert_eq!(s.index, Some(0)); s.select(false); - assert_eq!(s.index, Some(2)); // wraps backward + assert_eq!(s.index, Some(2)); assert_eq!(s.selected().unwrap().text, "ac"); } #[test] fn refilter_narrows_keeps_surviving_highlight_and_closes_when_stale() { let mut s = session(&["aa", "ab", "abc"]); - s.select(true); // highlight "ab" + s.select(true); assert!(s.refilter("ab")); - // "aa" filtered out; the highlighted "ab" survives and keeps its highlight. assert_eq!(s.filtered.len(), 2); assert_eq!(s.selected().unwrap().text, "ab"); - // A word that no longer extends the open word closes the menu… assert!(!s.refilter("")); - // …as does one nothing matches. let mut s = session(&["aa", "ab"]); assert!(!s.refilter("az")); } @@ -1150,7 +865,6 @@ mod tests { #[test] fn refilter_falls_back_to_the_top_when_the_highlight_is_filtered_away() { let mut s = session(&["aa", "ab", "abc"]); - // Highlight "aa", then type "ab" — "aa" drops out, top row takes over. assert_eq!(s.selected().unwrap().text, "aa"); assert!(s.refilter("ab")); assert_eq!(s.selected().unwrap().text, "ab"); @@ -1183,7 +897,7 @@ mod tests { let c = complete("ech", 3, Some(Path::new("/"))).unwrap(); let echo = c.candidates.iter().find(|c| c.text == "echo").unwrap(); assert_eq!(echo.kind, CandidateKind::Command); - assert_eq!((echo.start, echo.end), (0, 3)); // replaces the word "ech" + assert_eq!((echo.start, echo.end), (0, 3)); } #[test] @@ -1195,11 +909,10 @@ mod tests { let line = "cat a"; let c = complete(line, line.chars().count(), Some(dir.as_path())).unwrap(); let names: Vec<&str> = c.candidates.iter().map(|c| c.text.as_str()).collect(); - // Closeness order: assets(6) < apply.sh(8) < apple.txt(9). assert_eq!(names, vec!["assets", "apply.sh", "apple.txt"]); let assets = c.candidates.iter().find(|c| c.text == "assets").unwrap(); assert!(assets.is_dir()); - assert_eq!((assets.start, assets.end), (4, 5)); // the "a" word + assert_eq!((assets.start, assets.end), (4, 5)); } #[test] @@ -1238,66 +951,42 @@ mod tests { assert_eq!(names, vec!["xa", "xy", "xyz", "xyzzy"]); } - /// A remote pane has no local cwd. Path candidates must come back empty - /// rather than from tty7's own directory — inserting a local filename into - /// a remote command line names a file that isn't there. Command completion - /// is unaffected: it reads `$PATH`, not the cwd. #[test] fn a_remote_pane_completes_commands_but_never_local_paths() { let dir = temp_tree("remote", &[("only-here.txt", false), ("subdir", true)]); - // With a local cwd the file is offered... let c = complete("cat only", 8, Some(dir.as_path())).expect("local pane completes paths"); assert!(c.candidates.iter().any(|c| c.text.starts_with("only-here"))); - // ...and with none it is not, from the same line. assert!(complete("cat only", 8, None).is_none()); - // Nor does a bare argument position dump anything. assert!(complete("cat ", 4, None).is_none()); - // Command position still works — that source never touches the cwd. let c = complete("ech", 3, None).expect("command completion needs no cwd"); assert!(c.candidates.iter().any(|c| c.text == "echo")); } - /// The static half of a signature — subcommands, flags — describes the - /// *command*, not the machine, so it survives the loss of a local cwd. This - /// is what keeps Tab useful in a remote pane: a position with no candidates - /// hands the line to the shell (`handoff_tab_to_shell`), which costs the - /// user the inline editor until the next prompt, so answering "nothing" for - /// every `git <Tab>` was a real regression once remote panes gained an - /// editor at all. #[test] fn a_remote_pane_still_gets_a_signatures_static_candidates() { let c = complete("git ", 4, None).expect("subcommands need no filesystem"); assert!(c.candidates.iter().any(|c| c.text == "commit")); assert!(c.candidates.iter().any(|c| c.text == "push")); - // Prefix filtering works the same as it does locally. let c = complete("git ch", 6, None).expect("subcommands need no filesystem"); assert!(c.candidates.iter().any(|c| c.text == "checkout")); assert!(!c.candidates.iter().any(|c| c.text == "commit")); - // Flags too. let c = complete("git commit --", 13, None).expect("flags need no filesystem"); assert!(c.candidates.iter().any(|c| c.text == "--message")); } - /// Generators are local `/bin/sh -c` child processes, so against a remote - /// they would answer with this machine's state — `git checkout <Tab>` - /// offering the branches of whatever repo tty7's own cwd sits in. Unlike a - /// wrong filename, a wrong branch name is plausible enough to be accepted. #[test] fn a_remote_pane_never_runs_a_generator() { - // Locally this slot is generator-owned (the branch list). let local = complete("git checkout ", 13, Some(Path::new("/"))).unwrap(); assert!( !local.pending.is_empty(), "expected the local branch generator to still be declared" ); - // Remotely the same slot may keep its static candidates, but must not - // schedule a single script. if let Some(remote) = complete("git checkout ", 13, None) { assert!( remote.pending.is_empty(), @@ -1307,11 +996,8 @@ mod tests { } } - /// The word under the caret, split and resolved against the *remote* cwd. - /// This is the request the pane's SSH connection is asked to list. #[test] fn remote_path_request_splits_the_word_and_resolves_against_the_remote_cwd() { - // Bare word: list the cwd itself, nothing to re-prepend. let r = remote_path_request("cat fi", 6, "/home/me").unwrap(); assert_eq!( (r.dir.as_str(), r.prefix.as_str(), r.dir_part.as_str()), @@ -1320,56 +1006,39 @@ mod tests { assert_eq!((r.word_start, r.cursor), (4, 6)); assert!(!r.dirs_only); - // Relative subdirectory: resolved against the cwd, typed text preserved. let r = remote_path_request("cat sub/fi", 10, "/home/me").unwrap(); assert_eq!(r.dir, "/home/me/sub/"); assert_eq!((r.prefix.as_str(), r.dir_part.as_str()), ("fi", "sub/")); - // Absolute: stands alone, the cwd is irrelevant. let r = remote_path_request("cat /etc/pa", 11, "/home/me").unwrap(); assert_eq!(r.dir, "/etc/"); assert_eq!(r.prefix, "pa"); - // A trailing separator on the cwd must not double up. let r = remote_path_request("cat sub/", 8, "/").unwrap(); assert_eq!(r.dir, "/sub/"); - // `cd` takes directories only — same rule as the local engine. assert!( remote_path_request("cd pro", 6, "/home/me") .unwrap() .dirs_only ); - // A backslash is a filename character on a POSIX remote, not a - // separator — even when tty7 itself runs on Windows. let r = remote_path_request(r"cat a\b", 7, "/home/me").unwrap(); assert_eq!((r.dir.as_str(), r.prefix.as_str()), ("/home/me", r"a\b")); } - /// Positions where a remote listing is the wrong answer: the caller falls - /// back to the shell handoff for these rather than guessing. #[test] fn remote_path_request_declines_where_a_listing_cannot_help() { - // Command position: `$PATH`, not a directory listing. assert!(remote_path_request("ls", 2, "/home/me").is_none()); assert!(remote_path_request("", 0, "/home/me").is_none()); - // ...unless the "command" is itself a path, which is a real listing. assert!(remote_path_request("./scr", 5, "/home/me").is_some()); - // `~` needs the remote's $HOME, which no OSC reports. The remote shell - // can expand it; we can't, so we decline and let it have the Tab. assert!(remote_path_request("cat ~/pro", 9, "/home/me").is_none()); - // No absolute cwd to resolve against (the remote shell hasn't reported - // one yet, or reported something unusable). assert!(remote_path_request("cat fi", 6, "").is_none()); assert!(remote_path_request("cat fi", 6, "relative/dir").is_none()); } - /// A remote listing becomes candidates under exactly the local rules — - /// hidden entries stay hidden, the typed directory prefix is preserved, and - /// the ordering is the shared closeness sort. #[test] fn remote_path_candidates_mirror_the_local_path_rules() { let entries = |names: &[(&str, bool)]| -> Vec<RemoteEntry> { @@ -1401,25 +1070,19 @@ mod tests { "prefix-matched, shortest first; `.`/`..`/hidden/non-matching dropped" ); - // The directory kind survives, so the menu can mark it and the insert - // can add the trailing separator. let cands = remote_path_candidates(&req, &all); assert!(cands.iter().find(|c| c.text == "src").unwrap().is_dir()); assert!(!cands.iter().find(|c| c.text == "s").unwrap().is_dir()); - // The typed directory part is re-prepended to every candidate, and the - // replacement range covers the whole word. let req = remote_path_request("cat sub/s", 9, "/home/me").unwrap(); let c = &remote_path_candidates(&req, &all)[0]; assert_eq!(c.text, "sub/s"); assert_eq!((c.start, c.end), (4, 9)); - // A dot prefix opts into hidden entries, as it does locally. let req = remote_path_request("cat .h", 6, "/home/me").unwrap(); let got = texts(remote_path_candidates(&req, &all)); assert_eq!(got, vec![".hidden"]); - // `cd` drops the files. let req = remote_path_request("cd s", 4, "/home/me").unwrap(); let got = texts(remote_path_candidates(&req, &all)); assert_eq!(got, vec!["src"]); @@ -1429,20 +1092,16 @@ mod tests { fn no_candidates_returns_none() { let dir = temp_tree("empty", &[("zzz", false)]); assert!(complete("cat q", 5, Some(dir.as_path())).is_none()); - // A blank line offers nothing (no dump of every command on bare Tab). assert!(complete("", 0, Some(dir.as_path())).is_none()); assert!(complete(" ", 3, Some(dir.as_path())).is_none()); } #[test] fn mid_line_cursor_completes_only_the_word_before_it() { - // Caret sits right after "ap" with more text following; the candidate - // replaces only `word_start..cursor`, leaving the tail untouched. let dir = temp_tree("midline", &[("apple.txt", false)]); let c = complete("cat ap x.log", 6, Some(dir.as_path())).unwrap(); let apple = c.candidates.iter().find(|c| c.text == "apple.txt").unwrap(); assert_eq!((apple.start, apple.end), (4, 6)); - // Applying it splices over just that range. let (line, cursor) = Replacement { orig: "cat ap x.log".into(), start: apple.start, @@ -1463,25 +1122,19 @@ mod tests { "xb".to_string(), ]; sort_by_closeness(&mut items); - // Shorter first; equal-length ties broken alphabetically. assert_eq!(items, vec!["xa", "xb", "xyz", "xyzzy"]); } #[test] fn resolve_dir_handles_empty_absolute_and_relative() { let cwd = Path::new("/work/proj"); - // Empty dir part → the cwd itself. assert_eq!(resolve_dir("", cwd), PathBuf::from("/work/proj")); - // An absolute dir part is taken verbatim. assert_eq!(resolve_dir("/etc/", cwd), PathBuf::from("/etc/")); - // A relative dir part is joined onto the cwd. assert_eq!(resolve_dir("src/", cwd), PathBuf::from("/work/proj/src/")); } #[test] fn resolve_dir_expands_tilde_to_home() { - // Read the real home (no env mutation, so parallel tests aren't disturbed); - // the `~` branches must resolve against it. if let Some(home) = home_dir() { let cwd = Path::new("/work"); assert_eq!(resolve_dir("~", cwd), home); diff --git a/src/terminal/element.rs b/src/terminal/element.rs index 979f10ee..912faff8 100644 --- a/src/terminal/element.rs +++ b/src/terminal/element.rs @@ -1,6 +1,3 @@ -//! GPUI element that paints an `alacritty_terminal` grid as a fixed character -//! matrix: background quads, shaped glyph runs, and a cursor overlay. - use std::cell::RefCell; use std::collections::HashMap; @@ -18,15 +15,8 @@ use gpui::{ use gpui_component::ActiveTheme as _; use super::view::TerminalView; -// NOTE: `gpui::CursorStyle` (mouse pointer) is already in scope above, so the -// config cursor-shape enum is always referred to fully-qualified as -// `crate::core::config::CursorStyle` to avoid the name clash. use crate::core::config::Config; -/// Which underline variant the emulator asked for. The alacritty `Flags` bits -/// are independent, so we collapse them into one ordered style rather than a -/// bare bool — that lets the painter map curly → wavy and (best effort) vary -/// the rest instead of drawing every SGR 4:x the same. #[derive(Clone, Copy, PartialEq, Default, Debug)] enum UnderlineKind { #[default] @@ -38,14 +28,9 @@ enum UnderlineKind { Dashed, } -/// Per-cell render data resolved from the emulator grid. #[derive(Clone)] struct RenderCell { c: char, - /// Combining marks the emulator stacked on this cell: accents, variation - /// selectors, ZWJ-sequence tails. They carry no column of their own, but - /// dropping them changes what the shaper sees — `❤` and `❤\u{FE0F}` pick - /// different faces — so they ride along and get shaped with their base. marks: Option<Box<[char]>>, fg: Hsla, bg: Hsla, @@ -53,17 +38,11 @@ struct RenderCell { bold: bool, italic: bool, underline: UnderlineKind, - /// SGR 58 underline color (OSC-less, per-cell). `None` means "reuse the - /// glyph foreground", matching xterm's default. underline_color: Option<Hsla>, spacer: bool, selected: bool, - /// Covered by a (non-current) search match. match_hit: bool, - /// Covered by the current (focused) search match. match_current: bool, - /// Part of the URL currently under the mouse; painted underlined so the link - /// reads as clickable. link_hover: bool, } @@ -103,8 +82,6 @@ pub struct TermLayout { line_height: Pixels, cols: usize, rows: usize, - /// Hitbox over the grid, inserted in prepaint so paint can flip the cursor to - /// a pointing hand while a link is hovered. hitbox: Hitbox, } @@ -118,8 +95,6 @@ fn to_hsla(c: Rgb) -> Hsla { .into() } -/// Resolve an alacritty color slot to RGB, honoring OSC overrides then falling -/// back to the static xterm palette / theme defaults. fn resolve( color: AnsiColor, palette: &[Rgb; 256], @@ -156,22 +131,12 @@ fn build_font(base: &Font, bold: bool, italic: bool) -> Font { } else { FontStyle::Normal }; - // Batched runs shape several chars in one line, where a programming font's - // contextual ligatures (`calt`, e.g. Fira Code's "->") can fuse cells into - // one glyph and stress `force_width`'s one-glyph-per-column snapping. Keep - // the terminal-safe default unless the user explicitly configured OpenType - // features on the base font. if f.features.tag_value_list().is_empty() { f.features = gpui::FontFeatures::disable_ligatures(); } f } -/// Resolve one emulator cell into a `RenderCell`: colors (inverse/hidden -/// handling included), emphasis, underline style/color, and the selection -/// flag. `point` is the cell's grid-space position, used only for the -/// selection test. Wide-char spacers come back with just `spacer` set — the -/// leading cell paints them. fn snapshot_cell( cell: &Cell, point: AlacPoint, @@ -201,12 +166,6 @@ fn snapshot_cell( let mut rc = RenderCell { c: cell.c, - // `Cell::zerowidth` answers out of the same lazily-boxed `extra` that - // holds SGR 58 and OSC 8, so a cell carrying only an underline color or - // a hyperlink reports `Some(&[])`. Drop the empties: an empty mark list - // is still `Some`, which would pull every linked or SGR-58 cell off the - // batched run path onto its own `Cluster` — a `shape_line` per cell for - // a whole `ls --hyperlink` listing, and no powerline fast path either. marks: cell .zerowidth() .filter(|marks| !marks.is_empty()) @@ -216,9 +175,6 @@ fn snapshot_cell( draw_bg, bold: flags.contains(Flags::BOLD) || flags.contains(Flags::BOLD_ITALIC), italic: flags.contains(Flags::ITALIC) || flags.contains(Flags::BOLD_ITALIC), - // Map the specific underline bit → style. The variants are mutually - // exclusive in practice, but check the more specific bits first so a - // plain UNDERLINE never shadows them. underline: if flags.contains(Flags::DOUBLE_UNDERLINE) { UnderlineKind::Double } else if flags.contains(Flags::UNDERCURL) { @@ -232,27 +188,18 @@ fn snapshot_cell( } else { UnderlineKind::None }, - // SGR 58 underline color (falls back to fg at paint when absent). underline_color: cell .underline_color() .map(|c| to_hsla(resolve(c, palette, colors.fg_rgb, colors.bg_rgb).0)), ..RenderCell::default() }; - // Selection only *flags* the cell: the paint pass lays a translucent wash - // over it and the cell keeps its own foreground and background — so colors - // whose information lives in the background (fastfetch swatches, colored - // diff blocks, TUI status bars) stay visible while selected, matching the - // inline editor's translucent selection. if selection.is_some_and(|s| s.contains(point)) { rc.selected = true; } rc } -/// The active preset's terminal selection background, read from the -/// `ActivePalette` global. Falls back to a neutral dark tone if the global -/// isn't published yet (only possible before the first paint). fn active_selection_bg(cx: &gpui::App) -> Rgb { match cx.try_global::<crate::terminal::palette::ActivePalette>() { Some(a) => a.sel_bg, @@ -264,9 +211,6 @@ fn active_selection_bg(cx: &gpui::App) -> Rgb { } } -/// Theme, selection, and search-highlight colors resolved once per paint pass, -/// so the grid builder and the painters share one consistent set instead of -/// each re-deriving them. struct PaintColors { default_fg: Hsla, default_bg: Hsla, @@ -275,7 +219,6 @@ struct PaintColors { match_bg: Hsla, current_match_bg: Hsla, current_match_border: Hsla, - /// RGB defaults handed to the ANSI palette resolver. fg_rgb: Rgb, bg_rgb: Rgb, } @@ -285,23 +228,11 @@ impl PaintColors { let default_fg = theme.foreground; let default_bg = theme.background; let caret = theme.caret; - // The selection paints as a *translucent* wash over the cells' own - // colors — like the inline editor's selection and VS Code — so - // selected text keeps its syntax colors and background-only cells - // (fastfetch swatches, colored diff blocks) stay visible instead of - // vanishing under an opaque fill. Foreground at 0.24 alpha mirrors the - // preset's opaque selection surface (`mix(bg, fg, 0.24)` — see - // `presets::active_palette`): on default-background cells it composites - // to exactly the tone the opaque fill used to have. let selection_bg = { let mut c = default_fg; c.a = 0.24; c }; - // Search highlights derive from the preset's selection surface - // (published as the `ActivePalette` global by `apply_theme`, tuned to - // keep text legible on both themes): non-current matches get a subtle - // wash, the current match a stronger fill plus an accent outline. let base_match = to_hsla(active_selection_bg(cx)); let match_bg = { let mut c = base_match; @@ -327,9 +258,6 @@ impl PaintColors { } } -/// Paint per-cell background quads, merging each horizontal run of equal color -/// into a single quad. Background color varies per cell, so this can't share -/// the fixed-color `paint_cell_runs` helper. fn paint_backgrounds(window: &mut Window, geom: &CellGeom, buf: &[RenderCell]) { for row in 0..geom.rows { let mut col = 0; @@ -343,8 +271,6 @@ fn paint_backgrounds(window: &mut Window, geom: &CellGeom, buf: &[RenderCell]) { let start = col; while col < geom.cols { let c = &buf[row * geom.cols + col]; - // Spacer cells (trailing half of a wide char) inherit the - // preceding cell's background — include them in the run. if c.spacer || (c.draw_bg && c.bg == bg) { col += 1; } else { @@ -356,12 +282,6 @@ fn paint_backgrounds(window: &mut Window, geom: &CellGeom, buf: &[RenderCell]) { } } -/// Paint a single fixed `color` over every horizontal run of cells matching -/// `covered`, merging contiguous cells into one quad. An optional `border` -/// draws an accent outline around each run (used by the current search match). -/// -/// This collapses the selection, search-wash, and current-match overlays — all -/// previously copy-pasted run-merge loops — into one place. fn paint_cell_runs( window: &mut Window, geom: &CellGeom, @@ -380,9 +300,6 @@ fn paint_cell_runs( let start = col; while col < geom.cols { let cell = &buf[row * geom.cols + col]; - // Spacer cells are the trailing half of a wide (CJK) char. - // They inherit the preceding cell's highlight state, so include - // them in the run to paint the full 2-column width. if covered(cell) || cell.spacer { col += 1; } else { @@ -398,10 +315,6 @@ fn paint_cell_runs( } } -/// The style facets that must agree for two cells to share one shaped run. -/// Everything here feeds the `TextRun` (face, color, underline); backgrounds, -/// selection and search washes live in separate paint layers and don't split -/// glyph runs. #[derive(Clone, Copy, PartialEq)] struct GlyphStyle { fg: Hsla, @@ -424,22 +337,12 @@ impl GlyphStyle { } } - /// Whether this style paints ink even on blank cells (an underline does), - /// which forbids batching across space gaps: a lone space was never - /// underlined by the per-cell painter, and batching must not change that. fn draws_on_blanks(&self) -> bool { self.underline != UnderlineKind::None || self.link_hover } - /// Underline for either an emulator-styled underline or a hovered link (a - /// hovered link with no underline of its own reads as a plain single line - /// so it looks clickable). fn underline_style(&self) -> Option<gpui::UnderlineStyle> { self.draws_on_blanks().then(|| { - // gpui's `UnderlineStyle` only exposes `thickness` / `color` / - // `wavy`, so curly maps to `wavy` and double gets a thicker - // line. TODO: gpui has no dotted/dashed primitive, so those - // fall back to a straight single line for now. let wavy = self.underline == UnderlineKind::Curly; let thickness = if self.underline == UnderlineKind::Double { px(2.) @@ -448,8 +351,6 @@ impl GlyphStyle { }; gpui::UnderlineStyle { thickness, - // SGR 58 color when set, else the glyph's own foreground so - // the line reads as part of the text it sits on. color: Some(self.underline_color.unwrap_or(self.fg)), wavy, } @@ -457,119 +358,47 @@ impl GlyphStyle { } } -/// A blank cell paints no glyph (and today, no underline either — see -/// `GlyphStyle::draws_on_blanks`). A blank carrying combining marks is not -/// blank: a mark that opens a line lands on the space the grid starts with, and -/// it still has to be drawn. fn is_blank(cell: &RenderCell) -> bool { (cell.c == '\0' || cell.c == ' ') && cell.marks.is_none() } -/// One paintable piece of a row produced by [`segment_row`]. #[derive(Debug, PartialEq)] enum RowSeg { - /// Style-identical ASCII cells (with interior blank gaps rendered as - /// spaces), shaped as a single line. Run { start: usize, - /// Columns covered, gaps included — the clip width in cells. cells: usize, text: String, }, - /// One wide (two-column) glyph — CJK text, wide emoji — shaped on its own - /// and pinned to `2 × cell_width`. - /// - /// These used to batch into multi-glyph runs, which was wrong. gpui's - /// `apply_force_width_to_layout` distinguishes a base glyph from a - /// zero-advance combining mark by asking whether the shaped x advanced by - /// more than *half* the forced width — and fullwidth punctuation fails - /// that test: `(` (U+FF08) advances 0.472 em in the common CJK faces, - /// while half a two-cell slot is 0.6 em against a 0.6 em primary. The - /// glyph *after* such a character was therefore treated as a mark, painted - /// at the punctuation's own advance instead of the next column, and the - /// two overlapped (every following glyph in the batch then sat one column - /// early until the run ended). - /// - /// Shaping one glyph per line sidesteps the heuristic entirely: the first - /// glyph of a line is unconditionally a base. The cost is one `shape_line` - /// per wide glyph instead of per run, which the layout cache absorbs — - /// it is keyed on text + font + size + force_width, so a per-character key - /// recurs far more often than a per-phrase one. Wide { start: usize, - /// Columns covered — always 2 (the glyph plus its spacer). cells: usize, - /// Interned via [`char_string`], so re-painting a screen full of CJK - /// allocates nothing: the same `SharedString` is handed to `shape_line` - /// every frame, which is also what keys its layout cache. text: SharedString, }, - /// A cell painted on its own: any single-width non-ASCII glyph (box - /// drawing, accented Latin, …) that may route to a fallback face whose - /// advance isn't the cell width. - Solo { col: usize }, - /// A base with everything that has to shape alongside it — the combining - /// marks stacked on it, and a following SARA AM — as one string, so the - /// shaper sees the whole cluster. Never batched with neighbours: marks add - /// characters without adding columns, which is exactly the correspondence - /// `force_width` relies on in a [`RowSeg::Run`] or [`RowSeg::Wide`]. - /// - /// An absorbed SARA AM takes the base's style rather than its own. Unlike - /// a [`RowSeg::Run`], the cluster can't break on a style change: split off, - /// SARA AM has no base to reorder its nikhahit onto and renders as a dotted - /// circle. A recoloured vowel beats a broken one. + Solo { + col: usize, + }, Cluster { col: usize, - /// Columns the whole cluster occupies — 2 for a wide base, or for a - /// narrow base that absorbed a following SARA AM. cells: usize, text: String, - /// Whether `cells == 2` because the *base* is wide, rather than because - /// a spacing character joined it. The two need opposite pinning: a wide - /// base is one glyph across two columns, an absorbed SARA AM is two - /// glyphs of one column each. wide_base: bool, }, } -/// Append a cell's character followed by any combining marks riding on it. fn push_cell(text: &mut String, cell: &RenderCell) { text.push(cell.c); text.extend(cell.marks.iter().flat_map(|marks| marks.iter())); } -/// SARA AM (Thai U+0E33, Lao U+0EB3) is `Lo` and owns a column, but it is not -/// atomic to the shaper: the Thai shaper decomposes it into NIKHAHIT + SARA AA -/// and moves the nikhahit backwards over any above-base marks onto the base -/// consonant. Shaped in a run of its own it has no base to reorder onto, and -/// comes out as a dotted circle. fn is_sara_am(c: char) -> bool { matches!(c, '\u{0E33}' | '\u{0EB3}') } -/// Does `col` hold a SARA AM that should join the preceding cell's cluster? fn sara_am_at(row: &[RenderCell], col: usize) -> Option<&RenderCell> { row.get(col) .filter(|cell| !cell.spacer && is_sara_am(cell.c)) } -/// Split one grid row into paintable segments. -/// -/// ASCII-graphic cells batch into [`RowSeg::Run`]s: they always come from the -/// primary monospace face, so their advances match the cell width (and -/// `force_width` pins them exactly at paint). Blank cells end an underlined -/// run, silently join a plain one, and never lead or trail a run. -/// -/// Wide glyphs (a cell followed by its spacer — the grid's authoritative -/// two-column marker) each become their own [`RowSeg::Wide`], shaped alone and -/// pinned by `force_width` at `2 × cell_width`. They are deliberately *not* -/// batched: a full-width advance is not always > half the forced width (CJK -/// fullwidth punctuation is ~0.47 em), which breaks `apply_force_width_to_layout`'s -/// base-vs-combining-mark test — see [`RowSeg::Wide`]. -/// -/// Single-width non-ASCII glyphs still paint solo, preserving the per-cell -/// behavior for glyphs with unpredictable advances (box drawing must fill its -/// cell exactly, so per-cell clipping is deliberate there). fn segment_row(row: &[RenderCell]) -> Vec<RowSeg> { let mut segs = Vec::new(); let mut col = 0; @@ -579,16 +408,11 @@ fn segment_row(row: &[RenderCell]) -> Vec<RowSeg> { col += 1; continue; } - // Combining marks come first: they can sit on an ASCII base too, and - // either way the whole cluster has to reach the shaper in one string. if let Some(marks) = &cell.marks { let wide_base = col + 1 < row.len() && row[col + 1].spacer; let mut cells = if wide_base { 2 } else { 1 }; let mut text = String::with_capacity(1 + marks.len()); push_cell(&mut text, cell); - // A wide base already owns both columns, so only a narrow one has a - // column spare for SARA AM to join it in. A SARA AM is not itself a - // base to absorb onto — two in a row stay separate. if !wide_base && !is_sara_am(cell.c) && let Some(am) = sara_am_at(row, col + 1) @@ -606,11 +430,7 @@ fn segment_row(row: &[RenderCell]) -> Vec<RowSeg> { continue; } if !cell.c.is_ascii_graphic() { - // Wide (two-column) glyph? The trailing spacer is the grid's own - // width marker, so no Unicode width guessing is needed. if col + 1 < row.len() && row[col + 1].spacer { - // One segment per glyph, deliberately unbatched — see the - // `RowSeg::Wide` docs for why batching can't be made safe. segs.push(RowSeg::Wide { start: col, cells: 2, @@ -620,10 +440,6 @@ fn segment_row(row: &[RenderCell]) -> Vec<RowSeg> { } else if !is_sara_am(cell.c) && let Some(am) = sara_am_at(row, col + 1) { - // An unmarked base still has to shape with its SARA AM. A - // baseless SARA AM is not a base for the next one: absorbing - // there would pin the second one's glyphs outside the cluster's - // clip, so two in a row stay separate and both stay visible. let mut text = String::with_capacity(2); push_cell(&mut text, cell); push_cell(&mut text, am); @@ -647,9 +463,6 @@ fn segment_row(row: &[RenderCell]) -> Vec<RowSeg> { text.push(cell.c); let mut cells = 1; col += 1; - // Blanks between words may extend the run (they paint nothing), but are - // committed only once another matching glyph follows, so a run never - // carries trailing blanks. let mut gap = 0; while col < row.len() { let c = &row[col]; @@ -683,20 +496,9 @@ fn segment_row(row: &[RenderCell]) -> Vec<RowSeg> { } thread_local! { - /// Single-char `SharedString`s memoized across frames and panes (the UI - /// thread paints everything). Solo glyphs used to allocate a fresh String - /// per cell per frame — thousands of allocations per paint on a CJK-dense - /// screen. Entries are font-independent so they never go stale; the map is - /// cleared wholesale only if a pathological stream floods it with unique - /// codepoints. - static CHAR_STRINGS: RefCell<HashMap<char, SharedString>> = RefCell::new(HashMap::new()); + static CHAR_STRINGS: RefCell<HashMap<char, SharedString>> = RefCell::new(HashMap::new()); - /// The grid snapshot buffer, reused across frames and panes (the UI thread - /// paints everything sequentially). A full-screen grid is tens of - /// thousands of `RenderCell`s (~2 MB); remaking that Vec every paint was - /// pure allocator churn. Taken (`mem::take`) for the duration of one - /// element's paint and put back after, so panes share one allocation. - static GRID_BUF: RefCell<Vec<RenderCell>> = const { RefCell::new(Vec::new()) }; + static GRID_BUF: RefCell<Vec<RenderCell>> = const { RefCell::new(Vec::new()) }; } fn char_string(c: char) -> SharedString { @@ -711,38 +513,15 @@ fn char_string(c: char) -> SharedString { }) } -/// The Nerd Font powerline separators tty7 draws natively — as gpui paths -/// sized to the exact cell — instead of rasterizing a font glyph. -/// -/// These glyphs are pure geometry that only reads right when it fills the cell -/// edge-to-edge: prompt themes (powerlevel10k, oh-my-posh, starship) butt them -/// against colored segment backgrounds, so any gap or overshoot shows as a -/// seam. Font rasterization can't guarantee that fit — the primary font rarely -/// covers these codepoints, and a fallback face renders them at *its own* -/// advance, narrower or wider than our cell (issue #17: separators at -/// two-thirds width from a mismatched fallback). Building the shape from the -/// cell rect makes it exact for every font/size combination — the approach -/// kitty settled on with its programmatic glyphs. -/// The thin/outline variants (U+E0B1, U+E0B3, …) stay on the font path: they -/// are hairline strokes, not fills, and the bundled Hack covers the common -/// ones. #[derive(Clone, Copy, PartialEq, Debug)] enum PowerlineShape { - /// U+E0B0 — solid right-pointing triangle (the classic left separator). TriangleRight, - /// U+E0B2 — solid left-pointing triangle (right-prompt separator). TriangleLeft, - /// U+E0B4 — solid half-circle bulging right (rounded cap/separator). HalfCircleRight, - /// U+E0B6 — solid half-circle bulging left. HalfCircleLeft, - /// U+E0B8 — solid slant triangle filling the lower-left half. SlantLowerLeft, - /// U+E0BA — solid slant triangle filling the lower-right half. SlantLowerRight, - /// U+E0BC — solid slant triangle filling the upper-left half. SlantUpperLeft, - /// U+E0BE — solid slant triangle filling the upper-right half. SlantUpperRight, } @@ -762,12 +541,6 @@ impl PowerlineShape { } } -/// Build the fill path for one powerline shape spanning exactly `bounds` -/// (one cell). Pure geometry, split from the painting so tests can check it. -/// -/// The half-circles approximate each quarter-ellipse with a single quadratic -/// Bézier through the corner control point — within ~7% of a true ellipse, -/// indistinguishable at cell sizes and the same trade kitty makes. fn powerline_path(bounds: Bounds<Pixels>, shape: PowerlineShape) -> gpui::Path<Pixels> { let (x0, y0) = (bounds.origin.x, bounds.origin.y); let (x1, y1) = (x0 + bounds.size.width, y0 + bounds.size.height); @@ -787,8 +560,6 @@ fn powerline_path(bounds: Bounds<Pixels>, shape: PowerlineShape) -> gpui::Path<P PowerlineShape::SlantUpperLeft => tri(point(x0, y0), point(x1, y0), point(x0, y1)), PowerlineShape::SlantUpperRight => tri(point(x0, y0), point(x1, y0), point(x1, y1)), PowerlineShape::HalfCircleRight => { - // Flat edge on the left; the fan fill from the start point closes - // it implicitly (start → last point is that straight edge). let mut p = gpui::Path::new(point(x0, y0)); p.curve_to(point(x1, ymid), point(x1, y0)); p.curve_to(point(x0, y1), point(x1, y1)); @@ -803,45 +574,10 @@ fn powerline_path(bounds: Bounds<Pixels>, shape: PowerlineShape) -> gpui::Path<P } } -/// What a natively-drawn cell — a Powerline separator or a box-drawing/block -/// character, both painted as geometry rather than as a font glyph — still has -/// to send through the text pipeline after its ink is on screen. -/// -/// `None` for the common case: the geometry *is* the whole cell, so the shaping -/// and painting below can be skipped entirely. -/// -/// `Some(' ')` when the style draws on blanks, i.e. it carries an underline (or -/// is part of a hovered link). Underlines are not painted per-cell — they ride -/// on the [`TextRun`] that `paint_glyphs` builds, so a cell that returns early -/// silently loses its underline, leaving a one-column hole in an `ESC[4m` span -/// or a hovered URL. Shaping a *space* in the cell's own style closes the hole: -/// a space puts no glyph ink over the geometry already painted, and gpui draws -/// the line from the same [`gpui::UnderlineStyle`] (curly and double included) -/// it uses for every other cell, so weight, offset and colour match exactly. -/// The space comes from the primary monospace face, whose advance *is* the cell -/// width, so it needs no `force_width` to cover its column. fn native_cell_residue(style: &GlyphStyle) -> Option<char> { style.draws_on_blanks().then_some(' ') } -/// The width `paint_glyphs` clips a segment's paint to. -/// -/// A batched `Run`/`Wide` segment clips to its exact column span (`cells` -/// columns): its glyphs come from faces whose advance matches the cell, so -/// nothing should spill past that span. A lone `solo` glyph is different — it -/// can be a symbol whose face paints ink well past the single cell the grid -/// reserved for it: a non-Mono Nerd Font sets a *one-cell advance* on its icons -/// yet draws up to ~1.9 cells of ink (measured across Hasklug / Meslo / -/// JetBrainsMono NF), and the OS cascade serves a proportional `➜`/`❯` the same -/// way. Clipping that to one cell severs the glyph mid-ink — the incomplete -/// icons and the cut-off arrow in issue #17. -/// -/// Advance is no signal there (it reads one cell for exactly those overflowing -/// icons), so a solo glyph gets a two-cell window instead. A glyph that already -/// fits is untouched — it has no ink to spill — while a symbol that overflows -/// renders whole, bleeding into a trailing blank the way iTerm2 and Terminal.app -/// do with non-Mono faces. The two-cell bound keeps a pathological face from -/// smearing a lone glyph across the row. fn seg_clip_width(solo: bool, cells: usize, cell_width: Pixels) -> Pixels { if solo { cell_width * 2. @@ -850,20 +586,6 @@ fn seg_clip_width(solo: bool, cells: usize, cell_width: Pixels) -> Pixels { } } -/// Paint glyphs as per-row batched runs where safe, single cells otherwise. -/// -/// Merging cells into multi-char `shape_line` runs causes drift whenever a -/// glyph's font advance ≠ cell_width. gpui's `force_width` (added upstream for -/// Zed's terminal) pins every glyph in a shaped line to its own column, which -/// makes batching safe when all glyphs in the line occupy the same number of -/// columns *and* every one of them clears its base-glyph test — true for -/// style-identical ASCII runs at `cell_width` per glyph, false for wide glyphs -/// (see [`RowSeg::Wide`]), which shape one per line at `2 × cell_width`. -/// Single-width glyphs that may come from a fallback face (box drawing, …) -/// still paint cell-by-cell: their advances are unpredictable and mixing -/// widths inside one batch would break `force_width`'s uniform-column -/// assumption. Powerline separators skip fonts entirely — see -/// [`PowerlineShape`]. fn paint_glyphs( window: &mut Window, cx: &mut App, @@ -874,9 +596,6 @@ fn paint_glyphs( bold_font: Option<&Font>, italic_font: Option<&Font>, ) { - // The four style faces, resolved once per paint instead of once per cell. - // A distinct bold/italic family applies when configured (bold wins for - // bold+italic cells); `build_font` synthesizes the emphasis otherwise. let faces = [ build_font(base_font, false, false), build_font(bold_font.unwrap_or(base_font), true, false), @@ -907,28 +626,15 @@ fn paint_glyphs( Some(geom.cell_width), false, ), - // Each wide glyph is pinned to its own two-column slot; the - // clip stops an oversized fallback glyph bleeding past the run. RowSeg::Wide { start, cells, text } => { (start, cells, text, Some(geom.cell_width * 2.), false) } - // Always exactly one column now — anything with a trailing - // spacer became a Wide run in `segment_row`. No `force_width` - // for a single glyph — it paints at the run origin regardless. RowSeg::Solo { col } => { let cell = &buf[row_base + col]; let cell_bounds = Bounds::new( point(geom.origin.x + geom.cell_width * (col as f32), y), size(geom.cell_width, geom.line_height), ); - // Two families paint as native geometry rather than as a - // font glyph: Powerline separators, and the box-drawing / - // block characters (`boxdraw`) — a font glyph only covers - // the font's own line height, which broke every vertical - // run of `│`/`╭`/`╰` into dashes at line_height > 1.0. - // Either way the cell may still owe an underline, so this - // records whether the ink is already down rather than - // returning outright. let native = if let Some(shape) = PowerlineShape::of(cell.c) { let path = powerline_path(cell_bounds, shape); window.paint_path(path, GlyphStyle::of(cell).fg); @@ -957,21 +663,10 @@ fn paint_glyphs( } else { match native_cell_residue(&GlyphStyle::of(cell)) { None => continue, - // `solo: false` clips the space to its own single - // column so the underline can't spill sideways. Some(c) => (col, 1, char_string(c), None, false), } } } - // Same pinning as the batched runs, just for one base: two - // columns get `force_width` so a fallback emoji face can't - // drift, one column paints at the origin like `Solo`. - // Two columns pin per *base glyph*, and which that is depends - // on why the cluster is two cells wide: a wide base is one - // glyph spanning both, an absorbed SARA AM is two glyphs of one - // column each. `force_width` classifies by advance, so the - // marks ride their base under either. One column paints at the - // origin like `Solo`. RowSeg::Cluster { col, cells, @@ -1001,9 +696,6 @@ fn paint_glyphs( .shape_line(text, font_size, run_buf, force_width); let x = geom.origin.x + geom.cell_width * (start as f32); - // Batched runs clip to their exact column span; a solo glyph gets a - // two-cell window so a symbol/icon face whose ink overflows its cell - // isn't severed (see `seg_clip_width` — issue #17). let clip_width = seg_clip_width(solo, cells, geom.cell_width); let clip = Bounds::new(point(x, y), size(clip_width, geom.line_height)); window.with_content_mask(Some(ContentMask { bounds: clip }), |window| { @@ -1020,23 +712,14 @@ fn paint_glyphs( } } -/// Where the emulator's cursor sits, plus whether the app has hidden it -/// (DECTCEM off / `CursorShape::Hidden`). The position is tracked even while -/// hidden so the IME candidate window can anchor to it. #[derive(Clone, Copy)] struct GridCursor { row: usize, col: usize, hidden: bool, - /// The shape to draw after resolving terminal DECSCUSR/default state. style: crate::core::config::CursorStyle, } -/// Paint the cursor overlay in the configured shape: a filled block, a thin -/// vertical bar, or an underline in the blink "on" phase when focused (nothing in -/// the "off" phase, so it blinks). When unfocused every shape falls back to a -/// static hollow block outline — the conventional "not the active pane" cue, -/// independent of the shape choice. fn paint_cursor( window: &mut Window, geom: &CellGeom, @@ -1057,21 +740,18 @@ fn paint_cursor( return; } if !cursor_visible { - return; // blink "off" phase + return; } let mut c = caret; c.a = 0.55; match style { CursorStyle::Block => window.paint_quad(fill(rect, c)), CursorStyle::Bar => { - // A 2px vertical bar hugging the cell's left edge, scaled up a touch - // on very large fonts so it stays visible. let w = (geom.cell_width * 0.15).max(px(1.)).min(px(3.)); let bar = Bounds::new(rect.origin, size(w, rect.size.height)); window.paint_quad(fill(bar, c)); } CursorStyle::Underline => { - // A thin line along the cell's baseline, same thickness logic. let h = (geom.line_height * 0.12).max(px(1.)).min(px(3.)); let y = rect.origin.y + rect.size.height - h; let line = Bounds::new(point(rect.origin.x, y), size(rect.size.width, h)); @@ -1088,8 +768,6 @@ fn cursor_style_from_shape(shape: CursorShape) -> crate::core::config::CursorSty } } -/// Paint IME pre-edit (composing) text over the cursor cell, underlined so it -/// reads as provisional. fn paint_marked( window: &mut Window, cx: &mut App, @@ -1139,11 +817,6 @@ fn paint_marked( ); } -/// What [`TerminalElement::build_grid`] found alongside the cell buffer: the -/// cursor cell, the optional sub-line-scroll sliver row, and whether any cell -/// carries a selection / search-highlight flag — so the paint pass can skip -/// the corresponding overlay scans entirely in the common no-selection, -/// no-search frame. struct GridSnapshot { cursor: Option<GridCursor>, sliver: Option<Vec<RenderCell>>, @@ -1153,14 +826,6 @@ struct GridSnapshot { } impl TerminalElement { - /// Snapshot the emulator grid into `buf` (releasing the term lock before - /// returning) and locate the cursor cell. Search-match highlighting is - /// layered on afterwards. `buf` is caller-provided so the (rows × cols) - /// allocation is reused across frames instead of remade per paint. - /// - /// With `want_sliver`, also snapshots the row just above the viewport - /// (grid line `-(display_offset + 1)`), which becomes visible when a - /// sub-line scroll fraction shifts the whole grid down at paint. fn build_grid( &self, colors: &PaintColors, @@ -1178,10 +843,6 @@ impl TerminalElement { let display_offset; { let mut palette = self.view.read(cx).terminal.palette; - // Overwrite the ANSI 16 (slots 0-15) with the active preset's set - // for the current mode, published as the `ActivePalette` global by - // `apply_theme`. The 256-color cube and grayscale ramp (slots 16+) - // stay as built. Falls back to the stored palette if unset. if let Some(active) = cx.try_global::<crate::terminal::palette::ActivePalette>() { palette[..16].copy_from_slice(&active.ansi16); } @@ -1202,12 +863,6 @@ impl TerminalElement { buf[row as usize * cols + col] = rc; } - // The extra top row for sub-line scrolling. Grid lines index into - // history below 0, so the line above the viewport exists whenever - // we're not already at the top of the scrollback. Search-match - // washes are skipped here: matches are flagged in viewport - // coordinates, and a strip at most one line tall lighting up a - // frame early isn't worth widening that mapping. if want_sliver && (display_offset as usize) < term.grid().history_size() { let line = AlacLine(-display_offset - 1); let mut row_buf = vec![RenderCell::default(); cols]; @@ -1229,11 +884,6 @@ impl TerminalElement { sliver = Some(row_buf); } - // Cursor cell. We record the position even when the app has hidden the - // cursor (`CursorShape::Hidden`, e.g. a full-screen TUI like Claude Code - // that draws its own): the rendered block honours `hidden`, but the IME - // candidate window still needs an anchor at the input cell — otherwise it - // falls back to a window corner and can't follow the caret. let cur = content.cursor; let row = cur.point.line.0 + display_offset; let col = cur.point.column.0; @@ -1259,9 +909,6 @@ impl TerminalElement { } } - /// Flag the cells covered by the hovered link (if it's currently on screen) so - /// they paint underlined. The link is stored in scroll-stable grid coordinates, - /// so we shift it back into a screen row by the current display offset. fn flag_hovered_link( &self, buf: &mut [RenderCell], @@ -1273,9 +920,6 @@ impl TerminalElement { let Some(link) = self.view.read(cx).hovered_link.as_ref() else { return; }; - // The link may span several rows — a soft wrap, or a URL a program - // split with a hard newline. Paint every covered cell: full columns on - // the interior rows, clamped to `start`/`end` on the first and last. let (start, end) = (link.start, link.end); let mut line = start.line.0; while line <= end.line.0 { @@ -1302,14 +946,6 @@ impl TerminalElement { } } - /// Flag cells covered by search matches. Driven entirely by the SearchState - /// match list (computed only when the query changes), so this is the single - /// source of truth for highlighting. Cheap per frame: the list is ordered - /// top→bottom and non-overlapping (see `recompute_matches`), so a binary - /// search finds the first match that can touch the viewport and iteration - /// stops at the first one past it — instead of scanning all (up to 10k) - /// matches every frame. Returns whether any (non-current, current) cells - /// were flagged, so paint can skip the highlight passes entirely. fn flag_search_matches( &self, buf: &mut [RenderCell], @@ -1322,7 +958,6 @@ impl TerminalElement { return (false, false); }; let (mut any_hit, mut any_current) = (false, false); - // First match whose end reaches the viewport's top row. let first = search .matches .partition_point(|m| m.end().line.0 + display_offset < 0); @@ -1331,7 +966,7 @@ impl TerminalElement { let start = *m.start(); let end = *m.end(); if start.line.0 + display_offset >= rows as i32 { - break; // ordered: everything after starts below the viewport too + break; } if is_current { any_current = true; @@ -1369,9 +1004,6 @@ impl TerminalElement { (any_hit, any_current) } - /// Register the per-frame mouse listeners (press / drag / release) over our - /// bounds, translating pixel positions to grid cells and routing to the view - /// (selection, link opening, or mouse-tracking reports). fn register_mouse_handlers( &self, geom: CellGeom, @@ -1381,9 +1013,6 @@ impl TerminalElement { ) { let view = self.view.clone(); window.on_mouse_event(move |ev: &MouseDownEvent, phase, window, cx| { - // `is_hovered` (not `bounds.contains`) so a click on an overlay that - // sits above the terminal — the Cmd+F search bar, which `.occlude()`s - // its area — doesn't fall through and start a terminal selection. if !phase.bubble() || !hitbox.is_hovered(window) { return; } @@ -1393,10 +1022,6 @@ impl TerminalElement { let button = ev.button; let clicks = ev.click_count; view.update(cx, |v, cx| { - // Secondary+click (⌘ on macOS, Ctrl on Windows/Linux) opens a - // URL under the cursor. Not the raw platform key: that's Win/ - // Super off macOS, which the OS mostly swallows — and every - // other terminal there opens links on Ctrl+click. let link_modifier = mods.secondary() || v.link_modifier_down(); if link_modifier && button == MouseButton::Left @@ -1404,15 +1029,10 @@ impl TerminalElement { { return; } - // Report to the app when in mouse-tracking mode (Shift forces - // local selection instead). if v.mouse_mode() && !mods.shift { v.mouse_press(button, col, row, &mods); return; } - // A left click/double-click/triple-click on the command-editor - // line drives its caret/selection instead of a (meaningless) - // terminal selection over it. if button == MouseButton::Left && v.editor_click(col, raw_row, clicks, mods.shift, cx) { @@ -1430,15 +1050,7 @@ impl TerminalElement { let raw_row = geom.pos_to_row_raw(ev.position); let mods = ev.modifiers; let Some(button) = ev.pressed_button else { - // No button down: detect a link under the cursor so it underlines. - // This runs in mouse-tracking mode too — hover detection is purely - // local (button-less motion is never forwarded to the app), and - // ⌘-click opens links inside mouse-mode TUIs as well, so the - // underline affordance must match. Skipped only when the pointer - // is outside our bounds (or under the search-bar overlay). let inside = hitbox.is_hovered(window); - // Focus-follows-mouse: hovering an unfocused pane focuses it, no - // click needed. Guarded on `inside` and the config flag. if inside && cx.global::<Config>().focus_follows_mouse { let handle = view.read(cx).focus_handle.clone(); if !handle.is_focused(window) { @@ -1447,10 +1059,6 @@ impl TerminalElement { } view.update(cx, |v, cx| { if inside { - // Any-event mouse tracking (mode 1003): apps that asked - // for all motion get button-less moves too; `mouse_motion` - // no-ops unless the mode is set. Shift keeps the mouse - // local, matching the click/drag/scroll routing. if !mods.shift { v.mouse_motion(col, row, &mods); } @@ -1467,16 +1075,11 @@ impl TerminalElement { v.mouse_drag(button, col, row, &mods); return; } - // A drag that began on the command-editor line extends its - // selection rather than the terminal's. if button == MouseButton::Left && v.editor_drag(col, raw_row, cx) { return; } if button == MouseButton::Left { v.on_select_update(col, row, left, cx); - // Past the top/bottom edge, keep the selection growing by - // auto-scrolling the scrollback (`pos_to_cell` clamps the - // row, so the position alone stops at the edge). let overshoot = drag_overshoot(ev.position.y, bounds, geom.line_height); v.select_autoscroll(overshoot, col, left, cx); } @@ -1547,7 +1150,6 @@ impl Element for TerminalElement { let font_size = self.view.read(cx).font_size; let base_font = self.view.read(cx).font.clone(); - // Measure the monospace advance from a single glyph. let sample = window.text_system().shape_line( SharedString::new_static("M"), font_size, @@ -1563,10 +1165,6 @@ impl Element for TerminalElement { ); let cell_width = sample.width.max(px(1.)); let line_height_mul = self.view.read(cx).line_height_mul; - // Clamp to >= 1px like `cell_width`: a degenerate config (font_size 0 or a - // tiny line-height multiple) can round to 0, and dividing `bounds.height` - // by 0 yields `inf`, which casts to `usize::MAX` rows → `rows * cols` - // capacity overflow and an allocation panic on the first paint. let line_height = px((font_size.as_f32() * line_height_mul).round()).max(px(1.)); let cols = (bounds.size.width.as_f32() / cell_width.as_f32()) @@ -1601,20 +1199,9 @@ impl Element for TerminalElement { window: &mut Window, cx: &mut App, ) { - // Optional frame timing (TTY7_FPS=1). Times the whole paint body below. let fps_start = super::fps::enabled().then(std::time::Instant::now); - // Sub-line scroll fraction: shift the whole grid down by this many - // pixels so trackpad scrolling moves continuously instead of snapping - // line by line. The strip that opens above the top row is filled with - // the next older row (the "sliver"). Mouse mapping stays consistent - // automatically: `pos_to_cell` measures from this shifted origin. let frac = self.view.read(cx).scroll_frac.clamp(0., 1.); - // While the command editor's wrapped input would spill past the bottom - // row, the whole grid shifts up by that many lines — the top rows are - // clipped by the content mask and the overlay's wrapped tail lands in - // the vacated strip, emulating the scroll an echoing shell would do. - // Mouse mapping follows automatically via the shifted origin. let input_shift = self.view.read(cx).input_scroll_rows(); let geom = CellGeom { origin: point( @@ -1633,36 +1220,20 @@ impl Element for TerminalElement { let bold_font = self.view.read(cx).font_bold.clone(); let italic_font = self.view.read(cx).font_italic.clone(); let focused = self.view.read(cx).focus_handle.is_focused(window); - // Blink phase (only meaningful while focused) and the transient bell flash. let cursor_visible = self.view.read(cx).cursor_visible; let bell_flash = self.view.read(cx).bell_flash; - // While the inline line editor is live it owns the keyboard and draws its - // own caret at the prompt; suppress the terminal's block cursor so the two - // don't stack (the editor isn't focused on `focus_handle`, so otherwise the - // grid would paint a stale hollow box behind the field). let editor_active = self.view.read(cx).input_active(); - // Snapshot the emulator grid into the reused buffer (lock released - // inside). The buffer is returned to `GRID_BUF` at the end of paint. let mut buf = GRID_BUF.with(|b| std::mem::take(&mut *b.borrow_mut())); let snap = self.build_grid(&colors, &mut buf, geom.rows, geom.cols, frac > 0., cx); let cursor = snap.cursor; let sliver = snap.sliver.as_ref(); - // Cell the IME candidate window anchors to. Use the cursor position even - // when the app has hidden the hardware cursor (full-screen TUIs draw their - // own) so composition still tracks the input cell instead of dropping to a - // window corner. let cursor_cell = cursor.map(|c| (c.row, c.col)); - // The rendered cursor, by contrast, honours `hidden`; it carries its shape - // so `paint_cursor` can draw a block / bar / underline. let render_cursor = cursor .filter(|c| !c.hidden) .map(|c| (c.row, c.col, c.style)); - // Register the IME / text input handler so CJK (and dead-key) input - // composes and commits to the PTY. Positioned at the cursor cell so the - // candidate window appears in the right place. let cursor_bounds = cursor_cell.map(|(row, col)| geom.cell_rect(row, col, 1)); let focus_handle = self.view.read(cx).focus_handle.clone(); window.handle_input( @@ -1673,10 +1244,6 @@ impl Element for TerminalElement { let marked = self.view.read(cx).marked_text.clone(); window.with_content_mask(Some(ContentMask { bounds }), |window| { - // Background quads, then the selection / search overlays, then - // glyphs. The overlay passes each rescan the whole buffer, so they - // only run when the snapshot actually flagged something — the - // common no-selection, no-search frame skips all three. paint_backgrounds(window, &geom, &buf); if snap.any_selected { paint_cell_runs(window, &geom, &buf, colors.selection_bg, None, |c| { @@ -1708,10 +1275,6 @@ impl Element for TerminalElement { bold_font.as_ref(), italic_font.as_ref(), ); - // The sliver row above the viewport, exposed by the sub-line - // scroll shift: same paint layers on a one-row geometry sitting - // one line above the (already shifted) grid origin, clipped by - // the surrounding content mask. if let Some(row) = sliver { let sg = CellGeom { origin: point(geom.origin.x, geom.origin.y - geom.line_height), @@ -1733,9 +1296,6 @@ impl Element for TerminalElement { italic_font.as_ref(), ); } - // While the command editor is live, it draws its own caret and IME - // pre-edit in the overlay; suppress the grid's versions so they don't - // double up. if !editor_active { paint_cursor( window, @@ -1758,9 +1318,6 @@ impl Element for TerminalElement { ); } - // Visual bell: a brief, low-alpha wash over the whole surface as a - // restrained, non-intrusive alternative to an audible beep. Cleared - // automatically ~150ms after the bell by the view's timer. if bell_flash { let mut c = colors.default_fg; c.a = 0.12; @@ -1768,16 +1325,10 @@ impl Element for TerminalElement { } }); - // Hand the snapshot buffer back for the next paint (any pane). GRID_BUF.with(|b| *b.borrow_mut() = buf); self.register_mouse_handlers(geom, bounds, prepaint.hitbox.id, window); - // Mouse pointer over the surface: a pointing hand over a hovered link - // (Cmd+click opens it); otherwise an I-beam over the selectable text, - // like every other terminal. Once a program takes over mouse reporting - // the pointer stays the default arrow, signalling "the app owns this" - // (matching Terminal.app / iTerm). let view = self.view.read(cx); if view.hovered_link.is_some() { window.set_cursor_style(CursorStyle::PointingHand, &prepaint.hitbox); @@ -1791,7 +1342,6 @@ impl Element for TerminalElement { } } -/// Geometry helper for mapping pixel positions to grid cells. #[derive(Clone, Copy)] struct CellGeom { origin: Point<Pixels>, @@ -1802,7 +1352,6 @@ struct CellGeom { } impl CellGeom { - /// The pixel rectangle covering `span` cells starting at (`row`, `col`). fn cell_rect(&self, row: usize, col: usize, span: usize) -> Bounds<Pixels> { let x = self.origin.x + self.cell_width * (col as f32); let y = self.origin.y + self.line_height * (row as f32); @@ -1812,7 +1361,6 @@ impl CellGeom { ) } - /// Returns (column, row, is_left_half). fn pos_to_cell(&self, pos: Point<Pixels>) -> (usize, usize, bool) { let lx = (pos.x - self.origin.x).as_f32().max(0.); let ly = (pos.y - self.origin.y).as_f32().max(0.); @@ -1824,20 +1372,12 @@ impl CellGeom { (col, row, left) } - /// The row under `pos` without `pos_to_cell`'s bottom clamp. While the - /// input overlay shifts the grid up, its wrapped rows extend past - /// `rows - 1` into the vacated strip; the command editor needs those raw - /// rows so clicks and drags land on the right wrapped line. (Terminal - /// consumers — selection, mouse reports — keep the clamped row.) fn pos_to_row_raw(&self, pos: Point<Pixels>) -> usize { let ly = (pos.y - self.origin.y).as_f32().max(0.); (ly / self.line_height.as_f32()).floor() as usize } } -/// Vertical overshoot of a selection drag past the pane bounds, in lines: -/// positive above the top edge (auto-scroll up into history), negative below -/// the bottom, zero while inside. Feeds `TerminalView::select_autoscroll`. fn drag_overshoot(y: Pixels, bounds: Bounds<Pixels>, line_height: Pixels) -> f32 { let lh = line_height.as_f32().max(1.); if y < bounds.top() { @@ -1867,7 +1407,6 @@ mod tests { assert!((white.l - 1.0).abs() < 1e-6, "white has full lightness"); assert!(white.s.abs() < 1e-6, "white is desaturated"); - // A pure primary round-trips back through Rgba. let back = Rgba::from(to_hsla(Rgb { r: 255, g: 0, b: 0 })); assert!((back.r - 1.0).abs() < 1e-3); assert!(back.g.abs() < 1e-3 && back.b.abs() < 1e-3); @@ -1875,7 +1414,6 @@ mod tests { #[test] fn resolve_covers_every_color_slot() { - // A palette whose red channel encodes its own index, for easy assertions. let mut palette = [Rgb { r: 0, g: 0, b: 0 }; 256]; for (i, slot) in palette.iter_mut().enumerate() { slot.r = i as u8; @@ -1891,19 +1429,16 @@ mod tests { b: 12, }; - // A direct RGB spec passes through and is not a "default". let spec = Rgb { r: 1, g: 2, b: 3 }; assert_eq!( resolve(AnsiColor::Spec(spec), &palette, fg, bg), (spec, false) ); - // Indexed reads the palette slot. let (rgb, is_def) = resolve(AnsiColor::Indexed(5), &palette, fg, bg); assert_eq!(rgb.r, 5); assert!(!is_def); - // Named Foreground/Background fall back to the theme defaults (is_default=true). assert_eq!( resolve(AnsiColor::Named(NamedColor::Foreground), &palette, fg, bg), (fg, true) @@ -1913,7 +1448,6 @@ mod tests { (bg, true) ); - // A concrete named ANSI color reads the palette, not a default. let (rgb, is_def) = resolve(AnsiColor::Named(NamedColor::Red), &palette, fg, bg); assert_eq!(rgb.r, NamedColor::Red as u8); assert!(!is_def); @@ -1930,7 +1464,6 @@ mod tests { assert_eq!(bold_italic.weight, FontWeight::BOLD); assert_eq!(bold_italic.style, FontStyle::Italic); - // Family is preserved across the tweak. assert_eq!(bold_italic.family, base.family); } @@ -1948,7 +1481,6 @@ mod tests { assert_eq!(r.origin.y, px(20. + 16. * 2.)); assert_eq!(r.size.width, px(8.)); assert_eq!(r.size.height, px(16.)); - // A multi-cell span widens the rect by that many cells. assert_eq!(geom.cell_rect(0, 0, 4).size.width, px(32.)); } @@ -1961,33 +1493,24 @@ mod tests { cols: 5, rows: 3, }; - // Left of the cell midpoint → left half. let (c, r, left) = geom.pos_to_cell(point(px(2.), px(5.))); assert_eq!((c, r), (0, 0)); assert!(left); - // Right of the midpoint → right half. let (_, _, left) = geom.pos_to_cell(point(px(8.), px(5.))); assert!(!left); - // Negative offsets clamp to the first cell. let (c, r, _) = geom.pos_to_cell(point(px(-100.), px(-100.))); assert_eq!((c, r), (0, 0)); - // Far beyond the grid clamps to (cols-1, rows-1). let (c, r, _) = geom.pos_to_cell(point(px(9999.), px(9999.))); assert_eq!((c, r), (4, 2)); } - /// Drag auto-scroll only engages past the vertical edges, scaled to lines: - /// above the top is positive (into history), below the bottom negative. #[test] fn drag_overshoot_signed_by_edge_and_zero_inside() { let bounds = Bounds::new(point(px(0.), px(100.)), size(px(200.), px(100.))); - // Anywhere inside (including the exact edges) → no auto-scroll. assert_eq!(drag_overshoot(px(150.), bounds, px(10.)), 0.); assert_eq!(drag_overshoot(px(100.), bounds, px(10.)), 0.); assert_eq!(drag_overshoot(px(200.), bounds, px(10.)), 0.); - // 20px above the top at 10px lines → 2 lines up. assert_eq!(drag_overshoot(px(80.), bounds, px(10.)), 2.); - // 30px below the bottom → 3 lines down. assert_eq!(drag_overshoot(px(230.), bounds, px(10.)), -3.); } @@ -2010,8 +1533,6 @@ mod tests { ); } - // ---- segment_row ---- - fn cell(c: char) -> RenderCell { RenderCell { c, @@ -2035,8 +1556,6 @@ mod tests { } } - /// A row of wide glyphs as the grid stores them: each char followed by its - /// trailing spacer cell. fn wide_cells(chars: &str) -> Vec<RenderCell> { let mut row = Vec::new(); for c in chars.chars() { @@ -2056,10 +1575,8 @@ mod tests { #[test] fn segment_row_joins_plain_runs_across_gaps_but_trims_edges() { - // " ab cd " → one run: interior blanks join, leading/trailing don't. let row: Vec<_> = " ab cd ".chars().map(cell).collect(); assert_eq!(segment_row(&row), [run(1, 6, "ab cd")]); - // NUL cells (never-written grid slots) count as blanks too. let mut row: Vec<_> = "ab cd".chars().map(cell).collect(); row[2].c = '\0'; assert_eq!(segment_row(&row), [run(0, 5, "ab cd")]); @@ -2067,15 +1584,12 @@ mod tests { #[test] fn segment_row_ends_underlined_runs_at_blanks() { - // The per-cell painter never underlined a blank cell; a batched run - // must not start doing so, thus the gap splits the run. let mut row: Vec<_> = "ab cd".chars().map(cell).collect(); for c in &mut row { c.underline = UnderlineKind::Single; } assert_eq!(segment_row(&row), [run(0, 2, "ab"), run(3, 2, "cd")]); - // Same for a hovered link. let mut row: Vec<_> = "ab cd".chars().map(cell).collect(); for c in &mut row { c.link_hover = true; @@ -2085,13 +1599,11 @@ mod tests { #[test] fn segment_row_splits_on_style_changes() { - // Foreground color change mid-word. let mut row: Vec<_> = "abcd".chars().map(cell).collect(); row[2].fg = gpui::red(); row[3].fg = gpui::red(); assert_eq!(segment_row(&row), [run(0, 2, "ab"), run(2, 2, "cd")]); - // Bold toggling. let mut row: Vec<_> = "abcd".chars().map(cell).collect(); row[0].bold = true; assert_eq!(segment_row(&row), [run(0, 1, "a"), run(1, 3, "bcd")]); @@ -2099,9 +1611,6 @@ mod tests { #[test] fn segment_row_isolates_non_ascii() { - // A wide CJK char (cell + spacer) between ASCII words: the wide cell - // becomes a one-glyph Wide run (spanning its spacer), and the ASCII - // resumes batching after it. let mut row: Vec<_> = "ok?字 no".chars().map(cell).collect(); row.insert(4, { let mut sp = cell(' '); @@ -2113,8 +1622,6 @@ mod tests { [run(0, 3, "ok?"), wide(3, 2, "字"), run(6, 2, "no")] ); - // Single-width non-ASCII (box drawing) paints solo — it may come - // from a fallback face with a non-cell advance. let row: Vec<_> = "a─b".chars().map(cell).collect(); assert_eq!( segment_row(&row), @@ -2124,7 +1631,6 @@ mod tests { #[test] fn powerline_shape_maps_only_the_solid_separators() { - // The eight solid separators are drawn natively. for (c, shape) in [ ('\u{e0b0}', PowerlineShape::TriangleRight), ('\u{e0b2}', PowerlineShape::TriangleLeft), @@ -2137,9 +1643,6 @@ mod tests { ] { assert_eq!(PowerlineShape::of(c), Some(shape), "U+{:04X}", c as u32); } - // The thin/outline variants are hairline strokes — a filled gpui path - // can't draw those, so they stay on the font path — as do neighboring - // codepoints and ordinary prompt symbols. for c in [ '\u{e0b1}', '\u{e0b3}', '\u{e0b5}', '\u{e0b7}', '\u{e0b9}', '\u{e0bb}', '\u{e0bd}', '\u{e0bf}', '\u{e0a0}', '\u{e0c0}', '\u{2500}', '❯', '➜', @@ -2150,10 +1653,6 @@ mod tests { #[test] fn powerline_path_fills_exactly_one_cell() { - // Native drawing exists to guarantee edge-to-edge fit: every vertex of - // every shape must stay inside the cell it was given (no overshoot into - // a neighbor), and the fill must actually reach both horizontal edges - // (no two-thirds-width separators — the issue #17 symptom). let (x0, y0, w, h) = (px(10.), px(20.), px(9.), px(21.)); let bounds = Bounds::new(point(x0, y0), size(w, h)); for shape in [ @@ -2190,15 +1689,9 @@ mod tests { #[test] fn seg_clip_width_frees_solo_symbols_but_pins_batched_runs() { let cell = px(10.); - // Batched Run/Wide segments clip to their exact column span, so an - // oversized fallback glyph can't bleed past the run. assert_eq!(seg_clip_width(false, 1, cell), px(10.)); - assert_eq!(seg_clip_width(false, 5, cell), px(50.)); // an ASCII run - assert_eq!(seg_clip_width(false, 2, cell), px(20.)); // a wide (CJK) glyph - // A solo glyph gets a two-cell window instead, so a non-Mono Nerd Font - // icon (one-cell advance, ~1.9-cell ink) or a proportional arrow renders - // whole instead of severed at the cell edge (issue #17) — the bound keeps - // a pathological face from smearing across the row. + assert_eq!(seg_clip_width(false, 5, cell), px(50.)); + assert_eq!(seg_clip_width(false, 2, cell), px(20.)); assert_eq!(seg_clip_width(true, 1, cell), px(20.)); } @@ -2219,14 +1712,6 @@ mod tests { ); } - /// A natively-drawn cell keeps its underline. - /// - /// Underlines ride on the `TextRun`, so the Solo arm's early return for - /// Powerline separators and box-drawing characters used to drop them: an - /// `ESC[4m` span or a hovered URL containing `─`, `│` or `` showed a - /// one-column hole where the line should have run through. The residue is - /// what closes it — a space shaped in the cell's own style, carrying the - /// underline and no glyph ink. #[test] fn natively_drawn_cells_still_carry_their_underline() { let plain = GlyphStyle::of(&cell('│')); @@ -2250,8 +1735,6 @@ mod tests { ); } - // A hovered link underlines even without an emulator underline, and - // the characters it spans may well be box drawing or a separator. for ch in ['│', '─', '╭', '█', '\u{e0b0}'] { let mut c = cell(ch); c.link_hover = true; @@ -2266,9 +1749,6 @@ mod tests { #[test] fn segment_row_keeps_powerline_separators_solo() { - // The native-draw intercept lives in the Solo arm of `paint_glyphs`; - // if separators ever started batching into Run/Wide segments they'd - // silently bypass it and fall back to font rasterization. let row = vec![cell('a'), cell('\u{e0b0}'), cell('\u{e0b4}'), cell('b')]; assert_eq!( segment_row(&row), @@ -2283,15 +1763,6 @@ mod tests { #[test] fn segment_row_gives_each_wide_glyph_its_own_segment() { - // A CJK phrase emits one segment per glyph, each covering its glyph + - // spacer. Batching them was wrong: gpui's `apply_force_width_to_layout` - // only starts a new column when the shaped x has advanced past *half* - // the forced width, and fullwidth punctuation doesn't. `(` advances - // 0.472 em where half a two-cell slot is 0.6 em, so the glyph after it - // was classified as a combining mark, painted at the punctuation's own - // advance instead of the next column, and the two overlapped. Shaping - // each glyph alone makes it the first glyph of its line, which that - // function always treats as a base. let row = wide_cells("你好世界"); assert_eq!( segment_row(&row), @@ -2306,7 +1777,6 @@ mod tests { #[test] fn segment_row_isolates_narrow_fullwidth_punctuation() { - // The exact reproduction: `(这样` painted `这` on top of `(`. let row = wide_cells("(这样"); assert_eq!( segment_row(&row), @@ -2316,11 +1786,8 @@ mod tests { #[test] fn segment_row_tracks_wide_columns_across_styles_and_gaps() { - // Per-glyph segments make a mid-phrase style change a non-event: each - // glyph already carries its own style at paint. Column starts must - // still step by 2. let mut row = wide_cells("你好世界"); - row[4].fg = gpui::red(); // third glyph (cols 4-5) + row[4].fg = gpui::red(); row[6].fg = gpui::red(); assert_eq!( segment_row(&row), @@ -2332,8 +1799,6 @@ mod tests { ] ); - // A blank cell between wide glyphs still consumes its column, so the - // glyph after it starts at 3, not 2. let mut row = wide_cells("你好"); row.insert(2, cell(' ')); assert_eq!(segment_row(&row), [wide(0, 2, "你"), wide(3, 2, "好")]); @@ -2341,8 +1806,6 @@ mod tests { #[test] fn segment_row_leaves_spacerless_wide_char_solo() { - // A wide char whose spacer was clipped off (last column) has no width - // marker, so it falls back to the single-cell path. let row = vec![cell('a'), cell('字')]; assert_eq!(segment_row(&row), [run(0, 1, "a"), RowSeg::Solo { col: 1 }]); } @@ -2371,10 +1834,8 @@ mod tests { } } - /// Combining marks reach the shaper attached to their base, in one string. #[test] fn segment_row_shapes_combining_marks_with_their_base() { - // Single column: `e` + U+0301 → é. let mut row = vec![cell('a'), cell('e'), cell('b')]; row[1].marks = Some(Box::from(['\u{0301}'])); assert_eq!( @@ -2382,14 +1843,10 @@ mod tests { [run(0, 1, "a"), cluster(1, 1, "e\u{0301}"), run(2, 1, "b"),] ); - // Two columns: the emulator widened the base, so the cluster owns the - // spacer too (❤ + U+FE0F). let mut row = wide_cells("\u{2764}"); row[0].marks = Some(Box::from(['\u{FE0F}'])); assert_eq!(segment_row(&row), [wide_cluster(0, 2, "\u{2764}\u{FE0F}")]); - // Several marks on one base: an above-base vowel and a tone mark both - // sit on the consonant (ที่ = ท U+0E17 + ◌ี U+0E35 + ◌่ U+0E48). let mut row = vec![cell('\u{0E17}'), cell('a')]; row[0].marks = Some(Box::from(['\u{0E35}', '\u{0E48}'])); assert_eq!( @@ -2398,14 +1855,8 @@ mod tests { ); } - /// SARA AM (U+0E33) is the awkward Thai vowel: `Lo`, width 1, so the grid - /// gives it its own column — but the shaper decomposes it into NIKHAHIT + - /// SARA AA and reorders the nikhahit backwards onto the base consonant. - /// Shaped in its own run it has no base to reorder onto and comes out as a - /// dotted circle, so it has to join the preceding cell's cluster. #[test] fn segment_row_absorbs_sara_am_into_its_base() { - // น + ้ (tone) + ำ — the base already carries a mark. let mut row = vec![cell('\u{0E19}'), cell('\u{0E33}'), cell('a')]; row[0].marks = Some(Box::from(['\u{0E49}'])); assert_eq!( @@ -2413,36 +1864,25 @@ mod tests { [cluster(0, 2, "\u{0E19}\u{0E49}\u{0E33}"), run(2, 1, "a")] ); - // ก + ำ — an unmarked base still has to shape with it. let row = vec![cell('\u{0E01}'), cell('\u{0E33}')]; assert_eq!(segment_row(&row), [cluster(0, 2, "\u{0E01}\u{0E33}")]); - // Lao SARA AM (U+0EB3) takes the same shaper path. let row = vec![cell('\u{0E81}'), cell('\u{0EB3}')]; assert_eq!(segment_row(&row), [cluster(0, 2, "\u{0E81}\u{0EB3}")]); - // A style change does not break the cluster, unlike a `Run` or `Wide` - // batch: split off, the vowel has no base and paints a dotted circle, - // so it takes the base's style instead. let mut row = vec![cell('\u{0E01}'), cell('\u{0E33}')]; row[1].fg = gpui::red(); assert_eq!(segment_row(&row), [cluster(0, 2, "\u{0E01}\u{0E33}")]); } - /// With nothing to attach to, SARA AM paints alone — a dotted circle is the - /// shaper's honest answer for an orphaned mark, and inventing a base would - /// be worse. #[test] fn segment_row_leaves_a_baseless_sara_am_alone() { let row = vec![cell('\u{0E33}'), cell('a')]; assert_eq!(segment_row(&row), [RowSeg::Solo { col: 0 }, run(1, 1, "a")]); - // A blank before it is not a base either. let row = vec![cell(' '), cell('\u{0E33}')]; assert_eq!(segment_row(&row), [RowSeg::Solo { col: 1 }]); - // Nor is another SARA AM: absorbing would pin the second one's glyphs - // past the cluster's two-cell clip and swallow it entirely. let row = vec![cell('\u{0E33}'), cell('\u{0E33}')]; assert_eq!( segment_row(&row), @@ -2450,11 +1890,8 @@ mod tests { ); } - /// A marked cell never joins a batch: marks add characters without adding - /// columns, which would desync `force_width`'s glyph-per-column pinning. #[test] fn segment_row_never_batches_a_marked_cell() { - // ASCII run splits around it. let mut row: Vec<_> = "abc".chars().map(cell).collect(); row[1].marks = Some(Box::from(['\u{0301}'])); assert_eq!( @@ -2462,7 +1899,6 @@ mod tests { [run(0, 1, "a"), cluster(1, 1, "b\u{0301}"), run(2, 1, "c"),] ); - // Wide run splits around it. let mut row = wide_cells("你好世"); row[2].marks = Some(Box::from(['\u{FE0F}'])); assert_eq!( @@ -2475,8 +1911,6 @@ mod tests { ); } - /// A mark opening a line lands on the space the grid starts with — that - /// cell still has ink, so it must not be skipped as blank. #[test] fn segment_row_keeps_a_blank_that_carries_marks() { let mut row = vec![cell(' '), cell(' ')]; @@ -2492,8 +1926,6 @@ mod tests { assert_eq!(a.as_ref(), "界"); } - /// Hand-built `PaintColors` for the snapshot tests (the real `resolve` - /// needs a live theme/App). fn test_colors() -> PaintColors { let fg = Rgb { r: 10, @@ -2527,11 +1959,6 @@ mod tests { } } - /// Regression: selection must not rewrite a cell's own colors. It used to - /// force the foreground to the preset's selection text color and rely on an - /// opaque fill — which erased background-only cells entirely (fastfetch - /// color swatches, colored diff blocks vanished while selected). Selection - /// now only flags the cell; the paint pass lays a translucent wash on top. #[test] fn selected_cells_keep_their_own_colors_for_the_translucent_wash() { let mut palette = [Rgb { r: 0, g: 0, b: 0 }; 256]; @@ -2549,7 +1976,6 @@ mod tests { let point = AlacPoint::new(AlacLine(0), AlacColumn(0)); let range = SelectionRange::new(point, point, false); - // A fastfetch-style swatch: a space whose information IS its background. let swatch = Cell { bg: AnsiColor::Indexed(1), ..Cell::default() @@ -2559,7 +1985,6 @@ mod tests { assert!(rc.draw_bg, "the swatch background still paints"); assert_eq!(rc.bg, to_hsla(palette[1]), "background not replaced"); - // Colored text keeps its syntax color while selected. let text = Cell { c: 'x', fg: AnsiColor::Indexed(2), @@ -2569,14 +1994,9 @@ mod tests { assert!(rc.selected); assert_eq!(rc.fg, to_hsla(palette[2]), "foreground not forced"); - // And the wash itself is translucent, or the kept colors could never - // read through it. assert!(colors.selection_bg.a < 1.0); } - /// SGR 7 swaps the two colors, and the swapped background must paint even - /// when the cell sat on the *default* background — otherwise an inverse - /// block (`ls` selections, status bars) silently vanishes. #[test] fn inverse_swaps_colors_and_always_paints_the_background() { let mut palette = [Rgb { r: 0, g: 0, b: 0 }; 256]; @@ -2603,8 +2023,6 @@ mod tests { ); } - /// SGR 8 conceals text by drawing it in the background color — whatever - /// that background is — without turning a default background opaque. #[test] fn hidden_paints_the_foreground_as_the_background() { let mut palette = [Rgb { r: 0, g: 0, b: 0 }; 256]; @@ -2616,8 +2034,6 @@ mod tests { let colors = test_colors(); let point = AlacPoint::new(AlacLine(0), AlacColumn(0)); - // On the default background the glyph melts into the theme bg and the - // cell still skips the background fill. let on_default = Cell { c: 's', fg: AnsiColor::Indexed(1), @@ -2629,7 +2045,6 @@ mod tests { assert_eq!(rc.fg, to_hsla(colors.bg_rgb)); assert!(!rc.draw_bg); - // On a colored background it melts into *that* color instead. let on_colored = Cell { c: 's', bg: AnsiColor::Indexed(1), @@ -2641,10 +2056,6 @@ mod tests { assert_eq!(rc.fg, rc.bg); } - /// Each underline SGR maps to its own variant, and the specific bits must - /// win over a plain UNDERLINE that may be set alongside them — a curly - /// diagnostic squiggle degrading to a straight line is exactly the kind of - /// regression a human eyeball misses. #[test] fn underline_flag_bits_map_to_their_variants() { let palette = [Rgb { r: 0, g: 0, b: 0 }; 256]; @@ -2665,16 +2076,12 @@ mod tests { assert_eq!(kind(Flags::UNDERCURL), UnderlineKind::Curly); assert_eq!(kind(Flags::DOTTED_UNDERLINE), UnderlineKind::Dotted); assert_eq!(kind(Flags::DASHED_UNDERLINE), UnderlineKind::Dashed); - // A variant bit set together with plain UNDERLINE keeps the variant. assert_eq!( kind(Flags::UNDERLINE | Flags::UNDERCURL), UnderlineKind::Curly ); } - /// SGR 58 sets a dedicated underline color that resolves through the - /// palette; without it the field stays `None` so paint falls back to the - /// glyph's foreground. #[test] fn sgr58_underline_color_resolves_through_the_palette() { let mut palette = [Rgb { r: 0, g: 0, b: 0 }; 256]; @@ -2707,8 +2114,6 @@ mod tests { ); } - /// BOLD_ITALIC is its own flag bit — it must light up both emphases, not - /// require BOLD and ITALIC to also be set. #[test] fn bold_italic_flag_sets_both_emphases() { let palette = [Rgb { r: 0, g: 0, b: 0 }; 256]; @@ -2729,9 +2134,6 @@ mod tests { assert_eq!(emphases(Flags::BOLD_ITALIC), (true, true)); } - /// Wide-char spacers only mark the column as occupied; everything else — - /// colors, underline, selection — is the leading cell's job. A spacer that - /// painted anything would double-draw under every CJK glyph. #[test] fn wide_char_spacers_defer_to_the_leading_cell() { let mut palette = [Rgb { r: 0, g: 0, b: 0 }; 256]; @@ -2757,11 +2159,6 @@ mod tests { } } - /// SGR 58, OSC 8 and combining marks all live in alacritty's one lazily - /// boxed `extra`, so `Cell::zerowidth` says `Some(&[])` for a cell that - /// merely carries a color or a link. Only real marks may set `marks`: an - /// empty list is still `Some`, and `segment_row` would take every linked - /// cell off the batched run path onto a `shape_line` of its own. #[test] fn only_real_combining_marks_set_marks() { let palette = [Rgb { r: 0, g: 0, b: 0 }; 256]; diff --git a/src/terminal/fps.rs b/src/terminal/fps.rs index 21c64abd..5269cdb4 100644 --- a/src/terminal/fps.rs +++ b/src/terminal/fps.rs @@ -1,38 +1,15 @@ -//! Optional per-frame paint timing. Disabled unless `TTY7_FPS` is set to a -//! non-empty, non-`0` value (e.g. `TTY7_FPS=1 cargo run`). -//! -//! gpui repaints *on demand* — it only paints when something is marked dirty -//! via `cx.notify()`. So this deliberately does NOT report a steady 120fps -//! while the terminal is idle; idle frames are zero by design, and that's the -//! whole point of the architecture. What it measures is: -//! - how fast a single paint is on the CPU side (`paint avg/max`), and -//! - the frame rate actually achieved during *continuous* output or -//! scrolling (e.g. `yes`, `cat bigfile`), which is where "do we hit the -//! display's refresh rate?" is a meaningful question. -//! -//! Note this is the CPU-side cost of building the frame and enqueuing draw -//! commands; it does not include GPU execution. For true end-to-end frame -//! rate, pair this with Instruments → Core Animation FPS / Metal System Trace. - use std::sync::{Mutex, OnceLock}; use std::time::{Duration, Instant}; -/// Whether timing is on. Read once from `TTY7_FPS` and cached. pub fn enabled() -> bool { static ON: OnceLock<bool> = OnceLock::new(); *ON.get_or_init(|| flag_enables(std::env::var("TTY7_FPS").ok().as_deref())) } -/// Whether a `TTY7_FPS` value (or its absence) turns timing on: any non-empty -/// value except `0`. Split from `enabled` so the semantics are testable without -/// depending on the ambient process environment. fn flag_enables(value: Option<&str>) -> bool { value.is_some_and(|v| !v.is_empty() && v != "0") } -/// Length of one aggregation window of wall-clock time *in which painting -/// happened* (an idle gap just stretches the reported window, so it reads -/// honestly rather than as a low frame rate). const WINDOW: Duration = Duration::from_secs(1); struct Meter { @@ -52,9 +29,6 @@ impl Meter { } } - /// Fold one frame in; when `now` crosses the window boundary, return the - /// aggregate report line and start a fresh window anchored at `now`. The - /// clock is injected so tests can cross windows without sleeping. fn record(&mut self, now: Instant, paint: Duration) -> Option<String> { self.frames += 1; self.paint_total += paint; @@ -82,15 +56,11 @@ fn meter() -> &'static Mutex<Option<Meter>> { M.get_or_init(|| Mutex::new(None)) } -/// Record one frame's CPU-side paint duration. Emits an aggregate stderr line -/// roughly once per `WINDOW` of painting time. pub fn record(paint: Duration) { let now = Instant::now(); let mut guard = meter().lock().unwrap(); let m = guard.get_or_insert_with(|| Meter::new(now)); if let Some(line) = m.record(now, paint) { - // Direct to stderr: the app never initialises a `log` backend, so - // `log::info!` here would be silently dropped. eprintln!("{line}"); } } @@ -135,8 +105,6 @@ mod tests { m.record(start + Duration::from_millis(200), Duration::from_millis(6)) .is_none() ); - // Crossing the window boundary flushes the aggregate: 3 frames over - // 1.5s = 2.0 fps, paint avg (2+6+4)/3 = 4ms, max 6ms. let flush_at = start + Duration::from_millis(1500); let line = m .record(flush_at, Duration::from_millis(4)) @@ -145,7 +113,6 @@ mod tests { line, "[fps] 2.0 fps over 1.50s (3 frames) | paint avg 4.00ms max 6.00ms" ); - // The flush starts a fresh window anchored at the flush instant. assert_eq!(m.frames, 0); assert_eq!(m.paint_total, Duration::ZERO); assert_eq!(m.paint_max, Duration::ZERO); @@ -154,8 +121,6 @@ mod tests { #[test] fn meter_flushes_exactly_on_the_window_boundary() { - // `elapsed == WINDOW` counts as crossing (the check is `<`), so a frame - // landing exactly on the boundary flushes rather than being held over. let start = Instant::now(); let mut m = Meter::new(start); let line = m.record(start + WINDOW, Duration::from_millis(1)); diff --git a/src/terminal/fuzzy.rs b/src/terminal/fuzzy.rs index 528be65e..e109d133 100644 --- a/src/terminal/fuzzy.rs +++ b/src/terminal/fuzzy.rs @@ -1,52 +1,16 @@ -//! Fuzzy subsequence matching for the Ctrl+R history search. -//! -//! A small affine-gap aligner in the fzf/skim family: every query character -//! must appear in the haystack in order (a subsequence), and the returned score -//! rewards runs of consecutive matches and matches at word boundaries while -//! penalizing gaps — so `gst` prefers `git status` over `grep -rn "s" tests`. -//! The matched character positions come back too, so the menu can highlight -//! exactly which characters matched. -//! -//! Whitespace in the query splits it into terms that must *all* match -//! (anywhere, in any order) — `git push` finds `git push -f origin` but also -//! `push-all git-mirrors`. Matching is always case-insensitive, like the -//! substring search this replaces. -//! -//! Kept dependency-free on purpose: command lines are short, so the O(m×n) -//! dynamic program is comfortably cheap even against thousands of history -//! entries per keystroke. - -/// A successful match: the alignment score (higher is better; only comparable -/// between matches of the *same query*) and the matched char indices into the -/// haystack, ascending and deduplicated. pub(super) struct FuzzyMatch { pub score: i32, pub positions: Vec<usize>, } -/// Every matched character is worth this much before bonuses. const SCORE_MATCH: i32 = 16; -/// Bonus for a match at a word boundary (start of the line, or right after a -/// separator) — `st` should land on the `status` in `git status`. const BONUS_BOUNDARY: i32 = 12; -/// Bonus for extending a run of consecutive matches — favours tight matches -/// over the same letters scattered across the line. Deliberately worth more -/// than a boundary bonus reached across a gap (`BONUS_BOUNDARY + -/// PENALTY_GAP_START = 9`), so `ab` still prefers the literal `ab` over the -/// two word heads of `a-b`. const BONUS_CONSECUTIVE: i32 = 10; -/// Cost of opening a gap between two matched characters… const PENALTY_GAP_START: i32 = -3; -/// …and of each further character that gap skips. const PENALTY_GAP_EXTEND: i32 = -1; -/// "Impossible" sentinel. Kept far from `i32::MIN` so adding penalties/bonuses -/// to a sentinel value can never wrap around into a plausible score. const NEG: i32 = i32::MIN / 2; -/// Match `query` against `line`. Whitespace splits the query into terms which -/// must all match; scores add up and positions merge. `None` when the query is -/// blank or any term fails to match. pub(super) fn match_line(line: &str, query: &str) -> Option<FuzzyMatch> { let terms: Vec<&str> = query.split_whitespace().collect(); if terms.is_empty() { @@ -72,14 +36,10 @@ pub(super) fn match_line(line: &str, query: &str) -> Option<FuzzyMatch> { }) } -/// Lowercase a char for comparison (first mapping only — `ß`→`ss` expansions -/// don't matter for scoring command lines). fn lc(c: char) -> char { c.to_lowercase().next().unwrap_or(c) } -/// The word-boundary bonus a match at a position earns, given the preceding -/// character (`None` at the start of the line). fn char_bonus(prev: Option<char>) -> i32 { match prev { None => BONUS_BOUNDARY, @@ -92,12 +52,6 @@ fn char_bonus(prev: Option<char>) -> i32 { } } -/// Align one lowercased `term` against the lowercased haystack, returning the -/// best score and the matched positions. Classic affine-gap DP: -/// `m[i][j]` is the best score with `term[i]` matched at `hay[j]`, reachable -/// either consecutively from `m[i-1][j-1]` or across a gap (tracked by a -/// running per-row maximum so each cell is O(1)); `parent[i][j]` remembers the -/// chosen predecessor for the backtrack that recovers the positions. fn match_term(hay_lc: &[char], bonus: &[i32], term: &[char]) -> Option<(i32, Vec<usize>)> { let (m, n) = (term.len(), hay_lc.len()); if m == 0 || m > n { @@ -112,8 +66,6 @@ fn match_term(hay_lc: &[char], bonus: &[i32], term: &[char]) -> Option<(i32, Vec } } for i in 1..m { - // Best gapped predecessor for the current j: max over k ≤ j-2 of - // `score[i-1][k]` plus the affine penalty for the k→j gap. let mut gap_best = NEG; let mut gap_arg = usize::MAX; for j in 0..n { @@ -156,7 +108,6 @@ fn match_term(hay_lc: &[char], bonus: &[i32], term: &[char]) -> Option<(i32, Vec } } - // Best end position for the last term char; ties go to the earliest. let (mut best_j, mut best) = (usize::MAX, NEG); for j in 0..n { if score[(m - 1) * n + j] > best { @@ -191,8 +142,8 @@ mod tests { #[test] fn non_subsequence_is_no_match() { assert!(match_line("git status", "xyz").is_none()); - assert!(match_line("ls", "lss").is_none()); // longer than the line - assert!(match_line("git status", "tg").is_none()); // out of order + assert!(match_line("ls", "lss").is_none()); + assert!(match_line("git status", "tg").is_none()); } #[test] @@ -209,45 +160,35 @@ mod tests { #[test] fn consecutive_run_beats_scattered_letters() { - // Both contain g,i,t as a subsequence; only one has them adjacent. assert!(score("git log", "git") > score("going to lunch", "git")); } #[test] fn word_boundary_beats_mid_word() { - // `st` at the start of "status" (after a space) vs inside "faster". assert!(score("git status", "st") > score("faster", "st")); } #[test] fn positions_pick_the_best_alignment() { - // `gs` should land on the `g` of git and the boundary `s` of status, - // not some later `s`. assert_eq!(positions("git status", "gs"), vec![0, 4]); - // A consecutive alignment is recovered exactly. assert_eq!(positions("cargo build", "build"), vec![6, 7, 8, 9, 10]); } #[test] fn multi_term_queries_must_all_match_and_merge_positions() { - // Terms match independently (order-free) and positions merge sorted. let m = match_line("git push --force origin", "push git").unwrap(); assert_eq!(m.positions, vec![0, 1, 2, 4, 5, 6, 7]); - // One term failing fails the whole query. assert!(match_line("git push", "git nope").is_none()); } #[test] fn gaps_are_penalized_by_length() { - // Same letters, tighter gap scores higher. assert!(score("ab", "ab") > score("a-b", "ab")); assert!(score("a-b", "ab") > score("a---------b", "ab")); } #[test] fn unicode_haystacks_match_by_char() { - // Positions are char indices, not bytes: the CJK prefix occupies - // char cells 0..2, so `ls` lands at 3..=4. assert_eq!(positions("构建 ls", "ls"), vec![3, 4]); } } diff --git a/src/terminal/generator.rs b/src/terminal/generator.rs index d3a00c4b..1fa666f6 100644 --- a/src/terminal/generator.rs +++ b/src/terminal/generator.rs @@ -1,26 +1,3 @@ -//! Executing Fig *dynamic generators* — the shell scripts a completion spec -//! attaches to an argument so its candidates come from the live system rather -//! than a static list (`ssh <Tab>` → your known hosts, `git checkout <Tab>` → -//! your branches). tty7 parses these scripts out of the specs but, until now, -//! never ran them, so those positions fell through to filesystem paths (#51). -//! -//! The split: the pure [`completion`](super::completion) engine returns the -//! script text, the view spawns [`run`] on a background thread, and the stdout -//! is turned into candidates and merged into the already-open menu. Three -//! concerns live here: -//! - **execution** — [`run`]: `/bin/sh -c <script>` in the session's cwd, hard -//! wall-clock timeout, child killed on timeout or drop, stdout capped; -//! - **parsing** — [`parse`]: a per-script [`registry`] of parsers (a git -//! branch listing needs its `* ` marker stripped and detached-HEAD lines -//! dropped) over a newline-splitting default; -//! - **caching** — a short TTL cache so reopening the same menu doesn't respawn -//! a process for a result we just computed. -//! -//! Everything is deliberately synchronous and blocking: the caller hands `run` -//! to the background executor, so blocking a pool thread on a child process is -//! fine and keeps the child's lifetime tied to a single stack frame (hence the -//! drop-kill guard rather than async cancellation plumbing). - use serde_json::Value; use std::collections::HashMap; #[cfg(unix)] @@ -31,39 +8,20 @@ use std::process::{Command, Stdio}; use std::sync::{Mutex, OnceLock}; use std::time::{Duration, Instant}; -/// Wall-clock ceiling on a generator: past this the child is killed and the -/// position yields nothing. Generators are meant to be cheap local queries; a -/// slow or hung one must never stall the menu. #[cfg(unix)] const TIMEOUT: Duration = Duration::from_millis(800); -/// Ceiling on captured stdout. A runaway generator can't be allowed to buffer -/// unbounded output into the UI; past this we keep draining the pipe (so the -/// child doesn't block on a full buffer) but discard the overflow. #[cfg(unix)] const MAX_STDOUT: usize = 256 * 1024; -/// How long a parsed result stays fresh in the cache. Reopening a menu (Tab, -/// close, Tab again) or re-triggering the same generator within this window -/// reuses the result instead of respawning the process. const CACHE_TTL: Duration = Duration::from_secs(5); -/// A candidate produced by a generator: the replacement text plus an optional -/// one-line description for the menu's second column. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Parsed { pub text: String, pub description: Option<String>, } -/// Run `script` (already joined to a `/bin/sh -c` command string) with `cwd` as -/// the working directory and return its parsed candidates. A cache hit for the -/// same `(script, cwd)` within [`CACHE_TTL`] skips the process entirely. -/// -/// Blocking; meant to be handed to the app's background executor. A non-zero -/// exit, a timeout, or a spawn failure all yield an empty vec (at most a -/// `log::debug`) — a broken generator degrades to "no dynamic suggestions", never -/// an error surfaced to the user. pub fn run(script: &str, cwd: &Path) -> Vec<Parsed> { if let Some(hit) = cache_get(script, cwd) { return hit; @@ -73,24 +31,12 @@ pub fn run(script: &str, cwd: &Path) -> Vec<Parsed> { out } -/// Kill-on-drop wrapper: whatever path leaves [`run_uncached`] — normal return, -/// timeout, or an unwind — the child is signalled and reaped rather than leaked -/// as a zombie holding the pipe open. -/// -/// The kill targets the child's *process group*, not just the child: `sh -c` -/// may fork the command rather than exec it (dash does), and killing only the -/// shell would leave a grandchild holding the stdout pipe open — the reader -/// thread would then block until the grandchild exits on its own, defeating -/// the timeout. The child is spawned as its own group leader (see -/// [`run_uncached`]), so `killpg(pid)` takes the whole tree down and the pipe -/// closes immediately. #[cfg(unix)] struct Reaped(std::process::Child); #[cfg(unix)] impl Reaped { fn kill_group(&mut self) { - // The child was made leader of a group whose pgid == its pid. unsafe { libc::killpg(self.0.id() as libc::pid_t, libc::SIGKILL) }; } } @@ -103,10 +49,6 @@ impl Drop for Reaped { } } -/// Generator scripts are POSIX `sh` + awk pipelines; there is nothing to run -/// them with on Windows, so the whole execution path compiles away to "no -/// dynamic suggestions" there. (Windows would need its own spec corpus with -/// PowerShell scripts — a separate effort, not a porting gap here.) #[cfg(not(unix))] fn run_uncached(_script: &str, _cwd: &Path) -> Vec<Parsed> { Vec::new() @@ -119,8 +61,6 @@ fn run_uncached(script: &str, cwd: &Path) -> Vec<Parsed> { .arg("-c") .arg(script) .current_dir(cwd) - // Own process group (pgid == child pid), so the timeout can kill the - // shell *and* anything it forked in one killpg — see [`Reaped`]. .process_group(0) .stdin(Stdio::null()) .stdout(Stdio::piped()) @@ -134,8 +74,6 @@ fn run_uncached(script: &str, cwd: &Path) -> Vec<Parsed> { } }; - // Drain stdout on a helper thread so a chatty generator can't wedge on a full - // pipe while we poll for exit, and so the read is bounded to `MAX_STDOUT`. let stdout = child.0.stdout.take(); let reader = std::thread::spawn(move || { let mut buf = Vec::new(); @@ -149,8 +87,6 @@ fn run_uncached(script: &str, cwd: &Path) -> Vec<Parsed> { let room = MAX_STDOUT - buf.len(); buf.extend_from_slice(&chunk[..n.min(room)]); } - // Past the cap we keep reading but discard, so the child - // isn't blocked writing into a full pipe. } Err(_) => break, } @@ -159,7 +95,6 @@ fn run_uncached(script: &str, cwd: &Path) -> Vec<Parsed> { buf }); - // Poll for exit against the wall clock; kill past the deadline. let start = Instant::now(); let status = loop { match child.0.try_wait() { @@ -174,7 +109,6 @@ fn run_uncached(script: &str, cwd: &Path) -> Vec<Parsed> { Err(_) => break None, } }; - // Killing closes the pipe, so the reader thread always finishes. let buf = reader.join().unwrap_or_default(); match status { @@ -190,10 +124,6 @@ fn run_uncached(script: &str, cwd: &Path) -> Vec<Parsed> { } } -/// Turn a generator's raw stdout into candidates. A [`registry`] parser keyed by -/// the exact joined `script` string wins when present; otherwise the default -/// splits on newlines. Kept separate from [`run`] so parsing is unit-testable -/// without spawning a process. pub fn parse(script: &str, stdout: &str) -> Vec<Parsed> { match registry(script) { Some(parser) => parser(stdout), @@ -201,9 +131,6 @@ pub fn parse(script: &str, stdout: &str) -> Vec<Parsed> { } } -/// The fallback parser: one candidate per non-empty line, trailing whitespace -/// trimmed, no description. This is the behavior the converter's docs promise for -/// any generator without a bespoke `postProcess` (which we drop at conversion). fn default_parse(stdout: &str) -> Vec<Parsed> { stdout .lines() @@ -216,38 +143,13 @@ fn default_parse(stdout: &str) -> Vec<Parsed> { .collect() } -/// A parser for one generator's output. type Parser = fn(&str) -> Vec<Parsed>; -/// The bespoke-parser table: exact joined `script` string → parser. These are the -/// hand-ports of the Fig specs' JS `postProcess` functions, which the converter -/// drops — without them a generator whose stdout isn't already one-clean-token- -/// per-line falls to [`default_parse`] and pastes garbage (a JSON blob, a `NAME` -/// header, a two-column table) onto the command line. -/// -/// Keyed by the *exact* string produced by joining a spec's `script` token array -/// with single spaces, so a spec regeneration that changes a script string -/// silently orphans its parser — [`tests::every_registry_key_exists_in_corpus`] -/// walks the shipped specs and fails loudly if a key here no longer appears. -/// -/// Three shapes of decision, per the guiding principle "text is exactly the token -/// that belongs at this position, description is optional context": -/// - clean output → **no entry** (the default is already correct); -/// - a cheap line/JSON transform → a `parse_*` port; -/// - hopelessly noisy output → [`parse_suppress`] (empty vec — bad candidates -/// are worse than none). -/// -/// A linear scan is fine: `parse` runs once per menu-open, and the table is tens -/// of entries. #[rustfmt::skip] const REGISTRY: &[(&str, Parser)] = &[ - // --- ssh / scp / sftp / rsync ------------------------------------------- - // Both host scripts already print one host per line; the only value we add is - // the second-column label. (Shared verbatim across ssh/scp/sftp/rsync.) ("awk '/^[|#@]/{next}{n=split($1,a,\",\");for(i=1;i<=n;i++){h=a[i];sub(/^\\[/,\"\",h);sub(/\\]:[0-9]+$/,\"\",h);sub(/\\]$/,\"\",h);print h}}' ~/.ssh/known_hosts 2>/dev/null | sort -u", parse_ssh_host), ("cat ~/.ssh/config $(awk 'tolower($1)==\"include\"{for(i=2;i<=NF;i++){p=$i;if(p ~ /^~\\//){sub(/^~/,ENVIRON[\"HOME\"],p)}else if(p !~ /^\\//){p=ENVIRON[\"HOME\"]\"/.ssh/\"p}print p}}' ~/.ssh/config 2>/dev/null) 2>/dev/null | awk 'tolower($1)==\"host\"{for(i=2;i<=NF;i++){if($i !~ /[*?!]/)print $i}}' | sort -u", parse_ssh_host), - // --- git ---------------------------------------------------------------- ("git --no-optional-locks branch --no-color --sort=-committerdate", parse_git_branch), ("git branch --no-color", parse_git_branch), ("git --no-optional-locks branch -a --no-color --sort=-committerdate", parse_git_branch_all), @@ -259,49 +161,25 @@ const REGISTRY: &[(&str, Parser)] = &[ ("git --no-optional-locks log --oneline", parse_oneline), ("git rev-list --all --oneline", parse_oneline), ("git config --get-regexp .*", parse_git_config), - // `tag --list` and `diff --cached --name-only` are one clean token per line → - // no entry (default is correct). - // --- npm / pnpm / bun / yarn -------------------------------------------- - // The same `cat package.json` script backs both `run <script>` positions and - // several package-name positions (pnpm/yarn), but the registry keys on the - // script string alone and can't see the arg context. We parse `.scripts` — - // the flagship `npm run <Tab>` case; in a package-name position it's a lossy - // but never-garbage answer (a raw-JSON default would be pure garbage there). ("bash -c until [[ -f package.json ]] || [[ $PWD = '/' ]]; do cd ..; done; cat package.json", parse_package_scripts), - // turbo `run <Tab>`: task names live under `tasks` (v2) or `pipeline` (v1). ("bash -c until [[ ( -f turbo.json || $PWD = '/' ) ]]; do cd ..; done; cat turbo.json", parse_turbo_tasks), - // yarn/pnpm dependency listings are trees/JSON with legends and headers — - // nothing a line parse can salvage. ("yarn list --depth=0 --json", parse_suppress), ("yarn config list", parse_suppress), ("pnpm ls", parse_suppress), - // --- cargo -------------------------------------------------------------- - // `cargo metadata` is one giant JSON doc; pull workspace/dep package names. ("cargo metadata --format-version 1 --no-deps", parse_cargo_packages), ("cargo metadata --format-version 1", parse_cargo_packages), - // `read-manifest` feeds a `--features` position: the `.features` map keys. ("cargo read-manifest", parse_cargo_features), - // `rustc --print target-list` and the `cargo install --list | …` pipe are - // already one token per line → no entry. - // --- rustup ------------------------------------------------------------- ("rustup toolchain list", parse_rustup_toolchain), ("rustup target list", parse_rustup_target), - // The GitHub-releases JSON (curl/gh fallback) is an object array we can't turn - // into clean version tokens by line-parsing. ("bash -c if command -v gh > /dev/null; then gh api -H \"Accept: application/vnd.github+json\" /repos/rust-lang/rust/releases; else curl -sfL -H \"Accept: application/vnd.github+json\" https://api.github.com/repos/rust-lang/rust/releases; fi", parse_suppress), - // --- gh ----------------------------------------------------------------- ("gh alias list", parse_colon_kv), ("gh pr list --json=number,title,headRefName,state", parse_gh_pr), ("gh api graphql --paginate -f query='query($endCursor: String) { viewer { repositories(first: 100, after: $endCursor) { nodes { isPrivate, nameWithOwner, description } pageInfo { hasNextPage endCursor }}}}' --jq .data.viewer.repositories.nodes[]", parse_gh_repos), - // --- docker / podman ---------------------------------------------------- - // `--format '{{ json . }}'` prints one JSON object per line; pull the field - // that names the object. Absent field → the line is skipped (self-suppressing - // if a template's shape ever surprises us). ("docker ps --format {{ json . }}", parse_docker_names), ("docker ps -a --format {{ json . }}", parse_docker_names), ("docker ps --filter status=paused --format {{ json . }}", parse_docker_names), @@ -327,47 +205,30 @@ const REGISTRY: &[(&str, Parser)] = &[ ("podman images -a --format {{ json . }}", parse_docker_image_json), ("podman images --format {{.Repository}} {{.Size}} {{.Tag}} {{.ID}}", parse_docker_image_cols), - // --- kubectl / k9s ------------------------------------------------------ - // `get namespaces` prints a `NAME STATUS AGE` table (k9s uses it); take the - // first column, drop the header. (`-o name` / `-o custom-columns=:…` variants - // are already clean → no entry.) ("kubectl get namespaces", parse_kube_table), - // --- tmux --------------------------------------------------------------- - // Every `tmux ls*` line is `<target>: <details>`; the target before the colon - // is the token, the rest is context. ("tmux ls", parse_colon_kv), ("tmux lsb", parse_colon_kv), ("tmux lsc", parse_colon_kv), ("tmux lsp", parse_colon_kv), ("tmux lsw", parse_colon_kv), - // --- misc package managers (item-8 sweep) ------------------------------- ("apt list --installed", parse_apt), ("apt list --upgradable", parse_apt), - ("pip list", parse_pip), // both the `pip` and `pip3` specs key this exact string. + ("pip list", parse_pip), ("conda list", parse_conda_pkg), ("conda env list", parse_conda_env), - ("conda config --show", parse_suppress), // YAML-ish key/value + nested lists. + ("conda config --show", parse_suppress), ("terraform workspace list", parse_terraform_workspace), ]; -/// Script keys deliberately *not* expected to appear verbatim in the shipped -/// corpus — parsers we key for a synthetic or hand-authored command string. The -/// corpus-membership test skips these. Empty today: every registry key is drawn -/// straight from the corpus, so this exists only to give a regeneration an -/// escape hatch instead of a hard failure. #[cfg_attr(not(test), allow(dead_code))] const SYNTHETIC_KEYS: &[&str] = &[]; -/// Look up the bespoke parser for a script by its exact joined command string. -/// A miss falls to [`default_parse`]. fn registry(script: &str) -> Option<Parser> { REGISTRY.iter().find(|(k, _)| *k == script).map(|(_, p)| *p) } -/// Marker-strip for `git branch`-style listings: a `* ` (current) / `+ ` -/// (worktree) marker or the two-space indent, trailing whitespace trimmed. fn strip_branch_marker(line: &str) -> &str { line.strip_prefix("* ") .or_else(|| line.strip_prefix("+ ")) @@ -376,8 +237,6 @@ fn strip_branch_marker(line: &str) -> &str { .trim_end() } -/// The two SSH host scripts already emit one host per line; label them so the -/// menu's second column reads "SSH Host" instead of nothing. fn parse_ssh_host(stdout: &str) -> Vec<Parsed> { stdout .lines() @@ -390,8 +249,6 @@ fn parse_ssh_host(stdout: &str) -> Vec<Parsed> { .collect() } -/// `git branch`: strip the marker, drop the `(HEAD detached …)` pseudo-entry -/// (not a checkout target), label the rest "branch". fn parse_git_branch(stdout: &str) -> Vec<Parsed> { stdout .lines() @@ -408,11 +265,6 @@ fn parse_git_branch(stdout: &str) -> Vec<Parsed> { .collect() } -/// `git branch -a`: local branches plus `remotes/<remote>/<branch>` lines. We -/// strip the `remotes/` prefix so a remote entry reads `origin/main` — the form -/// `git checkout` accepts (DWIM to a tracking branch, or a valid detached -/// checkout) — and drop the `remotes/origin/HEAD -> origin/main` alias line -/// (the ` -> ` marks it as a symref, not its own checkout target). fn parse_git_branch_all(stdout: &str) -> Vec<Parsed> { stdout .lines() @@ -433,9 +285,6 @@ fn parse_git_branch_all(stdout: &str) -> Vec<Parsed> { .collect() } -/// `git branch -r`: remote-tracking refs (`origin/main`), already without the -/// `remotes/` prefix. Just drop the indent and the `origin/HEAD -> origin/main` -/// symref alias. fn parse_git_branch_remote(stdout: &str) -> Vec<Parsed> { stdout .lines() @@ -452,10 +301,6 @@ fn parse_git_branch_remote(stdout: &str) -> Vec<Parsed> { .collect() } -/// `git status --short`: each line is a two-char `XY` status, a space, then the -/// path (from column 3). A rename is `R old -> new`; the *new* path is the one -/// that exists on disk, so we take the right side of ` -> `. The status code -/// becomes the description. fn parse_git_status(stdout: &str) -> Vec<Parsed> { stdout .lines() @@ -480,8 +325,6 @@ fn parse_git_status(stdout: &str) -> Vec<Parsed> { .collect() } -/// `git remote -v`: `<name>\t<url> (fetch|push)`. Every remote appears twice -/// (a fetch and a push line); dedupe on the name, keep the URL as description. fn parse_git_remote(stdout: &str) -> Vec<Parsed> { let mut seen = std::collections::HashSet::new(); stdout @@ -501,9 +344,6 @@ fn parse_git_remote(stdout: &str) -> Vec<Parsed> { .collect() } -/// `git config --get-regexp ^alias.`: `alias.<name> <expansion>`. Strip the -/// `alias.` prefix to leave the token you'd type after `git`, keep the expansion -/// as description. fn parse_git_alias(stdout: &str) -> Vec<Parsed> { stdout .lines() @@ -521,8 +361,6 @@ fn parse_git_alias(stdout: &str) -> Vec<Parsed> { .collect() } -/// `git config --get-regexp .*`: `<key> <value>`. The key is the token; the -/// value (which may itself contain spaces, or be empty) is the description. fn parse_git_config(stdout: &str) -> Vec<Parsed> { stdout .lines() @@ -543,8 +381,6 @@ fn parse_git_config(stdout: &str) -> Vec<Parsed> { .collect() } -/// `git log/rev-list --oneline`: `<short-hash> <subject>`. Hash is the token, -/// subject is the description. fn parse_oneline(stdout: &str) -> Vec<Parsed> { stdout .lines() @@ -561,9 +397,6 @@ fn parse_oneline(stdout: &str) -> Vec<Parsed> { .collect() } -/// A `<key>: <rest>` line format, shared by everything whose token is the text -/// before the first colon: `git stash list` (`stash@{0}: WIP …`), every -/// `tmux ls*` (`<target>: <details>`), and `gh alias list` (`co: pr checkout`). fn parse_colon_kv(stdout: &str) -> Vec<Parsed> { stdout .lines() @@ -582,8 +415,6 @@ fn parse_colon_kv(stdout: &str) -> Vec<Parsed> { .collect() } -/// `cat package.json` → the `.scripts` object: keys are `npm run` targets, the -/// command line each maps to is the description. fn parse_package_scripts(stdout: &str) -> Vec<Parsed> { let Ok(Value::Object(root)) = serde_json::from_str::<Value>(stdout) else { return Vec::new(); @@ -600,8 +431,6 @@ fn parse_package_scripts(stdout: &str) -> Vec<Parsed> { .collect() } -/// `cat turbo.json` → task names: `tasks` (turbo ≥2) or `pipeline` (turbo 1). -/// (JSONC comments would fail the strict parse and yield nothing — acceptable.) fn parse_turbo_tasks(stdout: &str) -> Vec<Parsed> { let Ok(Value::Object(root)) = serde_json::from_str::<Value>(stdout) else { return Vec::new(); @@ -622,8 +451,6 @@ fn parse_turbo_tasks(stdout: &str) -> Vec<Parsed> { } } -/// `cargo metadata` → `.packages[].name`, deduped, version as description. With -/// `--no-deps` this is the workspace members; without, every resolved dependency. fn parse_cargo_packages(stdout: &str) -> Vec<Parsed> { let Ok(root) = serde_json::from_str::<Value>(stdout) else { return Vec::new(); @@ -647,8 +474,6 @@ fn parse_cargo_packages(stdout: &str) -> Vec<Parsed> { .collect() } -/// `cargo read-manifest` → the `.features` map keys (feature names for a -/// `--features` position). fn parse_cargo_features(stdout: &str) -> Vec<Parsed> { let Ok(root) = serde_json::from_str::<Value>(stdout) else { return Vec::new(); @@ -665,8 +490,6 @@ fn parse_cargo_features(stdout: &str) -> Vec<Parsed> { } } -/// `gh pr list --json=…` → a JSON array of PRs. The number is the canonical -/// `gh pr <number>` token; the title is context. fn parse_gh_pr(stdout: &str) -> Vec<Parsed> { let Ok(Value::Array(prs)) = serde_json::from_str::<Value>(stdout) else { return Vec::new(); @@ -683,8 +506,6 @@ fn parse_gh_pr(stdout: &str) -> Vec<Parsed> { .collect() } -/// `gh api graphql … --jq …nodes[]` → one repo object per line; the token is -/// `nameWithOwner`, the description its blurb. fn parse_gh_repos(stdout: &str) -> Vec<Parsed> { stdout .lines() @@ -702,7 +523,6 @@ fn parse_gh_repos(stdout: &str) -> Vec<Parsed> { .collect() } -/// First present, non-empty string field among `keys` in a JSON object. fn json_field<'a>(obj: &'a serde_json::Map<String, Value>, keys: &[&str]) -> Option<&'a str> { keys.iter().find_map(|k| match obj.get(*k) { Some(Value::String(s)) if !s.is_empty() => Some(s.as_str()), @@ -710,10 +530,6 @@ fn json_field<'a>(obj: &'a serde_json::Map<String, Value>, keys: &[&str]) -> Opt }) } -/// `docker/podman … --format '{{ json . }}'` for containers, networks, volumes, -/// nodes, secrets, services, stacks, plugins, contexts: one JSON object per line -/// named by `Names` (ps) or `Name` (everything else). Lines without either field -/// are skipped (self-suppressing). fn parse_docker_names(stdout: &str) -> Vec<Parsed> { stdout .lines() @@ -730,8 +546,6 @@ fn parse_docker_names(stdout: &str) -> Vec<Parsed> { .collect() } -/// `docker/podman image ls --format '{{ json . }}'`: `Repository[:Tag]` is the -/// token, image `ID` the description. A dangling `<none>` repository is dropped. fn parse_docker_image_json(stdout: &str) -> Vec<Parsed> { stdout .lines() @@ -754,9 +568,6 @@ fn parse_docker_image_json(stdout: &str) -> Vec<Parsed> { .collect() } -/// `docker/podman images --format '{{.Repository}} {{.Size}} {{.Tag}} {{.ID}}'`: -/// space-positional, so `Repository[:Tag]` is the token and the `ID` the -/// description. A `<none>` repository (dangling image) is dropped. fn parse_docker_image_cols(stdout: &str) -> Vec<Parsed> { stdout .lines() @@ -778,8 +589,6 @@ fn parse_docker_image_cols(stdout: &str) -> Vec<Parsed> { .collect() } -/// `kubectl get namespaces` (used by k9s): a `NAME STATUS AGE` table. Take the -/// first column, skip the header row. fn parse_kube_table(stdout: &str) -> Vec<Parsed> { stdout .lines() @@ -796,8 +605,6 @@ fn parse_kube_table(stdout: &str) -> Vec<Parsed> { .collect() } -/// `rustup toolchain list`: `<toolchain> (active, default)` — the name is the -/// first token, the parenthetical (if any) the description. fn parse_rustup_toolchain(stdout: &str) -> Vec<Parsed> { stdout .lines() @@ -819,8 +626,6 @@ fn parse_rustup_toolchain(stdout: &str) -> Vec<Parsed> { .collect() } -/// `rustup target list`: `<triple> (installed)` — the triple is the token; note -/// whether it's already installed. fn parse_rustup_target(stdout: &str) -> Vec<Parsed> { stdout .lines() @@ -839,8 +644,6 @@ fn parse_rustup_target(stdout: &str) -> Vec<Parsed> { .collect() } -/// `apt list …`: `<pkg>/<repo>,… <version> <arch> [flags]`, plus a leading -/// `Listing…` note. Take the package name before the `/`, version as context. fn parse_apt(stdout: &str) -> Vec<Parsed> { stdout .lines() @@ -860,8 +663,6 @@ fn parse_apt(stdout: &str) -> Vec<Parsed> { .collect() } -/// `pip list`: a `Package Version …` table. Skip the header and its `----` -/// underline; first column is the token, version the description. fn parse_pip(stdout: &str) -> Vec<Parsed> { stdout .lines() @@ -879,8 +680,6 @@ fn parse_pip(stdout: &str) -> Vec<Parsed> { .collect() } -/// `conda list`: `# …` comment headers then `<name> <version> <build> <channel>`. -/// First column is the token, version the description. fn parse_conda_pkg(stdout: &str) -> Vec<Parsed> { stdout .lines() @@ -898,8 +697,6 @@ fn parse_conda_pkg(stdout: &str) -> Vec<Parsed> { .collect() } -/// `conda env list`: `# …` headers then `<name> [*] <path>` (the `*` marks the -/// active env). First column is the env name, its path the description. fn parse_conda_env(stdout: &str) -> Vec<Parsed> { stdout .lines() @@ -917,8 +714,6 @@ fn parse_conda_env(stdout: &str) -> Vec<Parsed> { .collect() } -/// `terraform workspace list`: `* default` / ` prod` — strip the active marker -/// and indent. fn parse_terraform_workspace(stdout: &str) -> Vec<Parsed> { stdout .lines() @@ -935,16 +730,10 @@ fn parse_terraform_workspace(stdout: &str) -> Vec<Parsed> { .collect() } -/// A deliberate no-op for scripts whose real output is hopelessly noisy for -/// line-parsing (JSON trees, YAML dumps, legend-prefixed listings): returning -/// nothing is better than pasting garbage onto the command line. fn parse_suppress(_stdout: &str) -> Vec<Parsed> { Vec::new() } -/// The TTL cache: `(script, cwd)` → its parsed results and when they were -/// computed. Keeps reopening a menu from respawning a process for a result we -/// just produced; entries are pruned lazily on lookup once past [`CACHE_TTL`]. type Cache = Mutex<HashMap<(String, PathBuf), (Instant, Vec<Parsed>)>>; fn cache() -> &'static Cache { @@ -980,7 +769,6 @@ mod tests { fn default_parser_splits_lines_trims_and_skips_blanks() { let out = parse("echo whatever", "alpha\nbeta \n\n gamma\n"); let texts: Vec<&str> = out.iter().map(|p| p.text.as_str()).collect(); - // Trailing whitespace trimmed, leading kept, empty lines dropped. assert_eq!(texts, vec!["alpha", "beta", " gamma"]); assert!(out.iter().all(|p| p.description.is_none())); } @@ -1000,34 +788,21 @@ mod tests { ); } - // The execution tests spawn real `/bin/sh` children, so they are Unix-only — - // matching `run_uncached`, which compiles to "no results" everywhere else. - #[cfg(unix)] #[test] fn run_captures_stdout_lines() { - // A unique cwd so this never collides with a cached entry from a sibling - // test; the script itself ignores cwd. let cwd = std::env::temp_dir(); let out = run("printf 'a\\nb\\n'", &cwd); let texts: Vec<&str> = out.iter().map(|p| p.text.as_str()).collect(); assert_eq!(texts, vec!["a", "b"]); } - /// `sh -c` may *fork* the command instead of exec'ing it (dash does), so this - /// also proves the group-kill takes the grandchild down: were only the shell - /// killed, the grandchild's open pipe would hold the reader (and us) for the - /// full five seconds. #[cfg(unix)] #[test] fn run_times_out_and_kills_the_child() { let cwd = std::env::temp_dir(); let start = Instant::now(); - // The trailing `true` stops the shell exec-optimizing the single command - // away, so `sleep` is always a *forked* grandchild. let out = run("sleep 5; true", &cwd); - // Timed out → no results, and we returned near the deadline rather than - // waiting the full five seconds (the child was killed). assert!(out.is_empty()); assert!( start.elapsed() < Duration::from_secs(3), @@ -1043,12 +818,6 @@ mod tests { assert!(out.is_empty()); } - // --- registry integrity ------------------------------------------------- - - /// Walk the shipped specs the same way the enumeration script does and assert - /// every registry key (bar explicitly-synthetic ones) still appears verbatim, - /// so a spec regeneration that renames a `script` fails here loudly instead of - /// silently orphaning a parser. #[test] fn every_registry_key_exists_in_corpus() { use std::collections::HashSet; @@ -1123,9 +892,6 @@ mod tests { } } - // --- per-parser ports --------------------------------------------------- - - /// Small helper: `(text, description)` pairs, so assertions read as tables. fn pairs(v: &[Parsed]) -> Vec<(&str, Option<&str>)> { v.iter() .map(|p| (p.text.as_str(), p.description.as_deref())) @@ -1236,18 +1002,14 @@ mod tests { #[test] fn colon_kv_serves_stash_tmux_and_gh_alias() { - // stash: token before the FIRST colon, remainder (itself colon-bearing) is - // context. assert_eq!( pairs(&parse_colon_kv("stash@{0}: WIP on main: hello\n")), vec![("stash@{0}", Some("WIP on main: hello"))] ); - // tmux ls assert_eq!( pairs(&parse_colon_kv("main: 3 windows (created ...)\n")), vec![("main", Some("3 windows (created ...)"))] ); - // gh alias list assert_eq!( pairs(&parse_colon_kv("co: pr checkout\n")), vec![("co", Some("pr checkout"))] @@ -1262,7 +1024,6 @@ mod tests { let mut got = pairs(&scripts); got.sort(); assert_eq!(got, vec![("build", Some("tsc")), ("test", Some("jest"))]); - // Not JSON → nothing, rather than pasting the raw bytes. assert!(parse_package_scripts("not json").is_empty()); } diff --git a/src/terminal/git_diff.rs b/src/terminal/git_diff.rs index 237f8b32..ac82dcc4 100644 --- a/src/terminal/git_diff.rs +++ b/src/terminal/git_diff.rs @@ -1,190 +1,51 @@ -//! The full working-tree diff behind the sidebar's `+N −N` counts: `git diff -//! HEAD` parsed into files → hunks → lines, for the read-only diff overlay -//! (see [`crate::ui::diff_overlay`], which owns how it is opened) that covers -//! the terminal. -//! -//! Same discipline as [`git_status`](crate::terminal::git_status): every -//! invocation goes through the shared [`git_status::git`] helper — so it runs -//! on the pane's own [`Host`], read-only via `GIT_OPTIONAL_LOCKS=0` — on a -//! background executor, and is never trusted to be fast; the UI shows the -//! previous snapshot (or a loading state) until a probe lands. Asking the pane's -//! host rather than this machine is also what makes the overlay work at all for -//! a pane whose repository lives somewhere else. -//! -//! And never trusted to be *small*, either. `git diff HEAD` is the one git read -//! in this app whose output scales with the working tree rather than with what -//! the UI can show, so two things bound it: the read is incremental -//! ([`Host::git_lines`](crate::ui::host_ops::Host::git_lines)) rather than -//! buffered whole, and the parser retains at most [`MAX_LINES_PER_FILE`] per -//! file and [`MAX_TOTAL_LINES`] / [`MAX_FILES_WITH_HUNKS`] across the -//! repository. The `+`/`−` counts deliberately escape all of it: they are -//! compared against `git diff --numstat` to decide whether the overlay is -//! stale, so a capped total would disagree forever and re-probe in a loop. - use std::path::{Path, PathBuf}; use crate::terminal::git_status; use crate::ui::host_ops::Host; -/// Cap on parsed diff lines per file. A generated lockfile or vendored blob -/// can be tens of thousands of lines; past this the file's hunks stop and the -/// overlay shows a "truncated" notice instead of building a giant element -/// tree. Generous enough that real hand-written changes never hit it. pub const MAX_LINES_PER_FILE: usize = 2000; -/// Repo-wide cap on retained diff lines. [`MAX_LINES_PER_FILE`] bounds one -/// pathological file; this bounds the *sum*, which is the shape a working tree -/// full of agent edits actually takes — two hundred files of three hundred -/// lines each never trip the per-file cap yet retain 60k `DiffLine`s, each an -/// owned `String`. Past this the parser keeps counting `+`/`−` (the header -/// numbers must stay honest — see the note in [`parse_unified`]) but stops -/// retaining line text. pub const MAX_TOTAL_LINES: usize = 20_000; -/// Repo-wide cap on how many files keep their hunks. A branch that renames a -/// vendored tree can list thousands of files whose diffs are each tiny; every -/// one of them still costs a `Vec<Hunk>`. Files past this keep their header row -/// (path, status, counts) and lose only the body. pub const MAX_FILES_WITH_HUNKS: usize = 500; -/// A file's added+removed size at which the overlay collapses it by default -/// (GitHub's "Load diff" treatment) — the user can still expand it by click. pub const AUTO_COLLAPSE_LINES: u32 = 400; -/// Repo-wide counterpart to [`AUTO_COLLAPSE_LINES`]: once the snapshot's -/// *retained* lines exceed this, every file starts collapsed and the overlay -/// leads with the oversized-diff summary. Two differences from the per-file -/// threshold matter here — this counts context lines too (they are rendered, -/// so they are what costs), and it is a sum, so many medium files add up the -/// way one big file does. -/// -/// Counting context is also why this sits well above the row count that first -/// looks alarming. A hunk carries three lines of context each side by default, -/// so an ordinary afternoon — forty files, a handful of small hunks each — -/// retains four to six lines for every line it actually changed: at 2000 the -/// threshold fired on a tree whose `+N −N` read about 400, which is nobody's -/// idea of a diff too big to open. The number to compare against is -/// [`MAX_TOTAL_LINES`], the point past which the parser stops retaining at all; -/// this is deliberately a large fraction of it, because collapsing everything is -/// the heavier intervention of the two and should not arrive first by much. pub const AUTO_COLLAPSE_TOTAL_LINES: usize = 8_000; -/// The same idea by file count. Set well clear of a busy-but-ordinary tree — -/// forty changed files of a few lines each is a normal afternoon and must still -/// open expanded — because rows, not cards, are what actually cost: this axis -/// only catches the tree so wide that a card per file is itself the problem. pub const AUTO_COLLAPSE_TOTAL_FILES: usize = 100; -/// Hard ceiling on file cards the overlay builds at all. Past this the list is -/// cut and a "… and N more" line stands in for the tail, so the element tree -/// stays bounded no matter what the working tree looks like. Also bounds the -/// untracked section, which is the same one-row-per-path shape. pub const MAX_RENDERED_FILES: usize = 300; -/// Repo-wide cap on retained untracked paths. `git ls-files --others` answers -/// with the whole tree of anything not yet ignored — a fresh clone before -/// `node_modules` / `target` / `.venv` reach `.gitignore` reports tens of -/// thousands of paths — and every one of them is an owned `String` here and a -/// row in the overlay. The count stays exact past the cap -/// ([`DiffSnapshot::untracked_total`]); only the retained list is bounded. pub const MAX_UNTRACKED: usize = 500; -/// Why a file's hunks stop short of its real diff. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum Truncation { - /// This one file exceeded [`MAX_LINES_PER_FILE`]. PerFile, - /// The repo-wide budget ([`MAX_TOTAL_LINES`] / [`MAX_FILES_WITH_HUNKS`]) - /// ran out — the file itself may be small. Budget, } -/// One parsed `git diff HEAD` for a repo, plus the untracked files `diff` -/// itself can't see. This is the overlay's whole model. #[derive(Clone, PartialEq, Eq, Debug, Default)] pub struct DiffSnapshot { - /// The work-tree root the diff was taken in. pub root: PathBuf, - /// Branch name (or short sha when detached) — the overlay's title. pub branch: String, - /// Changed tracked files, in `git diff` order. pub files: Vec<FileDiff>, - /// Untracked (new, un-added) paths, repo-relative. Listed by name only: - /// `git diff HEAD` has no blob to diff them against, and agents create - /// files constantly — hiding them would make the overlay look like it - /// lost work. - /// - /// Capped at [`MAX_UNTRACKED`]; [`untracked_count`](Self::untracked_count) - /// is the honest total. pub untracked: Vec<String>, - /// How many untracked paths `git ls-files --others` actually reported, - /// which is not `untracked.len()` once the cap bites. Same discipline as - /// [`totals`](Self::totals): what we *retain* is budgeted, what we *report* - /// stays exact, because a count that shrank with the budget would read as - /// files having disappeared. Read it through - /// [`untracked_count`](Self::untracked_count). pub untracked_total: usize, - /// One of the two reads behind this snapshot did not complete, so the - /// emptiness below it means "we could not look", not "there is nothing". - /// - /// A failed probe still produces a snapshot — the overlay keeps its branch - /// and its shape, and the next refresh fills it in, which is better than - /// blanking. But an empty file list renders as *Working tree clean*, and - /// that sentence is a claim about the repository: saying it because a read - /// timed out tells the reader their changes are gone. This is the bit that - /// keeps the two apart. - /// - /// Newly reachable, too. A buffered read either arrived or errored; a - /// stream can also be refused ([`MAX_CONCURRENT_GIT_STREAMS`] on one - /// connection) or go silent mid-diff, so the empty-because-broken case is - /// no longer rare enough to leave conflated with the empty-because-clean - /// one. - /// - /// [`MAX_CONCURRENT_GIT_STREAMS`]: tty7_core::daemon::control::MAX_CONCURRENT_GIT_STREAMS pub read_failed: bool, } impl DiffSnapshot { - /// Total added/removed line counts across all files — the overlay's - /// header numbers, matching the sidebar's `+N −N` by construction (both - /// sum per-file counts of the same `HEAD` diff). - /// - /// Deliberately *not* affected by any truncation: the parser keeps counting - /// past every cap, because this number is compared against the status - /// cache's `git diff --numstat` totals to decide whether the overlay is - /// stale. A capped total would never match, and the overlay would re-probe - /// in a loop. pub fn totals(&self) -> (u32, u32) { self.files .iter() .fold((0, 0), |(a, r), f| (a + f.added, r + f.removed)) } - /// The true number of untracked paths, whether or not the retained list was - /// capped. Falls back to the retained length so a snapshot built by hand - /// (tests, `..Default::default()`) can't under-report — the fallback is - /// never wrong, since `untracked_total` is only ever ≥ `untracked.len()`. pub fn untracked_count(&self) -> usize { self.untracked_total.max(self.untracked.len()) } - /// Every whole-snapshot number the render path needs, in one pass. - /// - /// The overlay asks six questions of a landed snapshot — is it oversized, - /// what are the totals, how many lines were retained, did the budget fire, - /// did the per-file cap fire, how many untracked — and it asks them while - /// building the element tree, so they run on the UI thread on every render. - /// `files` is deliberately *not* capped (only hunks are, by - /// [`MAX_FILES_WITH_HUNKS`]), so answering them one accessor at a time is - /// six walks over a list whose length is the size of the working tree, on - /// exactly the tree this whole module exists to keep responsive. Hence one - /// walk answering all of them, and no per-question accessors to drift from - /// it. - /// - /// Computed rather than cached in the struct on purpose: the snapshot is - /// `PartialEq` and built by hand all over the tests with - /// `..Default::default()`, and a stored count would silently read as zero - /// for every one of them. pub fn stats(&self) -> DiffStats { let mut added = 0u32; let mut removed = 0u32; @@ -206,9 +67,6 @@ impl DiffSnapshot { totals: (added, removed), retained_lines, untracked_count, - // Changed files only. Untracked paths are bounded where they are - // *rendered* instead — see the field's own note for why collapsing - // bodies is the wrong lever for them. oversized: self.files.len() > AUTO_COLLAPSE_TOTAL_FILES || retained_lines > AUTO_COLLAPSE_TOTAL_LINES, budget_exhausted, @@ -217,46 +75,16 @@ impl DiffSnapshot { } } -/// Everything [`DiffSnapshot::stats`] answers in one walk. See that method for -/// why the render path wants them together. #[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] pub struct DiffStats { - /// `(added, removed)` — exact, never affected by truncation. See - /// [`DiffSnapshot::totals`]. pub totals: (u32, u32), - /// Diff lines actually kept, summed over every file. This — not - /// [`totals`](Self::totals) — is what the overlay would have to build rows - /// for if every file were expanded, so it's what the render-side thresholds - /// compare against. Counted one `len()` per hunk, not per line. pub retained_lines: usize, - /// True untracked count, cap or no cap. See - /// [`DiffSnapshot::untracked_count`]. pub untracked_count: usize, - /// Too big to open expanded: every file starts collapsed and the overlay - /// leads with the "too large to render efficiently" summary. Not a refusal — - /// individual files still expand by click, which is the escape hatch the - /// summary points at. - /// - /// Changed files and retained lines only. Untracked paths are the same - /// one-row-per-entry cost, but this is not the lever that answers them: - /// collapsing every file body leaves the untracked section rendering exactly - /// as many rows as before, because that section has no bodies to fold. A - /// tree with an un-ignored `node_modules` and three edited files would have - /// folded away the three cheap things and kept the expensive one — while - /// telling the reader their working tree was too large to render. The - /// untracked list is bounded where it is actually built, by - /// [`MAX_UNTRACKED`] on retention and [`MAX_RENDERED_FILES`] on rows. pub oversized: bool, - /// The repo-wide budget dropped hunks that a smaller diff would have kept — - /// the overlay says so, so a missing body reads as a cap rather than as tty7 - /// losing the change. pub budget_exhausted: bool, - /// Any file was cut at [`MAX_LINES_PER_FILE`] — the sibling axis, which the - /// oversized banner has to name separately because the two compose. pub per_file_truncated: bool, } -/// How a file changed vs `HEAD` — drives the status glyph in its header row. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum FileStatus { Added, @@ -265,31 +93,20 @@ pub enum FileStatus { Renamed, } -/// One changed file: its header-row facts plus the parsed hunks. #[derive(Clone, PartialEq, Eq, Debug)] pub struct FileDiff { - /// New path (repo-relative); for a deletion, the old path. pub path: String, - /// The pre-rename path, only when `status == Renamed`. pub old_path: Option<String>, pub status: FileStatus, - /// Lines added / removed in this file (counted from the parsed hunks). pub added: u32, pub removed: u32, - /// Binary file — no hunks, the header row says "binary" instead. pub binary: bool, - /// Hunk parsing stopped short of the file's real diff, and why; the overlay - /// appends a "truncated" footer under the last hunk. `None` means the body - /// is complete. pub truncated: Option<Truncation>, pub hunks: Vec<Hunk>, } -/// One `@@` hunk: its header line (kept verbatim, function context and all) -/// and the diff lines under it. #[derive(Clone, PartialEq, Eq, Debug)] pub struct Hunk { - /// The full `@@ -a,b +c,d @@ …` line as git printed it. pub header: String, pub lines: Vec<DiffLine>, } @@ -301,38 +118,18 @@ pub enum LineKind { Removed, } -/// One diff line with the gutter numbers it carries: an added line has only a -/// new number, a removed line only an old one, context both. #[derive(Clone, PartialEq, Eq, Debug)] pub struct DiffLine { pub kind: LineKind, pub old_no: Option<u32>, pub new_no: Option<u32>, - /// The line's text, without the leading `+`/`-`/space marker. pub text: String, } -/// Probe the full diff snapshot for `cwd` on `host`, or `None` when it isn't -/// inside a git work tree. Blocking (three `git` invocations, three round trips -/// on a remote host) — call it through `HostOps`, never on the UI thread. pub fn probe(host: &dyn Host, cwd: &Path) -> Option<DiffSnapshot> { - // No `exists` pre-check: a vanished cwd fails the first invocation with - // `NotFound`, which is the same `None` one round trip cheaper. - // Doubles as the "is this a repo" gate, same as the status probe. let root = git_status::git(host, cwd, &["rev-parse", "--show-toplevel"])?; let root = PathBuf::from(root.trim_end_matches(['\n', '\r'])); let branch = git_status::branch_name(host, cwd)?; - // `-M` folds a delete+add pair back into one rename entry; `--no-ext-diff` - // keeps a configured external diff tool from replacing the parseable - // unified format. A failed diff (e.g. racing a concurrent git write) still - // yields a snapshot — an empty file list with the branch — rather than - // hiding the overlay; the next refresh fills it in. - // Incremental, not buffered: `git diff HEAD` on a big work tree prints tens - // of megabytes, and holding all of it before parsing could drop what it - // doesn't keep is the cost issue #239 measured. `git_lines` funnels through - // the pane's own host either way — it is streaming where the transport can - // carry it and buffered where it can't, so the lines seen here are the same - // either way. let mut parser = DiffParser::default(); let diffed = host.git_lines( cwd, @@ -340,20 +137,9 @@ pub fn probe(host: &dyn Host, cwd: &Path) -> Option<DiffSnapshot> { &mut |line| parser.push_line(line), ); let files = match diffed { - // A failed diff (e.g. racing a concurrent git write) still yields a - // snapshot — an empty file list with the branch — rather than hiding - // the overlay; the next refresh fills it in. A *partial* read is - // discarded rather than shown: the stream is reassembled into lines, so - // what a cut one is missing is the tail of the diff, and half a diff - // presented as a whole one is worse than none. Ok(Some(0)) => parser.finish(), _ => Vec::new(), }; - // `--full-name` pins paths to the repo root regardless of which - // subdirectory the pane sits in, matching the diff's path space. Capped for - // the same reason the diff is: `--others` walks everything not yet ignored, - // so one un-ignored dependency directory answers with tens of thousands of - // paths. let mut untracked: Vec<String> = Vec::new(); let mut untracked_total = 0usize; let listed = host.git_lines( @@ -367,8 +153,6 @@ pub fn probe(host: &dyn Host, cwd: &Path) -> Option<DiffSnapshot> { }, ); if !matches!(listed, Ok(Some(0))) { - // A failed listing is "we don't know", not "there are none" — same - // shape as the diff above. untracked.clear(); untracked_total = 0; } @@ -382,14 +166,6 @@ pub fn probe(host: &dyn Host, cwd: &Path) -> Option<DiffSnapshot> { }) } -/// Parse `git diff` unified output into per-file structures. Tolerant by -/// construction: unrecognized metadata lines between the `diff --git` header -/// and the first hunk (modes, index, similarity) are simply skipped, so a git -/// version printing extra headers degrades to "fewer facts", never a panic. -/// -/// The whole-string form the tests drive the parser through. [`probe`] feeds -/// [`DiffParser`] a line at a time off the host's streaming read instead, so no -/// caller in the app ever holds the full diff as one `String`. #[cfg(test)] pub fn parse_unified(out: &str) -> Vec<FileDiff> { let mut parser = DiffParser::default(); @@ -399,34 +175,18 @@ pub fn parse_unified(out: &str) -> Vec<FileDiff> { parser.finish() } -/// The unified-diff parser as an incremental state machine, so `git diff`'s -/// output can be consumed a line at a time off a pipe rather than buffered -/// whole. Enforces both the per-file and the repo-wide retention budgets; see -/// [`MAX_LINES_PER_FILE`] and [`MAX_TOTAL_LINES`]. #[derive(Default)] pub struct DiffParser { files: Vec<FileDiff>, - /// Line-number counters for the hunk currently being filled. old_no: u32, new_no: u32, - /// Lines consumed by the current file's hunks, for the per-file cap. file_lines: usize, - /// Lines retained across every file so far, for the repo-wide cap. total_lines: usize, - /// Files that actually kept at least one hunk, for the repo-wide file cap. - /// Counted at the first `@@`, not at the file header: a pure rename or a - /// binary blob has no body and must not spend the budget on nothing. files_with_hunks: usize, - /// Whether the last `@@` header opened a body we're still inside. Tracked - /// explicitly rather than inferred from "the file has hunks": a file the - /// budget truncated never gets a `Hunk` pushed, but its lines still have to - /// be *counted*, and a removed line whose own text starts with `--- ` must - /// not be mistaken for the file header it looks like. in_hunk: bool, } impl DiffParser { - /// Feed one line of `git diff` output (no trailing newline). pub fn push_line(&mut self, line: &str) { if let Some(rest) = line.strip_prefix("diff --git ") { let (old_p, new_p) = parse_git_header_paths(rest); @@ -445,9 +205,8 @@ impl DiffParser { return; } let Some(file) = self.files.last_mut() else { - return; // preamble before any header (shouldn't happen) + return; }; - // ── File-level metadata between the header and the first hunk ────── if line.starts_with("new file mode") { file.status = FileStatus::Added; return; @@ -464,28 +223,17 @@ impl DiffParser { file.binary = true; return; } - // `--- a/x` / `+++ b/x` repeat what the header said; `rename to`, - // `index`, modes and similarity scores add nothing we render. But only - // skip them *outside* hunk bodies — a removed line legitimately starts - // with `--- ` inside one. if !self.in_hunk && (line.starts_with("--- ") || line.starts_with("+++ ") || !is_hunk_line(line)) && !line.starts_with("@@") { return; } - // ── Hunks ─────────────────────────────────────────────────────────── if line.starts_with("@@") { - // A truncated file still enters the body — its lines have to be - // counted — it just doesn't get a `Hunk` to keep them in. self.in_hunk = true; if file.truncated.is_some() { return; } - // The repo-wide budget is charged here rather than at the file - // header, so a file with no body at all (a pure rename, a binary - // blob) neither consumes the file budget nor gets flagged as - // truncated for a body it never had. let first_hunk = file.hunks.is_empty(); if (first_hunk && self.files_with_hunks >= MAX_FILES_WITH_HUNKS) || self.total_lines >= MAX_TOTAL_LINES @@ -506,19 +254,14 @@ impl DiffParser { return; } if !self.in_hunk { - return; // stray content outside any hunk + return; } let (kind, text) = match line.as_bytes().first() { Some(b'+') => (LineKind::Added, &line[1..]), Some(b'-') => (LineKind::Removed, &line[1..]), Some(b' ') => (LineKind::Context, &line[1..]), - // `\ No newline at end of file` and anything else: not a diff line. _ => return, }; - // Count added/removed *before* the truncation gate: the caps are about - // element volume, but the header numbers must stay honest (they are - // compared against `--numstat` to detect staleness), so lines past a cap - // still count even though they're never kept. match kind { LineKind::Added => file.added += 1, LineKind::Removed => file.removed += 1, @@ -566,28 +309,16 @@ impl DiffParser { self.total_lines += 1; } - /// The parsed files. A truncated file still counts +/− for its whole diff - /// (the parser keeps counting past every cap), so totals stay consistent - /// with `--numstat`. pub fn finish(self) -> Vec<FileDiff> { self.files } } -/// Whether a line can only belong to a hunk body (`+`/`-`/space/`\` lead). fn is_hunk_line(line: &str) -> bool { matches!(line.as_bytes().first(), Some(b'+' | b'-' | b' ' | b'\\')) || line.is_empty() } -/// Split the `a/old b/new` tail of a `diff --git` header into the two paths. -/// -/// Plain names split on the ` b/` separator; paths with spaces work because -/// git quotes *those* (`"a/x y" "b/x y"`), handled by the quoted branch. A -/// path containing a literal ` b/` unquoted is ambiguous in git's own format — -/// we take the last occurrence, matching git's convention of the `b/` side -/// naming the current file. fn parse_git_header_paths(rest: &str) -> (String, String) { - // Quoted form: "a/path with spaces" "b/path with spaces". if rest.starts_with('"') { let parts: Vec<String> = parse_quoted_pair(rest); if parts.len() == 2 { @@ -599,12 +330,9 @@ fn parse_git_header_paths(rest: &str) -> (String, String) { let new = &rest[idx + 1..]; return (strip_prefix_ab(old), strip_prefix_ab(new)); } - // Unsplittable — show the whole tail rather than nothing. (rest.to_string(), rest.to_string()) } -/// Parse up to two double-quoted strings (git's C-style quoting, minus octal -/// escapes — good enough for spaces, the common case). fn parse_quoted_pair(s: &str) -> Vec<String> { let mut parts = Vec::new(); let mut cur = String::new(); @@ -631,7 +359,6 @@ fn parse_quoted_pair(s: &str) -> Vec<String> { parts } -/// Drop the `a/` / `b/` prefix git puts on header paths. fn strip_prefix_ab(p: &str) -> String { p.strip_prefix("a/") .or_else(|| p.strip_prefix("b/")) @@ -639,7 +366,6 @@ fn strip_prefix_ab(p: &str) -> String { .to_string() } -/// The old/new start line numbers from a `@@ -a,b +c,d @@` header. fn parse_hunk_starts(line: &str) -> Option<(u32, u32)> { let rest = line.strip_prefix("@@ -")?; let (old_part, rest) = rest.split_once(" +")?; @@ -684,8 +410,6 @@ index 5555555..6666666 100644 Binary files a/img.png and b/img.png differ "; - /// The sample covers modify / add / delete / binary; statuses, counts, and - /// hunk line numbers all land where the unified format says they should. #[test] fn parses_the_four_file_shapes() { let files = parse_unified(SAMPLE); @@ -699,7 +423,6 @@ Binary files a/img.png and b/img.png differ assert_eq!(m.hunks[0].header, "@@ -10,4 +10,5 @@ fn main() {"); let lines = &m.hunks[0].lines; assert_eq!(lines.len(), 5); - // Context line carries both numbers, tracking the hunk starts. assert_eq!((lines[0].old_no, lines[0].new_no), (Some(10), Some(10))); assert_eq!(lines[1].kind, LineKind::Removed); assert_eq!(lines[1].old_no, Some(11)); @@ -708,7 +431,6 @@ Binary files a/img.png and b/img.png differ assert_eq!(lines[2].new_no, Some(11)); assert_eq!(lines[3].new_no, Some(12)); assert_eq!(lines[3].text, "let c = 3;"); - // Trailing context resumes both counters. assert_eq!((lines[4].old_no, lines[4].new_no), (Some(12), Some(13))); let a = &files[1]; @@ -724,7 +446,6 @@ Binary files a/img.png and b/img.png differ assert!(b.hunks.is_empty()); } - /// Renames keep both paths and don't show phantom +/− lines. #[test] fn parses_renames() { let out = "\ @@ -741,7 +462,6 @@ rename to new/name.rs assert_eq!((files[0].added, files[0].removed), (0, 0)); } - /// Quoted headers (paths with spaces) resolve to the unquoted paths. #[test] fn parses_quoted_paths() { let out = "diff --git \"a/has space.txt\" \"b/has space.txt\"\n"; @@ -750,7 +470,6 @@ rename to new/name.rs assert_eq!(files[0].old_path, None); } - /// A `--- ` *content* line inside a hunk is a removed line, not metadata. #[test] fn triple_dash_content_line_is_kept() { let out = "\ @@ -766,13 +485,9 @@ index 1111111..2222222 100644 let lines = &files[0].hunks[0].lines; assert_eq!(lines.len(), 2); assert_eq!(lines[1].kind, LineKind::Removed); - // Raw `---- a heading rule` = marker `-` + content `--- a heading rule`: - // content that *itself* starts with `--- ` must not be eaten as metadata. assert_eq!(lines[1].text, "--- a heading rule"); } - /// Past the per-file cap the hunks stop growing and the file is flagged, - /// but the +/− counts keep counting so the header stays honest. #[test] fn caps_lines_per_file_but_keeps_counting() { let mut out = String::from( @@ -788,7 +503,6 @@ index 1111111..2222222 100644 assert_eq!(kept, MAX_LINES_PER_FILE); } - /// `\ No newline at end of file` markers are skipped, not rendered. #[test] fn skips_no_newline_marker() { let out = "\ @@ -807,7 +521,6 @@ index 1..2 100644 assert_eq!((files[0].added, files[0].removed), (1, 1)); } - /// Totals sum per-file counts. #[test] fn snapshot_totals() { let snap = DiffSnapshot { @@ -817,8 +530,6 @@ index 1..2 100644 assert_eq!(snap.totals(), (4, 2)); } - /// Build `files` synthetic modified files of `lines_each` added lines — - /// the "many medium files" shape the per-file cap alone can't bound. fn many_files(files: usize, lines_each: usize) -> String { let mut out = String::new(); for f in 0..files { @@ -832,13 +543,8 @@ index 1..2 100644 out } - /// The repo-wide budget bounds retained lines even when no single file is - /// anywhere near [`MAX_LINES_PER_FILE`] — the case the reporter of #239 - /// called out as the one the per-file cap misses. #[test] fn repo_wide_budget_caps_retained_lines() { - // 300 files × 300 lines = 90k lines, none of which trips the 2000-line - // per-file cap. let files = parse_unified(&many_files(300, 300)); assert_eq!(files.len(), 300, "every file keeps its header row"); let retained: usize = files @@ -857,9 +563,6 @@ index 1..2 100644 ); } - /// Budget or no budget, the +/− totals must stay exact: they are compared - /// against `git diff --numstat` to decide whether the overlay is stale, and - /// a short count would make every comparison disagree and re-probe forever. #[test] fn repo_wide_budget_keeps_totals_exact() { let snap = DiffSnapshot { @@ -870,17 +573,12 @@ index 1..2 100644 assert!(snap.stats().budget_exhausted); } - /// The file cap keeps a rename-the-world diff from allocating a `Vec<Hunk>` - /// per file, while every file still lists its path and counts. #[test] fn repo_wide_budget_caps_files_with_hunks() { - // One line each: far under the line budget, so only the file cap can - // stop this. let files = parse_unified(&many_files(MAX_FILES_WITH_HUNKS + 50, 1)); assert_eq!(files.len(), MAX_FILES_WITH_HUNKS + 50); let with_hunks = files.iter().filter(|f| !f.hunks.is_empty()).count(); assert_eq!(with_hunks, MAX_FILES_WITH_HUNKS); - // The tail still counts, so totals stay honest. assert_eq!( files.iter().map(|f| f.added).sum::<u32>(), (MAX_FILES_WITH_HUNKS + 50) as u32 @@ -888,8 +586,6 @@ index 1..2 100644 assert_eq!(files.last().unwrap().truncated, Some(Truncation::Budget)); } - /// A small diff is untouched by the budget — the setting-enabled, - /// small-working-tree case must behave exactly as before. #[test] fn small_diff_is_not_truncated() { let snap = DiffSnapshot { @@ -901,7 +597,6 @@ index 1..2 100644 assert!(!snap.stats().budget_exhausted); } - /// `oversized` trips on either axis: many files, or many retained lines. #[test] fn oversized_trips_on_files_or_lines() { let by_files = DiffSnapshot { @@ -910,10 +605,6 @@ index 1..2 100644 }; assert!(by_files.stats().oversized); - // Few files, but past the line threshold. Spread over enough files to - // clear it without any one of them hitting `MAX_LINES_PER_FILE` first — - // the per-file cap would otherwise decide this test's outcome instead of - // the repo-wide threshold it is about. let per_file = MAX_LINES_PER_FILE / 2; let by_lines = DiffSnapshot { files: parse_unified(&many_files( @@ -927,8 +618,6 @@ index 1..2 100644 assert!(by_lines.stats().oversized); } - /// Even a budget-truncated file keeps a removed line whose *content* starts - /// with `--- ` out of the metadata skip — that line still has to count. #[test] fn truncated_file_counts_dash_prefixed_content() { let mut out = many_files(MAX_FILES_WITH_HUNKS, 1); @@ -943,12 +632,8 @@ index 1..2 100644 assert_eq!((late.added, late.removed), (0, 1), "but the line counts"); } - /// The untracked cap keeps the retained list bounded while the reported - /// count stays exact — the same split the diff side already makes between - /// what is retained and what is counted. #[test] fn untracked_is_capped_but_counted() { - // What `probe` builds while streaming `ls-files --others`. let mut untracked: Vec<String> = Vec::new(); let mut untracked_total = 0usize; for i in 0..(MAX_UNTRACKED * 3) { @@ -970,13 +655,6 @@ index 1..2 100644 ); } - /// Measurement harness for issue #239 finding 1 — run with - /// `cargo test --release -- --ignored --nocapture bench_stream_vs_buffer`. - /// - /// Measures the shipped code paths against real git output in this - /// repository: `git_output` (what `Host::git` uses) versus `git_stream` + - /// `LineSplitter` (what `Host::git_lines` uses on a local host), both fed - /// to the same [`DiffParser`]. #[test] #[ignore = "measurement, not an assertion"] fn bench_stream_vs_buffer() { @@ -984,7 +662,6 @@ index 1..2 100644 use tty7_core::core::git::{LineSplitter, git_output, git_stream}; let here = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); - // Real, sizeable git output: patches for the last few hundred commits. let args = ["log", "-p", "-n", "400", "--no-color"]; let t = Instant::now(); @@ -1025,8 +702,6 @@ index 1..2 100644 assert_eq!(buffered_files.len(), streamed_files.len()); } - /// Measurement harness for issue #239, not a correctness gate — run with - /// `cargo test -- --ignored --nocapture bench_parse_budget`. #[test] #[ignore = "measurement, not an assertion"] fn bench_parse_budget() { diff --git a/src/terminal/git_status.rs b/src/terminal/git_status.rs index 82d45436..f53fa4be 100644 --- a/src/terminal/git_status.rs +++ b/src/terminal/git_status.rs @@ -1,85 +1,26 @@ -//! A lightweight git snapshot for a pane's working directory — the current -//! branch and the working-tree diff size — rendered as the sidebar row's third -//! line (`⎇ feat/x +6 −5`): each session fronted with its branch and change -//! count. -//! -//! Snapshots are shared through [`GitStatusCache`], a process-wide map keyed -//! by machine *and* work-tree root: every pane whose cwd resolves into the same -//! repo reads the *same* entry, so ten tabs in one repo show one truth, -//! refreshed by whichever pane probed last — not ten drifting copies refreshed -//! on ten different schedules. Probes stay per-trigger (a pane's cwd change, -//! command end, or agent-turn end — see [`crate::terminal::view`]) but are -//! deduped in-flight, so simultaneous triggers from panes in the same directory -//! cost one `git` invocation, not one per pane. -//! -//! **Machine is part of every key.** `/home/me/proj` is a real path on this -//! laptop and on the box it is SSH'd into, and they are different repositories -//! on different branches. A cache keyed by path alone would serve one's branch -//! line for the other, so every table here is a [`ByHost`] and every entry -//! point takes the [`HostId`] the cwd belongs to. -//! -//! The probe itself — [`probe`], [`branch_name`], and the [`git`] invocation -//! every git read in tty7 funnels through — lives in `tty7-core`, because the -//! remote server has to answer the same questions the same way. All three now -//! take the [`Host`](crate::ui::host_ops::Host) to ask, which is what lets a -//! pane on another machine report its own repository instead of reporting -//! nothing. What stays here is the cache, which is a gpui `Global`. - use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; pub use crate::core::git::{GitStatus, RepoSnapshot, branch_name, git, probe}; use crate::ui::host_ops::{ByHost, HostId, InFlight}; -/// The process-wide snapshot store (a gpui [`Global`](gpui::Global)): pane -/// cwds grouped by work-tree root, one [`GitStatus`] per root. Views read -/// through [`status_for`](Self::status_for); the probe loop in -/// [`crate::terminal::view`] brackets each background probe with -/// [`begin_probe`](Self::begin_probe) / [`finish_probe`](Self::finish_probe). -/// -/// In-flight dedup is keyed by cwd (the root isn't known until a first probe -/// answers), so two panes at the same directory share one probe; panes in -/// *different* subdirectories of one repo can still race a redundant probe — -/// rare, and both land the same answer. #[derive(Default)] pub struct GitStatusCache { - /// cwd → its work-tree root; `None` = probed and found not to be a repo. roots: ByHost<PathBuf, Option<PathBuf>>, - /// work-tree root → the repository home it belongs to (see - /// [`RepoSnapshot::home`]). Identity for a plain checkout; the main - /// root for a linked worktree, so the sidebar groups them together. homes: ByHost<PathBuf, PathBuf>, - /// root → the snapshot every pane in that tree shares. status: ByHost<PathBuf, GitStatus>, - /// Probes in flight, and which of those were re-triggered while flying — - /// so concurrent triggers fold into one invocation and the newest - /// trigger's state is still observed. Keyed by `(host, cwd)`: two machines - /// at the same path are two independent probes. probes: InFlight<(HostId, PathBuf)>, - /// When each cwd's last probe *landed*, for the throttle that opportunistic - /// triggers go through ([`begin_probe_throttled`](Self::begin_probe_throttled)). last_probe: ByHost<PathBuf, Instant>, } impl gpui::Global for GitStatusCache {} impl GitStatusCache { - /// The snapshot for a pane at `cwd`: resolved through its work-tree root, - /// so every pane in the same repo answers identically. `None` before the - /// first probe lands or when `cwd` isn't in a repo. pub fn status_for(&self, host: HostId, cwd: &Path) -> Option<GitStatus> { let root = self.roots.get(host, cwd)?.as_ref()?; self.status.get(host, root).cloned() } - /// What the cache *knows* about the repository `cwd` belongs to, - /// three-valued for the sidebar's repo grouping: `None` = no probe has - /// answered yet (the caller should keep whatever grouping it had, not - /// reshuffle on a guess); `Some(None)` = probed and confirmed outside any - /// work tree; `Some(Some(home))` = probed and inside the repo at `home`. - /// `home` is the repository home, not the work-tree root — a linked - /// worktree answers with the main checkout's root, so every worktree of - /// one repo lands in one sidebar group. pub fn known_repo_for(&self, host: HostId, cwd: &Path) -> Option<Option<PathBuf>> { let root = self.roots.get(host, cwd)?; Some(root.as_ref().map(|root| { @@ -90,39 +31,16 @@ impl GitStatusCache { })) } - /// Claim a probe for `cwd`. `false` means one is already in flight — the - /// caller must *not* spawn another; the landed flight will reprobe once - /// (the cwd is marked dirty) so this trigger's state still gets observed. pub fn begin_probe(&mut self, host: HostId, cwd: &Path) -> bool { let key = (host, cwd.to_path_buf()); if self.probes.begin(key.clone()) { true } else { - // Already flying: mark it superseded so the landing asks for one - // more run rather than dropping this trigger's state. self.probes.invalidate(&key); false } } - /// Claim an *opportunistic* probe for `cwd`: one triggered by a cheap, - /// frequent signal — the window regaining focus, an agent finishing a tool - /// call — rather than by a rare edge like a command ending. - /// - /// Unlike [`begin_probe`](Self::begin_probe) this declines instead of - /// queueing: a probe already in flight, or one against a repo probed less - /// than `min_interval` ago, drops the trigger entirely (no dirty mark, no - /// rerun). That's the whole point of the two entry points — the rare edges - /// must never be missed, while these signals repeat on their own, so a - /// count that's a second stale beats a `git` storm across every pane of a - /// repo the moment the user alt-tabs back. - /// - /// The throttle counts per *repo*, not per cwd (see - /// [`throttle_key`](Self::throttle_key)), and the claim stamps the clock - /// rather than waiting for the landing: without that, a dozen panes - /// scattered over one repo's subdirectories would all claim in the same - /// instant — each of them passing a throttle no probe had answered yet — - /// and produce a dozen identical full-repo diffs. pub fn begin_probe_throttled( &mut self, host: HostId, @@ -145,19 +63,6 @@ impl GitStatusCache { true } - /// What the opportunistic throttle counts against: the work-tree root once - /// some probe has answered for `cwd`, and `cwd` itself before that. - /// - /// The counts a probe produces are repo-wide — `git diff --numstat HEAD` - /// ignores which subdirectory it ran in — so panes at `repo/`, `repo/src` - /// and `repo/docs` are three ways of asking one question, and want one - /// shared clock rather than one each. In-flight dedup stays keyed by cwd: - /// it brackets a specific spawn, and [`finish_probe`](Self::finish_probe) - /// has to be able to release exactly what was claimed. - /// - /// Before any probe has landed the root is simply unknown, so the first - /// sweep over a repo still costs one probe per distinct cwd; every sweep - /// after that collapses to one. fn throttle_key<'a>(&'a self, host: HostId, cwd: &'a Path) -> &'a Path { match self.roots.get(host, cwd) { Some(Some(root)) => root, @@ -165,23 +70,13 @@ impl GitStatusCache { } } - /// Fold a landed probe for `cwd` into the cache. A failed diff inside a - /// live repo keeps the root's previous counts (a transient `git` error is - /// not "the tree went clean"). Returns whether the cwd was re-triggered - /// while this probe flew — the caller should start one more probe. pub fn finish_probe( &mut self, host: HostId, cwd: &Path, snapshot: Option<RepoSnapshot>, ) -> bool { - // `finish` both retires the claim and reports whether it survived: it - // answers "still current", so the rerun this function promises is its - // negation. let rerun = !self.probes.finish(&(host, cwd.to_path_buf())); - // Re-stamp on landing so the gap is measured from fresh counts, and - // under the root this probe just resolved — which is how a cwd first - // learns to share its repo's clock (at claim time it had none). let key = match &snapshot { Some(snap) => snap.root.clone(), None => self.throttle_key(host, cwd).to_path_buf(), @@ -207,8 +102,6 @@ impl GitStatusCache { self.homes.insert(host, snap.root.clone(), snap.home); self.roots.insert(host, cwd.to_path_buf(), Some(snap.root)); } - // Not a repo (or the dir vanished). The root's entry stays for - // other cwds that still live in it. None => { self.roots.insert(host, cwd.to_path_buf(), None); } @@ -221,8 +114,6 @@ impl GitStatusCache { mod tests { use super::*; - /// This machine — the host every pre-existing case implicitly used, back - /// when there was only one. const L: HostId = HostId::LOCAL; fn snap(root: &str, branch: &str, counts: Option<(u32, u32)>) -> RepoSnapshot { @@ -234,7 +125,6 @@ mod tests { } } - /// A snapshot for a linked worktree: its own root, a shared repo home. fn wt_snap(root: &str, home: &str, branch: &str) -> RepoSnapshot { RepoSnapshot { root: PathBuf::from(root), @@ -243,15 +133,12 @@ mod tests { counts: Some((0, 0)), } } - /// Two cwds landing in the same work tree share one entry: a probe from - /// either updates what both read (the group-by-root contract). #[test] fn cwds_in_one_repo_share_a_snapshot() { let mut cache = GitStatusCache::default(); let (a, b) = (Path::new("/repo/sub/a"), Path::new("/repo")); cache.finish_probe(L, a, Some(snap("/repo", "main", Some((5, 2))))); cache.finish_probe(L, b, Some(snap("/repo", "main", Some((5, 2))))); - // A later probe from `a` refreshes the numbers `b` reads too. cache.finish_probe(L, a, Some(snap("/repo", "main", Some((200, 42))))); for cwd in [a, b] { let got = cache.status_for(L, cwd).unwrap(); @@ -259,12 +146,6 @@ mod tests { } } - /// The same absolute path on two machines is two repositories. `/src/app` - /// exists on this laptop and on the box it is SSH'd into, on different - /// branches with different diffs — and before the tables were keyed by - /// host, whichever probed last would have overwritten the other's branch - /// line. Dedup and the throttle are per host too: a probe flying for one - /// machine must not make the other's trigger silently vanish. #[test] fn one_path_on_two_machines_is_two_entries() { let mut cache = GitStatusCache::default(); @@ -283,10 +164,6 @@ mod tests { assert_eq!((local.branch.as_str(), local.added), ("main", 1)); assert_eq!((there.branch.as_str(), there.added), ("feat/x", 30)); - // The repo *home* — what the sidebar groups by — is resolved per host - // too. Here the same path is a plain checkout on one machine and a - // linked worktree of a different repository on the other; a shared - // `homes` table would have handed one machine's answer to the other. cache.finish_probe( remote, cwd, @@ -301,15 +178,12 @@ mod tests { Some(Some(PathBuf::from("/src/main"))) ); - // A probe in flight for one host leaves the other free to claim. assert!(cache.begin_probe(L, cwd)); assert!(cache.begin_probe(remote, cwd)); assert!(!cache.begin_probe(L, cwd), "same host, already flying"); assert!(cache.finish_probe(L, cwd, None), "…so it asks for a rerun"); assert!(!cache.finish_probe(remote, cwd, None), "the other did not"); - // …and the throttle clock is the host's own: one machine's fresh probe - // does not silence the other's. let gap = Duration::from_secs(60); assert!(!cache.begin_probe_throttled(L, cwd, gap), "just landed"); assert!( @@ -321,8 +195,6 @@ mod tests { assert!(cache.begin_probe_throttled(remote, other, gap)); } - /// A failed `git diff` (counts `None`) keeps the previous numbers rather - /// than rendering the tree as suddenly clean; the branch still updates. #[test] fn failed_diff_keeps_previous_counts() { let mut cache = GitStatusCache::default(); @@ -334,22 +206,17 @@ mod tests { assert_eq!((got.added, got.removed), (200, 42)); } - /// In-flight dedup: a second trigger while a probe flies doesn't claim a - /// new one, but marks the cwd dirty so the landing reports "go again". #[test] fn concurrent_triggers_fold_into_one_probe_then_rerun() { let mut cache = GitStatusCache::default(); let cwd = Path::new("/repo"); assert!(cache.begin_probe(L, cwd)); - assert!(!cache.begin_probe(L, cwd)); // deduped, marked dirty + assert!(!cache.begin_probe(L, cwd)); assert!(cache.finish_probe(L, cwd, Some(snap("/repo", "main", Some((1, 0)))))); - // The rerun claims cleanly and lands with nothing pending. assert!(cache.begin_probe(L, cwd)); assert!(!cache.finish_probe(L, cwd, Some(snap("/repo", "main", Some((1, 0)))))); } - /// A cwd that leaves the repo (dir deleted / not a work tree) stops - /// answering, without disturbing the root entry other cwds still use. #[test] fn non_repo_cwd_clears_only_itself() { let mut cache = GitStatusCache::default(); @@ -361,10 +228,6 @@ mod tests { assert!(cache.status_for(L, b).is_some()); } - /// The three-valued `known_repo_for` the sidebar's repo grouping reads: - /// unprobed → `None`, probed-and-in-a-repo → `Some(Some(home))`, - /// probed-and-not-a-repo → `Some(None)`. The three cases are what let a - /// sticky group key hold across an in-flight cd instead of flickering. #[test] fn known_repo_for_is_three_valued() { let mut cache = GitStatusCache::default(); @@ -376,20 +239,14 @@ mod tests { cache.finish_probe(L, repo, Some(snap("/repo", "main", Some((1, 0))))); cache.finish_probe(L, plain, None); - // Inside a work tree: the resolved repo home, wrapped twice. assert_eq!( cache.known_repo_for(L, repo), Some(Some(PathBuf::from("/repo"))) ); - // Probed and confirmed outside any repo: a definite "not a repo". assert_eq!(cache.known_repo_for(L, plain), Some(None)); - // Never probed: no answer yet — the caller keeps its sticky key. assert_eq!(cache.known_repo_for(L, unseen), None); } - /// Linked worktrees of one repository share a *group* (`known_repo_for` - /// answers the main root for both) while their *status* stays per work - /// tree — different branches never clobber each other. #[test] fn worktrees_share_a_repo_but_not_a_status() { let mut cache = GitStatusCache::default(); @@ -397,7 +254,6 @@ mod tests { cache.finish_probe(L, main, Some(wt_snap("/repo", "/repo", "main"))); cache.finish_probe(L, wt, Some(wt_snap("/repo/.wt/feat", "/repo", "feat/x"))); - // One sidebar group… assert_eq!( cache.known_repo_for(L, main), Some(Some(PathBuf::from("/repo"))) @@ -406,13 +262,9 @@ mod tests { cache.known_repo_for(L, wt), Some(Some(PathBuf::from("/repo"))) ); - // …two independent branch lines. assert_eq!(cache.status_for(L, main).unwrap().branch, "main"); assert_eq!(cache.status_for(L, wt).unwrap().branch, "feat/x"); } - /// The opportunistic path declines where the edge path queues: an in-flight - /// probe drops the trigger (and leaves nothing dirty, so no rerun), and a - /// probe that just landed rate-limits the next one. #[test] fn throttled_probes_decline_instead_of_queueing() { let mut cache = GitStatusCache::default(); @@ -420,24 +272,15 @@ mod tests { let gap = Duration::from_secs(60); assert!(cache.begin_probe_throttled(L, cwd, gap)); - // In flight: declined, and unlike `begin_probe` it doesn't mark dirty — - // the landing reports "nothing pending" rather than asking for a rerun. assert!(!cache.begin_probe_throttled(L, cwd, gap)); assert!(!cache.finish_probe(L, cwd, Some(snap("/repo", "main", Some((1, 0)))))); - // Landed just now: still inside the gap, so the next trigger is dropped. assert!(!cache.begin_probe_throttled(L, cwd, gap)); - // …but a zero gap always lets one through, and edge triggers never - // consult the throttle at all. assert!(cache.begin_probe_throttled(L, cwd, Duration::ZERO)); assert!(!cache.finish_probe(L, cwd, Some(snap("/repo", "main", Some((1, 0)))))); assert!(cache.begin_probe(L, cwd)); } - /// The throttle is per repo, not per cwd: panes sitting in different - /// subdirectories ask one question (the counts are repo-wide), so once the - /// cache knows where they live, a window activation costs one probe for - /// the repo rather than one per pane. #[test] fn throttle_collapses_subdirectories_of_one_repo() { let mut cache = GitStatusCache::default(); @@ -448,25 +291,18 @@ mod tests { ); let gap = Duration::from_secs(60); - // Nothing known yet, so each cwd is its own key and each gets a probe. for cwd in [top, src, docs] { assert!(cache.begin_probe_throttled(L, cwd, gap)); assert!(!cache.finish_probe(L, cwd, Some(snap("/repo", "main", Some((3, 1)))))); } - // Now all three resolve to `/repo`, so the next sweep collapses: the - // first pane to ask spends the probe and the rest ride on it. assert!(!cache.begin_probe_throttled(L, top, gap)); assert!(!cache.begin_probe_throttled(L, src, gap)); - // …and the claim itself is what stops the stampede — with the clock - // wound back far enough to let one through, the *others* still decline - // while it is in flight, even though nothing has landed yet. assert!(cache.begin_probe_throttled(L, docs, Duration::ZERO)); assert!(!cache.begin_probe_throttled(L, top, gap)); assert!(!cache.begin_probe_throttled(L, src, gap)); - // A pane elsewhere is untouched by any of it. let other = Path::new("/other"); assert!(cache.begin_probe_throttled(L, other, gap)); } diff --git a/src/terminal/highlight.rs b/src/terminal/highlight.rs index 077d6db6..fcd4dc02 100644 --- a/src/terminal/highlight.rs +++ b/src/terminal/highlight.rs @@ -1,34 +1,15 @@ -//! A small shell-command syntax highlighter for the command editor — tty7's own -//! highlighter, independent of any zsh highlighting plugin. -//! -//! It splits a line into contiguous spans whose concatenated text reproduces the -//! input exactly (whitespace included), tagging each with a [`TokenKind`] the -//! renderer maps to a color. The grammar is deliberately shallow — enough to -//! color commands, arguments, flags, paths, quoted strings, operators and -//! comments — not a real shell parser. - -/// What a span of the command line represents, for coloring. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TokenKind { - /// A command name: the first word, and the first word after a `|`/`&&`/`;`. Command, - /// A plain argument. Arg, - /// A `-f` / `--flag` option. Flag, - /// A word containing `/` (treated as a path). Path, - /// A single- or double-quoted string (quotes included). StringLit, - /// A shell operator: `| & ; < >` (and runs like `&&`, `||`, `>>`). Operator, - /// A `# …` comment to end of line. Comment, - /// Inter-token whitespace (kept so spans tile the whole line). Whitespace, } -/// A contiguous run of the line with a single [`TokenKind`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Span { pub text: String, @@ -39,14 +20,11 @@ fn is_operator(c: char) -> bool { matches!(c, '|' | '&' | ';' | '<' | '>') } -/// Split `line` into colored spans. Concatenating the spans' `text` yields `line`. pub fn highlight(line: &str) -> Vec<Span> { let chars: Vec<char> = line.chars().collect(); let n = chars.len(); let mut spans = Vec::new(); let mut i = 0; - // The next bare word is a command at the start of the line and right after a - // pipe / list operator. let mut expect_command = true; while i < n { @@ -65,7 +43,6 @@ pub fn highlight(line: &str) -> Vec<Span> { } if c == '#' { - // Comment to end of line. spans.push(Span { text: chars[i..].iter().collect(), kind: TokenKind::Comment, @@ -82,7 +59,7 @@ pub fn highlight(line: &str) -> Vec<Span> { text: chars[start..i].iter().collect(), kind: TokenKind::Operator, }); - expect_command = true; // a command follows the operator + expect_command = true; continue; } @@ -94,7 +71,7 @@ pub fn highlight(line: &str) -> Vec<Span> { i += 1; } if i < n { - i += 1; // include the closing quote + i += 1; } spans.push(Span { text: chars[start..i].iter().collect(), @@ -104,7 +81,6 @@ pub fn highlight(line: &str) -> Vec<Span> { continue; } - // A bare word: up to the next whitespace / operator / quote / comment. let start = i; while i < n && !chars[i].is_whitespace() @@ -141,7 +117,6 @@ mod tests { .collect() } - /// Spans must tile the line exactly. fn assert_tiles(line: &str) { let joined: String = highlight(line).into_iter().map(|s| s.text).collect(); assert_eq!(joined, line); @@ -160,10 +135,10 @@ mod tests { #[test] fn command_resets_after_pipe_and_operators() { let k = kinds("cat f | grep x"); - assert_eq!(k[0].1, TokenKind::Command); // cat - assert_eq!(k[2].1, TokenKind::Arg); // f - assert_eq!(k[4].1, TokenKind::Operator); // | - assert_eq!(k[6].1, TokenKind::Command); // grep (command after pipe) + assert_eq!(k[0].1, TokenKind::Command); + assert_eq!(k[2].1, TokenKind::Arg); + assert_eq!(k[4].1, TokenKind::Operator); + assert_eq!(k[6].1, TokenKind::Command); assert_tiles("cat f | grep x"); } @@ -171,7 +146,7 @@ mod tests { fn paths_and_comments() { let k = kinds("ls src/main.rs # look"); assert_eq!(k[0].1, TokenKind::Command); - assert_eq!(k[2].1, TokenKind::Path); // src/main.rs + assert_eq!(k[2].1, TokenKind::Path); assert!(k.iter().any(|(_, kind)| *kind == TokenKind::Comment)); assert_tiles("ls src/main.rs # look"); } @@ -193,14 +168,11 @@ mod tests { #[test] fn command_position_wins_over_flag_and_path_shapes() { - // The first word is always a Command, even when it looks like a flag or - // a path — command position takes precedence in the classifier. assert_eq!(kinds("-v")[0], ("-v".into(), TokenKind::Command)); assert_eq!( kinds("./run.sh now")[0], ("./run.sh".into(), TokenKind::Command) ); - // Off command position the same shapes classify as Flag / Path. let k = kinds("ls -v ./run.sh"); assert_eq!(k[2].1, TokenKind::Flag); assert_eq!(k[4].1, TokenKind::Path); @@ -208,14 +180,10 @@ mod tests { #[test] fn leading_operator_and_quoted_first_word() { - // An operator at the very start still tiles, and the word after it is a - // command. let k = kinds("| grep x"); assert_eq!(k[0], ("|".into(), TokenKind::Operator)); assert_eq!(k[2].1, TokenKind::Command); assert_tiles("| grep x"); - // A quoted string in command position stays a StringLit (quotes are not - // classified as commands), and the argument after it is a plain Arg. let k = kinds("'./a b' c"); assert_eq!(k[0], ("'./a b'".into(), TokenKind::StringLit)); assert_eq!(k[2].1, TokenKind::Arg); @@ -223,7 +191,6 @@ mod tests { #[test] fn multibyte_text_tiles_exactly() { - // Span boundaries are char-based; CJK args must reassemble losslessly. assert_tiles("echo 你好 世界 | grep 好"); let k = kinds("echo 你好"); assert_eq!(k[2], ("你好".into(), TokenKind::Arg)); diff --git a/src/terminal/history.rs b/src/terminal/history.rs index 24aeaf2f..39de3963 100644 --- a/src/terminal/history.rs +++ b/src/terminal/history.rs @@ -1,48 +1,14 @@ -//! Persistent command history, shared across sessions. -//! -//! Stored as a newline-delimited file at `~/.config/tty7/history` (the same config -//! dir as `config.json`), oldest first — simple, greppable, and good enough for -//! ↑/↓ recall and Ctrl+R search without pulling in a database. Each terminal loads -//! a snapshot on creation and appends as commands are submitted. -//! -//! Each new line is `<ts>\t<exit>\t<cwd>\t<command>` — when the command ran -//! (unix seconds), the exit code of that run (empty while unknown: the record is -//! written once the command finishes, but a pane can die before that), the -//! working directory it ran in (empty when unusable), then the command itself -//! (which may contain further tabs — it's the last field). The cwd feeds the -//! frecency ranking; ts and exit feed the Ctrl+R menu's "ran 3h ago" / failure -//! badges. Older `<cwd>\t<command>` lines and legacy bare commands still parse -//! fine, just without the missing fields. -//! -//! On load we also seed from the user's real shell histories (`~/.zsh_history`, -//! `~/.bash_history`, and `$HISTFILE`), so recall and completion work from the -//! very first launch — before tty7 has accumulated a history of its own. Those -//! files are read-only inputs; tty7 only ever writes its own file. zsh extended -//! and bash `HISTTIMEFORMAT` timestamps are carried over when present. - use crate::core::config::config_path; use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::time::SystemTime; -/// Keep at most this many entries when loading, so the file can't grow without -/// bound across months of use (and so a huge shell history can't flood recall). const MAX_ENTRIES: usize = 5000; -/// Frequency weight in the frecency score: how much a command's repeat count -/// matters relative to its recency. Recency contributes a normalized `0..1` -/// (oldest..newest); `FREQ_WEIGHT * ln(1 + count)` adds the frequency boost on -/// top, so a command run dozens of times outranks a once-typed recent line. const FREQ_WEIGHT: f64 = 0.6; -/// Bonus added when a command was previously run in the *current* working -/// directory. Larger than recency's `0..1` range and on par with a ~7× frequency -/// boost, so directory-local commands float up strongly without wholly drowning a -/// very frequent global one (`git status`, `ls`, …). const CWD_BONUS: f64 = 1.2; -/// One history line as parsed from disk, before de-duplication: the command, -/// plus whatever metadata its source format carried. struct Raw { cmd: String, cwd: Option<String>, @@ -61,20 +27,12 @@ impl Raw { } } -/// Last-known run metadata for one history line: when it last ran (unix -/// seconds) and that run's exit code (`None` when the run never completed -/// under tty7's watch — or predates exit tracking). #[derive(Clone, Copy, Default, PartialEq, Debug)] pub struct EntryMeta { pub ts: Option<u64>, pub exit: Option<i32>, } -/// Loaded history: the unique command lines (oldest-first, the source for ↑/↓ -/// recall and Ctrl+R search), plus the extra dimensions ranking and the Ctrl+R -/// menu need — per-line run `counts` (frequency), the set of directories each -/// line was run in (`cwds`, so we can favour commands used *here*), and the -/// last-run `meta` (timestamp + exit code) per line. pub struct History { pub entries: Vec<String>, pub counts: HashMap<String, u32>, @@ -82,15 +40,7 @@ pub struct History { pub meta: HashMap<String, EntryMeta>, } -/// Load history (oldest first), seeding from the user's shell histories and then -/// tty7's own file. Blanks are dropped and duplicates collapsed (keeping the most -/// recent occurrence), while occurrence counts, per-directory associations and -/// last-run metadata are tallied for ranking and the Ctrl+R menu. Returns empty -/// when nothing is readable. pub fn load() -> History { - // Shell history first (older, so it sits at a lower completion priority than - // commands actually run in tty7), then tty7's own file last (most recent). - // Shell-history lines carry no cwd; tty7's own lines do. let mut raw: Vec<Raw> = load_shell_history(); if let Some(path) = config_path("history") && let Ok(content) = std::fs::read_to_string(&path) @@ -100,26 +50,14 @@ pub fn load() -> History { normalize(raw) } -/// Whether `p` looks like an absolute path, recognizing **both** Unix (`/…`) and -/// Windows (`C:\…`, `\\server\…`) forms regardless of the host platform. The -/// std `Path::is_absolute` is host-specific (it rejects `/home/me` on Windows and -/// `C:\…` on Unix), but a history file could have been written on either OS, and -/// the `\t` tag separator can't appear in a path, so this lenient check is safe. fn looks_absolute(p: &str) -> bool { match p.as_bytes() { - // Unix absolute, or a Windows rooted / UNC path. [b'/' | b'\\', ..] => true, - // Windows drive path: `C:\`, `C:/`, or bare `C:`. [d, b':', ..] => d.is_ascii_alphabetic(), _ => false, } } -/// Parse one line of tty7's own history file. Current lines are -/// `<ts>\t<exit>\t<cwd>\t<command>` (ts all-digits; exit an integer or empty; -/// cwd absolute or empty; the command — the last field — may itself contain -/// tabs). Older `<cwd>\t<command>` lines and legacy bare commands still parse, -/// carrying only the fields they have. fn parse_own_line(line: &str) -> Raw { let mut f = line.splitn(4, '\t'); if let (Some(ts), Some(exit), Some(cwd), Some(cmd)) = (f.next(), f.next(), f.next(), f.next()) @@ -148,11 +86,6 @@ fn parse_own_line(line: &str) -> Raw { Raw::bare(line.to_string()) } -/// The frecency score of every entry (frequency × recency, plus a -/// current-directory bonus), index-aligned with `entries`. Shared by -/// [`rank_by_frecency`] and the Ctrl+R search's relevance blend. `entries` is -/// oldest-first as from [`load`]; `counts` and `cwds` are its companions; `cwd` -/// is the directory to favour (none → no directory bonus). pub fn frecency_scores( entries: &[String], counts: &HashMap<String, u32>, @@ -164,8 +97,6 @@ pub fn frecency_scores( .iter() .enumerate() .map(|(i, e)| { - // Recency: 0 for the oldest entry, 1 for the newest (position in the - // oldest-first list). Frequency: a diminishing-returns boost on count. let recency = if n <= 1 { 1.0 } else { @@ -173,7 +104,6 @@ pub fn frecency_scores( }; let count = f64::from(*counts.get(e).unwrap_or(&1)); let mut score = recency + FREQ_WEIGHT * (1.0 + count).ln(); - // Directory bonus: this command has been run here before. if let Some(cwd) = cwd && cwds.get(e).is_some_and(|dirs| dirs.contains(cwd)) { @@ -184,10 +114,6 @@ pub fn frecency_scores( .collect() } -/// Order unique history entries by *frecency*, most relevant first — the -/// ranking that drives ghost-text autosuggestion and the completion menu's -/// history recalls, so neither surfaces stale junk just because it was typed -/// once, recently. See [`frecency_scores`] for the inputs. pub fn rank_by_frecency( entries: &[String], counts: &HashMap<String, u32>, @@ -196,7 +122,6 @@ pub fn rank_by_frecency( ) -> Vec<String> { let scores = frecency_scores(entries, counts, cwds, cwd); let mut idx: Vec<usize> = (0..entries.len()).collect(); - // Higher score first; ties broken toward the more recent entry. idx.sort_by(|&a, &b| { scores[b] .partial_cmp(&scores[a]) @@ -206,8 +131,6 @@ pub fn rank_by_frecency( idx.into_iter().map(|i| entries[i].clone()).collect() } -/// Compact "how long ago" label for the Ctrl+R menu: `now` and `ts` are unix -/// seconds. Coarse on purpose — the menu row has room for `3h`, not a date. pub fn format_ago(now: u64, ts: u64) -> String { let s = now.saturating_sub(ts); let (n, unit) = if s < 60 { @@ -228,12 +151,6 @@ pub fn format_ago(now: u64, ts: u64) -> String { format!("{n}{unit}") } -/// Append one command to the history file (best effort): `ts` is when it ran -/// (unix seconds) and `exit` its exit code when the run completed under tty7's -/// watch. The cwd is recorded when it's a usable absolute path — one that can't -/// confuse the one-line format: no tab (the field separator) and no newline/CR -/// (which would split the record across lines). Commands containing a newline -/// are skipped, since the format is one-per-line. pub fn append(cmd: &str, cwd: Option<&Path>, ts: u64, exit: Option<i32>) { if cmd.contains('\n') { return; @@ -256,20 +173,10 @@ pub fn append(cmd: &str, cwd: Option<&Path>, ts: u64, exit: Option<i32>) { .append(true) .open(&path) { - // One `write_all` of the fully formatted record: `writeln!` on an - // unbuffered `File` can issue the text and the trailing newline as - // separate writes, and concurrent appenders (several panes, or several - // tty7 processes sharing the file) then interleave half-records even - // though O_APPEND keeps each individual write atomic. let _ = f.write_all(format!("{line}\n").as_bytes()); } } -/// Drop blanks and de-duplicate (keeping the most recent occurrence, so recall -/// and completion stay clean when shell history and tty7's own file overlap), -/// tallying how many times each line appears, which directories it ran in, and -/// its most recent run's metadata, then cap to the most recent `MAX_ENTRIES`. -/// Output entries are oldest-first. fn normalize(raw: Vec<Raw>) -> History { let mut counts: HashMap<String, u32> = HashMap::new(); let mut cwds: HashMap<String, HashSet<String>> = HashMap::new(); @@ -285,8 +192,6 @@ fn normalize(raw: Vec<Raw>) -> History { if let Some(cwd) = r.cwd { cwds.entry(line.to_string()).or_default().insert(cwd); } - // Newest-first scan: the first occurrence carrying any run metadata is - // the last known run — its ts and exit stay a matched pair. if (r.ts.is_some() || r.exit.is_some()) && !meta.contains_key(line) { meta.insert( line.to_string(), @@ -300,10 +205,9 @@ fn normalize(raw: Vec<Raw>) -> History { out.push(line.to_string()); } } - out.reverse(); // back to oldest-first + out.reverse(); if out.len() > MAX_ENTRIES { let cut = out.len() - MAX_ENTRIES; - // Drop the over-cap entries from the companion maps too, keeping them bounded. for r in out.drain(0..cut) { counts.remove(&r); cwds.remove(&r); @@ -318,11 +222,6 @@ fn normalize(raw: Vec<Raw>) -> History { } } -/// Read the user's bash/zsh histories (best effort), returning command lines -/// oldest-first. Reads the standard `~/.zsh_history` and `~/.bash_history` plus -/// `$HISTFILE` if set, and orders the files by modification time so the -/// most-recently-used shell's entries end up with the highest completion -/// priority. fn load_shell_history() -> Vec<Raw> { let mut files: Vec<PathBuf> = Vec::new(); let mut seen = HashSet::new(); @@ -339,7 +238,6 @@ fn load_shell_history() -> Vec<Raw> { add(home.join(".zsh_history")); add(home.join(".bash_history")); } - // Oldest-modified file first → newest last (highest recall/completion priority). files.sort_by_key(|p| { std::fs::metadata(p) .and_then(|m| m.modified()) @@ -349,27 +247,13 @@ fn load_shell_history() -> Vec<Raw> { let mut out = Vec::new(); for path in files { if let Ok(bytes) = std::fs::read(&path) { - // History files can hold non-UTF-8 bytes (zsh metafies some); lossy - // decoding keeps the rest usable. parse_shell_history(&String::from_utf8_lossy(&bytes), &mut out); } } out } -/// Parse one shell-history file into command lines, appending to `out`, -/// carrying over the timestamps the file records: zsh's extended-format prefix -/// (`: <start>:<elapsed>;cmd`) and bash's `HISTTIMEFORMAT` comment (`#<ts>` on -/// the line *before* the command). -/// -/// Each physical line becomes its own entry — we deliberately do *not* stitch -/// backslash-continued multi-line commands back together. bash stores multi-line -/// commands as separate lines anyway, and joining them would (a) embed newlines -/// that wreck the single-line completion menu's layout and (b) on bash, wrongly -/// swallow the following command. A few stray fragments from a zsh here-doc are a -/// fair price for robustness. fn parse_shell_history(content: &str, out: &mut Vec<Raw>) { - // A bash timestamp comment stamps the *next* command line. let mut pending_ts: Option<u64> = None; for raw in content.split('\n') { let line = raw.strip_suffix('\r').unwrap_or(raw); @@ -392,8 +276,6 @@ fn parse_shell_history(content: &str, out: &mut Vec<Raw>) { } } -/// The bash `HISTTIMEFORMAT` timestamp comment (`#1700000000`), if that's what -/// this line is. It carries no command itself — it stamps the following line. fn bash_timestamp(line: &str) -> Option<u64> { let rest = line.strip_prefix('#')?; if rest.is_empty() || !rest.bytes().all(|b| b.is_ascii_digit()) { @@ -402,16 +284,10 @@ fn bash_timestamp(line: &str) -> Option<u64> { rest.parse().ok() } -/// The command text at the start of a history line plus the zsh -/// extended-history timestamp when the line carries one, or `None` for blank -/// lines. Strips the `": <start>:<elapsed>;"` prefix when present. fn start_of_command(line: &str) -> Option<(&str, Option<u64>)> { if line.is_empty() { return None; } - // zsh extended history: ": 1700000000:0;the command". The timestamp field - // must hold at least one digit — an empty/colon-only prefix would otherwise - // match a *real* command like `: ;echo hi` and wrongly strip its head. if let Some(rest) = line.strip_prefix(": ") && let Some(semi) = rest.find(';') { @@ -470,7 +346,6 @@ mod tests { [ ("ls -la".to_string(), Some(1_700_000_000)), ("cd ..".to_string(), Some(1_700_000_005)), - // No comment directly above → no timestamp bleeds over. ("untimed".to_string(), None), ] ); @@ -478,9 +353,6 @@ mod tests { #[test] fn multiline_commands_are_split_not_joined() { - // We never stitch continuation lines together — each physical line is its - // own entry, so no entry can carry an embedded newline (which would wreck - // the single-line completion menu's layout). let content = ": 1700000000:0;for f in *; do\\\necho $f\\\ndone\n"; let got = parse(content); assert_eq!(got, ["for f in *; do\\", "echo $f\\", "done"]); @@ -498,38 +370,31 @@ mod tests { #[test] fn parse_own_line_reads_all_generations() { - // Current format: ts, exit, cwd, command. let r = parse_own_line("1700000000\t0\t/home/me\tgit status"); assert_eq!(r.cmd, "git status"); assert_eq!(r.cwd.as_deref(), Some("/home/me")); assert_eq!(r.ts, Some(1_700_000_000)); assert_eq!(r.exit, Some(0)); - // Exit unknown (pane died mid-command) and cwd unknown stay empty fields. let r = parse_own_line("1700000000\t\t\tmake"); assert_eq!( (r.cmd.as_str(), r.cwd, r.ts, r.exit), ("make", None, Some(1_700_000_000), None) ); - // The command is the last field, so its own tabs survive. let r = parse_own_line("1700000000\t1\t/a\techo\tfoo"); assert_eq!(r.cmd, "echo\tfoo"); assert_eq!(r.exit, Some(1)); - // Previous generation: `<cwd>\t<command>`. let r = parse_own_line("/home/me\tgit status"); assert_eq!( (r.cmd.as_str(), r.cwd.as_deref(), r.ts), ("git status", Some("/home/me"), None) ); - // Windows absolute cwd is recognized too (cross-platform, host-independent). let r = parse_own_line("C:\\Users\\me\tgit status"); assert_eq!(r.cwd.as_deref(), Some("C:\\Users\\me")); - // Legacy bare command — no tab, no metadata. let r = parse_own_line("ls -la"); assert_eq!( (r.cmd.as_str(), r.cwd, r.ts, r.exit), ("ls -la", None, None, None) ); - // A tab whose pre-part isn't an absolute path is not treated as a cwd. assert_eq!(parse_own_line("echo\tfoo").cmd, "echo\tfoo"); } @@ -539,11 +404,10 @@ mod tests { pair("ls", None), pair("", None), pair("cd /tmp", None), - pair("ls", None), // later duplicate wins its (later) position + pair("ls", None), ]; let h = normalize(raw); assert_eq!(h.entries, ["cd /tmp", "ls"]); - // Both occurrences of "ls" are counted, even though it appears once. assert_eq!(h.counts.get("ls"), Some(&2)); assert_eq!(h.counts.get("cd /tmp"), Some(&1)); } @@ -553,7 +417,7 @@ mod tests { let raw = vec![ pair("make", Some("/a")), pair("make", Some("/b")), - pair("make", Some("/a")), // same dir again — still just the set {/a, /b} + pair("make", Some("/a")), ]; let h = normalize(raw); let dirs = h.cwds.get("make").unwrap(); @@ -573,8 +437,6 @@ mod tests { with_meta("make", 100, Some(2)), pair("ls", None), with_meta("make", 200, Some(0)), - // The newest occurrence has no metadata (a shell-history duplicate): - // the newest occurrence *with* metadata still wins. pair("make", None), ]; let h = normalize(raw); @@ -585,14 +447,11 @@ mod tests { exit: Some(0) }) ); - // No metadata anywhere → no entry. assert_eq!(h.meta.get("ls"), None); } #[test] fn frecency_ranks_frequent_over_merely_recent() { - // `git status` is old but run many times; `oops typo` is the newest line - // but a one-off. Frecency should float the frequent command above it. let entries = vec![ "git status".to_string(), "ls".to_string(), @@ -612,19 +471,16 @@ mod tests { #[test] fn frecency_favours_commands_run_in_the_current_directory() { - // Two equally rare, equally old commands; only `cargo build` has been run - // in the current directory, so the cwd bonus lifts it above `npm test`. let entries = vec!["npm test".to_string(), "cargo build".to_string()]; - let counts = HashMap::new(); // both default to count 1 + let counts = HashMap::new(); let mut cwds: HashMap<String, HashSet<String>> = HashMap::new(); cwds.entry("cargo build".to_string()) .or_default() .insert("/work/proj".to_string()); let ranked = rank_by_frecency(&entries, &counts, &cwds, Some("/work/proj")); assert_eq!(ranked[0], "cargo build"); - // Without the directory context, recency tie-break favours the newer entry. let neutral = rank_by_frecency(&entries, &counts, &cwds, None); - assert_eq!(neutral[0], "cargo build"); // newest wins the tie either way + assert_eq!(neutral[0], "cargo build"); assert_eq!(neutral[1], "npm test"); } @@ -633,7 +489,6 @@ mod tests { let entries = vec!["a".to_string(), "b".to_string()]; let scores = frecency_scores(&entries, &HashMap::new(), &HashMap::new(), None); assert_eq!(scores.len(), 2); - // Same count, so the newer entry scores strictly higher (recency). assert!(scores[1] > scores[0]); } @@ -647,50 +502,40 @@ mod tests { assert_eq!(format_ago(now, now - 20 * 86_400), "2w"); assert_eq!(format_ago(now, now - 90 * 86_400), "3mo"); assert_eq!(format_ago(now, now - 800 * 86_400), "2y"); - // A clock that went backwards degrades to "now", never underflows. assert_eq!(format_ago(now, now + 100), "now"); } #[test] fn looks_absolute_recognizes_unix_and_windows_roots() { assert!(looks_absolute("/home/me")); - assert!(looks_absolute("\\\\server\\share")); // UNC - assert!(looks_absolute("C:\\Users")); // drive + backslash - assert!(looks_absolute("D:/data")); // drive + forward slash - assert!(looks_absolute("Z:")); // bare drive - // Not absolute. + assert!(looks_absolute("\\\\server\\share")); + assert!(looks_absolute("C:\\Users")); + assert!(looks_absolute("D:/data")); + assert!(looks_absolute("Z:")); assert!(!looks_absolute("relative/path")); - assert!(!looks_absolute("1:no")); // non-alpha "drive" + assert!(!looks_absolute("1:no")); assert!(!looks_absolute("")); } #[test] fn start_of_command_strips_prefixes_and_keeps_timestamps() { - // zsh extended-history prefix is stripped, its start timestamp kept. assert_eq!( start_of_command(": 1700000000:0;git status"), Some(("git status", Some(1_700_000_000))) ); - // A colon-prefixed line whose middle isn't numeric is taken verbatim. assert_eq!( start_of_command(": not-a-ts;cmd"), Some((": not-a-ts;cmd", None)) ); - // Regression: an empty or colon-only "timestamp" is not the zsh format — - // the line is a real command (`: ;echo hi` runs the colon builtin, then - // echo) and must NOT have its head stripped. assert_eq!(start_of_command(": ;echo hi"), Some((": ;echo hi", None))); assert_eq!(start_of_command(": :::;cmd"), Some((": :::;cmd", None))); - // Blank → None. assert_eq!(start_of_command(""), None); - // Plain command passes through. assert_eq!(start_of_command("ls -la"), Some(("ls -la", None))); } #[test] fn bash_timestamp_recognizes_only_all_digit_comments() { assert_eq!(bash_timestamp("#1700000000"), Some(1_700_000_000)); - // A real comment-looking line with non-digits is a command, not a stamp. assert_eq!(bash_timestamp("#notdigits"), None); assert_eq!(bash_timestamp("#"), None); assert_eq!(bash_timestamp("ls"), None); @@ -698,28 +543,23 @@ mod tests { #[test] fn normalize_dedups_counts_and_caps_entries() { - // Duplicates collapse to the most recent position, with a run count tallied. let raw = vec![ pair("ls", Some("/a")), pair("git", None), - pair("", None), // blank dropped + pair("", None), pair("ls", Some("/b")), ]; let h = normalize(raw); - // "ls" moved to the end (most recent) and "git" stayed; blank gone. assert_eq!(h.entries, vec!["git".to_string(), "ls".to_string()]); assert_eq!(h.counts.get("ls"), Some(&2)); - // Both directories "ls" ran in are recorded. let dirs = h.cwds.get("ls").unwrap(); assert!(dirs.contains("/a") && dirs.contains("/b")); - // The cap keeps only the most recent MAX_ENTRIES unique lines. let big: Vec<Raw> = (0..MAX_ENTRIES + 50) .map(|i| pair(&format!("cmd{i}"), None)) .collect(); let capped = normalize(big); assert_eq!(capped.entries.len(), MAX_ENTRIES); - // The oldest were dropped; the newest survives. assert_eq!( capped.entries.last().unwrap(), &format!("cmd{}", MAX_ENTRIES + 49) @@ -728,13 +568,10 @@ mod tests { #[test] fn append_then_load_recovers_the_command_and_metadata() { - // Pin the config dir so history writes to a temp file, not the real one. crate::core::config::pin_test_config_dir(); - // A command with an embedded newline is rejected (one-per-line format). append("bad\ncmd", None, 1_700_000_000, None); - // A unique command tagged with cwd/ts/exit round-trips through load(). let unique = format!("tty7_cov_marker_{}", std::process::id()); append(&unique, Some(Path::new("/tmp")), 1_700_000_123, Some(1)); let loaded = load(); @@ -761,10 +598,6 @@ mod tests { #[test] fn concurrent_appends_never_interleave_records() { - // Regression: `writeln!` on an unbuffered File could split one record - // into two write syscalls (text, then newline), so two panes appending - // at once produced fused half-lines ("cmdAcmdB\n\n") that loaded back - // as garbage commands. Each record must land as one atomic write. crate::core::config::pin_test_config_dir(); let tag = format!("tty7_race_{}", std::process::id()); @@ -801,10 +634,6 @@ mod tests { #[test] fn append_rejects_a_cwd_that_would_break_the_line_format() { - // Regression: a cwd containing a newline used to be written verbatim into - // the record, splitting it — the pre-newline half loaded back as a bogus - // command and the real command gained a wrong cwd. Such a cwd is dropped - // (empty field) so the record stays one line. crate::core::config::pin_test_config_dir(); let unique = format!("tty7_nlcwd_marker_{}", std::process::id()); @@ -815,11 +644,8 @@ mod tests { None, ); let loaded = load(); - // The command itself survives… assert!(loaded.entries.iter().any(|e| e == &unique)); - // …with no cwd association (the unusable path was dropped, not split)… assert!(loaded.cwds.get(&unique).is_none_or(|d| d.is_empty())); - // …and no half-a-path entry leaked in as a phantom command. assert!(!loaded.entries.iter().any(|e| e == "/tmp/evil")); } } diff --git a/src/terminal/hold.rs b/src/terminal/hold.rs index 037f54c6..39203e1a 100644 --- a/src/terminal/hold.rs +++ b/src/terminal/hold.rs @@ -1,63 +1,21 @@ -//! Client-side hold for keystrokes typed into the prompt→prompt gap. -//! -//! While a command runs (`at_prompt` false), typed bytes traditionally go -//! straight to the PTY, where the kernel echoes them immediately — leaving -//! `ls%`-style debris in the scrollback when the user types ahead of a fast -//! command (`cd`, `ls`…). But those bytes can't just be swallowed either: a -//! running command may be reading its stdin (a REPL, a password prompt). -//! -//! The compromise is a short hold: reconstructable gap input (printable text, -//! Backspace) is captured client-side for up to the caller's dump window -//! (~150 ms). If the editor engages first — the fast-command case — the held -//! text is handed to it verbatim and the PTY never sees a byte: no echo, no -//! wipe, pristine scrollback. If the window lapses — a long command, or a -//! program actually reading stdin — the bytes are released to the PTY exactly -//! as typed, and the rest of the gap is raw passthrough so interactive -//! programs feel no further delay. Unreconstructable input (arrows, chords, -//! Enter, multi-line pastes) releases the hold immediately and passes -//! through, preserving byte order. -//! -//! The struct is pure state — no timers, no PTY. The caller arms a timer when -//! a hold window opens (`Verdict::Held(Some(epoch))`) and calls [`GapHold::timeout`] -//! when it fires; the epoch makes a late timer firing after engage/release a -//! no-op. Two views of the held input are kept: `net`, the backspace-folded -//! text the editor (or the typeahead record) adopts, and `bytes`, the raw -//! stream a dump writes — zle folds backspaces the same way, so both views -//! converge on the same line. - -/// What the hold decided to do with one gap-input event. pub enum Verdict { - /// Captured client-side; nothing reaches the PTY for now. `Some(epoch)` on - /// the event that opened the window — the caller starts the dump timer - /// with it. Held(Option<u64>), - /// The gap already went raw (a dump or release happened); the caller - /// writes the event to the PTY itself, as before holds existed. Passthrough, } #[derive(Default)] enum State { - /// No gap input seen since the last engage. #[default] Idle, - /// Input is being held, dump timer running. Holding, - /// The hold was dumped/released this gap; further input goes raw. Passthrough, } -/// Held gap input. One per pane view; reset by [`GapHold::engage`] whenever -/// the line editor takes over. #[derive(Default)] pub struct GapHold { state: State, - /// Backspace-folded text, as the editor would end up showing it. net: String, - /// The raw byte stream exactly as typed — what a dump writes to the PTY. bytes: Vec<u8>, - /// Bumped when a window opens; a dump timer carries its window's epoch so - /// firing after engage (or after an earlier dump) is a no-op. epoch: u64, } @@ -66,14 +24,10 @@ impl GapHold { Self::default() } - /// Offer printable text (IME commit, single-line paste) to the hold. pub fn hold_text(&mut self, s: &str, bytes: &[u8]) -> Verdict { self.hold(bytes, |net| net.push_str(s)) } - /// Offer a plain Backspace to the hold. Folds the last held char off - /// `net`; on an empty hold there is nothing shell-side to erase either - /// (nothing was dumped), so the fold simply stays empty. pub fn hold_backspace(&mut self, bytes: &[u8]) -> Verdict { self.hold(bytes, |net| { net.pop(); @@ -96,10 +50,6 @@ impl GapHold { } } - /// An unreconstructable event (arrow, chord, Enter, multi-line paste) is - /// about to be written raw: release whatever is held so it precedes that - /// event on the wire, and switch the rest of the gap to passthrough. - /// Returns `(folded_text, raw_bytes)` for the caller to write and record. pub fn release(&mut self) -> Option<(String, Vec<u8>)> { let held = matches!(self.state, State::Holding); self.state = State::Passthrough; @@ -111,8 +61,6 @@ impl GapHold { }) } - /// The dump timer for `epoch` fired: if that window is still open, release - /// it (the command is taking long / reading stdin — the bytes must flow). pub fn timeout(&mut self, epoch: u64) -> Option<(String, Vec<u8>)> { if matches!(self.state, State::Holding) && epoch == self.epoch { self.release() @@ -121,9 +69,6 @@ impl GapHold { } } - /// The line editor engaged: whatever is still held goes to it (the PTY - /// never saw those bytes, so there is nothing to wipe), and the next gap - /// starts from a clean slate. pub fn engage(&mut self) -> Option<String> { self.state = State::Idle; self.bytes.clear(); @@ -139,15 +84,9 @@ mod tests { #[test] fn fast_command_gap_replays_into_the_editor_and_never_touches_the_pty() { let mut h = GapHold::new(); - // The first held key opens the window (the caller arms the timer)... assert!(matches!(h.hold_text("l", b"l"), Verdict::Held(Some(_)))); - // ...later keys ride the same window. assert!(matches!(h.hold_text("s", b"s"), Verdict::Held(None))); - // The command finished inside the window: everything goes to the - // editor; the PTY never saw a byte, so nothing echoes, nothing needs - // a wipe. assert_eq!(h.engage(), Some("ls".to_string())); - // The gap is over; the next one starts from a clean slate. assert_eq!(h.engage(), None); } @@ -158,16 +97,9 @@ mod tests { panic!("first key should open a window"); }; assert!(matches!(h.hold_text("s", b"s"), Verdict::Held(None))); - // The window lapsed (long command / stdin reader): the raw bytes are - // released for the PTY, with the folded text for the typeahead record. assert_eq!(h.timeout(epoch), Some(("ls".to_string(), b"ls".to_vec()))); - // The same timer can't fire twice… assert_eq!(h.timeout(epoch), None); - // …and the rest of the gap is raw passthrough — no added latency for - // whatever is reading stdin now. assert!(matches!(h.hold_text("x", b"x"), Verdict::Passthrough)); - // Nothing left for the editor; the next gap opens a fresh window with - // a fresh epoch. assert_eq!(h.engage(), None); let Verdict::Held(Some(e2)) = h.hold_text("a", b"a") else { panic!("fresh gap should hold again"); @@ -182,8 +114,6 @@ mod tests { panic!("first key should open a window"); }; assert_eq!(h.engage(), Some("l".to_string())); - // The timer fires late, after the editor already adopted the text — - // dumping now would type a stray "l" at the prompt. assert_eq!(h.timeout(epoch), None); } @@ -191,13 +121,9 @@ mod tests { fn unreconstructable_input_releases_the_hold_in_typed_order() { let mut h = GapHold::new(); h.hold_text("ls", b"ls"); - // An arrow / chord / Enter can't be replayed into the editor: what's - // held is released first (the caller writes it, then the event's own - // bytes — FIFO preserved), and the gap goes raw. assert_eq!(h.release(), Some(("ls".to_string(), b"ls".to_vec()))); assert!(matches!(h.hold_text("x", b"x"), Verdict::Passthrough)); - // With nothing held, release still switches to passthrough, silently. let mut h = GapHold::new(); assert_eq!(h.release(), None); assert!(matches!(h.hold_text("x", b"x"), Verdict::Passthrough)); @@ -205,15 +131,11 @@ mod tests { #[test] fn backspace_folds_for_the_editor_but_dumps_verbatim() { - // Editor path: the fold applies, exactly like zle would. let mut h = GapHold::new(); h.hold_text("lss", b"lss"); assert!(matches!(h.hold_backspace(b"\x7f"), Verdict::Held(None))); assert_eq!(h.engage(), Some("ls".to_string())); - // Dump path: the PTY gets the stream exactly as typed (text + 0x7f); - // the record seed uses the folded text — zle folds the same way, so - // both views converge on the same line. let mut h = GapHold::new(); let Verdict::Held(Some(e)) = h.hold_text("lss", b"lss") else { panic!("first key should open a window"); @@ -221,9 +143,6 @@ mod tests { h.hold_backspace(b"\x7f"); assert_eq!(h.timeout(e), Some(("ls".to_string(), b"lss\x7f".to_vec()))); - // A backspace with nothing held folds to nothing, and there is - // nothing shell-side to erase either (nothing was dumped): it simply - // vanishes instead of reaching the PTY. let mut h = GapHold::new(); assert!(matches!(h.hold_backspace(b"\x7f"), Verdict::Held(Some(_)))); assert_eq!(h.engage(), None); diff --git a/src/terminal/input.rs b/src/terminal/input.rs index a5d0ad44..464e0c18 100644 --- a/src/terminal/input.rs +++ b/src/terminal/input.rs @@ -1,32 +1,14 @@ -//! Keyboard input for the terminal view: translating GPUI keystrokes into the -//! byte sequences a PTY expects, and bridging the platform IME (NSTextInputClient -//! on macOS) so CJK and dead-key input composes and commits into the terminal. - use alacritty_terminal::term::TermMode; use gpui::{App, Bounds, InputHandler, Pixels, UTF16Selection, Window}; use super::view::TerminalView; -// Only the macOS Option/Meta split reads config from this file; elsewhere the import -// would be dead. #[cfg(target_os = "macos")] use crate::core::config::Config; -/// The Kitty keyboard-protocol progressive-enhancement flags currently active in -/// the terminal, distilled from `TermMode`. We read them straight off the client's -/// local `Term` (which the reader thread advances over *all* child output, so its -/// mode bits already reflect every `CSI = flags u` push/pop the app sent — the fork -/// runs that state machine for us). Only the bits the encoder actually consults are -/// kept, so the struct stays small and `Copy`. #[derive(Clone, Copy, Default)] pub(super) struct KittyFlags { - /// `DISAMBIGUATE_ESC_CODES` (level 1): escape otherwise-ambiguous keys - /// (Tab vs Ctrl+I, Esc, Ctrl+letter, …) as `CSI … u`. disambiguate: bool, - /// `REPORT_ALL_KEYS_AS_ESC`: encode *every* key as `CSI … u`, including plain - /// text keys — not just the ambiguous ones. report_all_keys: bool, - /// `REPORT_ASSOCIATED_TEXT`: include the produced text as a third `CSI u` - /// field, so full-mode apps still receive the character. report_text: bool, } @@ -39,33 +21,11 @@ impl KittyFlags { } } - /// Whether any level of the protocol is active (so the encoder should run). pub(super) fn active(self) -> bool { self.disambiguate || self.report_all_keys } } -/// Reshape a keystroke according to the macOS Option-key policy, before any -/// encoding runs. macOS gives Option two jobs that a terminal can't serve at -/// once: the OS composes a special character (Option+B types `∫`, delivered in -/// `key_char`), while Meta bindings need an ESC-prefixed chord (Option+B → -/// `ESC b`, readline's backward-word). `Config::macos_option_as_alt` picks: -/// -/// * **On** — the chord is Meta: `key_char` is replaced with the plain key -/// (uppercased under Shift, matching xterm's `metaSendsEscape` output), so -/// the legacy encoder's Alt branch emits `ESC` + the base character instead -/// of `ESC` + the composed one. -/// * **Off** (default) — the chord is text input: the alt bit is dropped so the -/// composed character is sent bare. (Without this, the legacy encoder bolts -/// an ESC prefix onto the composed char — `ESC ∫` — a sequence that is wrong -/// under either reading; and the prompt editor swallows the chord entirely.) -/// -/// Only Option chords that produce a single text key are reshaped: named keys -/// (arrows, Enter, …) and Ctrl/Cmd combinations keep their existing encodings -/// on both settings. Returns `None` when the keystroke needs no reshaping, so -/// callers only clone on the affected chords. Callers gate on macOS — the -/// composed-character split doesn't exist elsewhere — but the function itself -/// is platform-neutral so it can be tested everywhere. pub(super) fn reshape_option_keystroke( ks: &gpui::Keystroke, option_as_alt: bool, @@ -75,30 +35,23 @@ pub(super) fn reshape_option_keystroke( return None; } if option_as_alt { - // Meta semantics: the byte after ESC must be the key itself. Only - // single-character keys compose; named keys already encode off `key`. let mut chars = ks.key.chars(); let base = chars.next()?; if chars.next().is_some() { return None; } - // gpui reports shifted letters as a lowercase key + the shift bit; - // Meta follows the shifted character (Option+Shift+B → `ESC B`). let ch = if m.shift { base.to_uppercase().to_string() } else { base.to_string() }; if ks.key_char.as_deref() == Some(ch.as_str()) { - return None; // already the base character — nothing to reshape + return None; } let mut out = ks.clone(); out.key_char = Some(ch); Some(out) } else { - // macOS convention: the chord is ordinary text input. A chord that - // composed no printable text (named keys, Enter's "\n") stays a real - // Alt chord — dropping alt there would break Alt+arrow and friends. let ch = ks.key_char.as_deref()?; if ch.is_empty() || ch.chars().any(|c| c < '\u{20}' || c == '\u{7f}') { return None; @@ -109,37 +62,6 @@ pub(super) fn reshape_option_keystroke( } } -/// True when a keystroke is ordinary text that macOS should deliver through the -/// input context (`insertText:` → `replace_text_in_range` → `commit_text`) -/// rather than the raw `key_char` path. -/// -/// gpui derives `key_char` by running the event's *virtual keycode* back through -/// the current layout (`chars_for_modified_key` in its macOS backend); it never -/// reads the event's Unicode payload. That is fine for a physical keyboard, where -/// the keycode is the truth, but wrong for any event whose text lives only in the -/// payload — notably remote-control apps, which synthesize keystrokes as -/// `CGEventCreateKeyboardEvent(src, 0, …)` + `CGEventKeyboardSetUnicodeString()`. -/// Keycode 0 is `a`, so every remotely typed character arrived as `a`. -/// -/// gpui already diverts printable keys to the input context, but only while a -/// composing input source is active (`is_ime_input_source_active`), so the bug -/// appeared and vanished depending on which input method was selected — and the -/// plain ABC layout, the macOS default, always lost the text. Declining the key -/// here instead makes the IME the single delivery path for text on macOS: gpui -/// falls through to `handleEvent:`, and the Unicode payload survives. -/// -/// Chords are deliberately excluded: Ctrl/Cmd/Fn belong to the encoders below, -/// and Option is owned by [`reshape_option_keystroke`]'s Meta policy. -/// -/// REPORT_ALL_KEYS_AS_ESC is excluded too: it asks for every key as -/// `CSI <code>;<mods>[;<text>]u`, and the IME path terminates in -/// `write_gap_text`, which writes raw UTF-8 with no Kitty awareness. Under that -/// mode text keys must stay on the [`keystroke_to_bytes`] path so they get -/// encoded. Disambiguate-only sessions are unaffected — [`encode_kitty`] -/// declines unmodified text keys there, so the IME route is equivalent. -/// -/// Compiled under `test` on every platform so the routing rule is covered by -/// CI everywhere, not just on the macOS runner. #[cfg(any(target_os = "macos", test))] pub(super) fn defer_to_ime(ks: &gpui::Keystroke, kitty: KittyFlags) -> bool { if kitty.report_all_keys { @@ -154,41 +76,13 @@ pub(super) fn defer_to_ime(ks: &gpui::Keystroke, kitty: KittyFlags) -> bool { .is_some_and(|ch| !ch.is_empty() && ch.chars().all(|c| c >= '\u{20}' && c != '\u{7f}')) } -/// Whether an Option chord must be kept away from the IME so the Meta policy in -/// [`reshape_option_keystroke`] can claim it. -/// -/// macOS counts ⌥-chords as printable text — ⌥B composes `∫` — so while a CJK input -/// source is active gpui routes them to the IME before the key handler ever runs. The -/// IME commits the composed character and swallows the event, and Option-as-Meta -/// silently does nothing (#177). This is the predicate -/// [`TerminalInputHandler::prefers_ime_for_printable_keys`] answers `false` on. -/// -/// Only ⌥ alone (optionally with Shift) counts: ⌘ chords are app shortcuts and Ctrl -/// chords already bypass the IME upstream, and both keep their existing routing. -/// -/// With the setting off the chord is text input and the IME is the right owner — it is -/// what makes dead keys (⌥E then E → `é`) compose at all — so this returns `false` and -/// nothing changes. -/// -/// Compiled under `test` on every platform so CI covers the rule everywhere, not just -/// on the macOS runner. #[cfg(any(target_os = "macos", test))] pub(super) fn meta_chord_bypasses_ime(ks: &gpui::Keystroke, option_as_alt: bool) -> bool { let m = &ks.modifiers; option_as_alt && m.alt && !m.platform && !m.control } -/// Translate a GPUI keystroke into the bytes a PTY expects. -/// -/// When the app has enabled the Kitty keyboard protocol (`kitty.active()`) we try -/// the `CSI u` encoder first; anything it declines to encode (plain text keys at the -/// disambiguate level, keys it doesn't special-case) falls through to the *unchanged* -/// legacy path. So with the protocol off — the overwhelmingly common case — the -/// output is byte-for-byte identical to before. pub(super) fn keystroke_to_bytes(ks: &gpui::Keystroke, kitty: KittyFlags) -> Option<Vec<u8>> { - // Cmd (platform) chords are app-shortcut territory, resolved before we get here; - // never Kitty-encode them, so their behavior is unchanged whether or not the - // protocol is on. if kitty.active() && !ks.modifiers.platform { if let Some(bytes) = encode_kitty(ks, kitty) { return Some(bytes); @@ -197,17 +91,8 @@ pub(super) fn keystroke_to_bytes(ks: &gpui::Keystroke, kitty: KittyFlags) -> Opt legacy_keystroke_to_bytes(ks) } -/// Bytes for a Tab / Shift-Tab press. These keys reach the PTY through the -/// `SendTab` / `SendBackTab` actions (not `on_key_down`), so the Kitty encoding -/// lives here rather than in [`encode_kitty`]. Mirrors the encoder's rule for the -/// legacy control keys: plain unmodified Tab stays legacy `\t` even under -/// DISAMBIGUATE (so a shell survives a crashed TUI leaving the mode on); it -/// becomes `CSI 9 u` only when Shift makes it ambiguous or REPORT_ALL_KEYS_AS_ESC -/// escapes every key. Back-tab keeps its legacy `CSI Z` form when the protocol is -/// off. pub(super) fn tab_bytes(shift: bool, kitty: KittyFlags) -> Vec<u8> { if kitty.active() && (shift || kitty.report_all_keys) { - // Shift adds the modifier subfield (mods = 1 + shift = 2). if shift { b"\x1b[9;2u".to_vec() } else { @@ -220,22 +105,8 @@ pub(super) fn tab_bytes(shift: bool, kitty: KittyFlags) -> Vec<u8> { } } -/// Kitty keyboard-protocol (`CSI u`) encoder. Covers the DISAMBIGUATE_ESC_CODES -/// level well, plus enough of REPORT_ALL_KEYS_AS_ESC / REPORT_ASSOCIATED_TEXT to be -/// usable at the full level. Returns `None` for keys it deliberately leaves to the -/// legacy path — chiefly plain text keys at the disambiguate level, which must still -/// be sent as raw UTF-8. -/// -/// Spec: <https://sw.kovidgoyal.net/kitty/keyboard-protocol/> -/// -/// TODO: REPORT_EVENT_TYPES (press/repeat/release event-type subfield) and -/// REPORT_ALTERNATE_KEYS (shifted / base-layout alternate key codes) are not encoded -/// yet — we report key *presses* at the primary code only. That's a safe subset: -/// apps degrade to press-only behavior rather than misbehaving. fn encode_kitty(ks: &gpui::Keystroke, kitty: KittyFlags) -> Option<Vec<u8>> { let m = &ks.modifiers; - // Modifier bitmask per spec: value = 1 + shift(1) + alt(2) + ctrl(4) + super(8). - // Super (Cmd) is intentionally excluded — platform chords never reach here. let mut mods = 1u32; if m.shift { mods += 1; @@ -247,18 +118,10 @@ fn encode_kitty(ks: &gpui::Keystroke, kitty: KittyFlags) -> Option<Vec<u8>> { mods += 4; } - // Escape is disambiguated to `CSI 27 u` whenever the protocol is active — that - // is the whole point of DISAMBIGUATE_ESC_CODES (tell a plain Esc apart from an - // escape-sequence introducer). if ks.key.as_str() == "escape" { return Some(csi_u(27, mods, None)); } - // Enter / Tab / Backspace are the three legacy control keys the spec keeps as - // plain `\r` / `\t` / 0x7f under DISAMBIGUATE alone, so a shell stays usable if - // a crashed app leaves the mode on (typing `reset⏎` must still send a real CR). - // They escalate to `CSI u` only when a modifier makes them ambiguous, or under - // REPORT_ALL_KEYS_AS_ESC (which reports *every* key as an escape code). let legacy_ctrl_code = match ks.key.as_str() { "enter" => Some(13u32), "tab" => Some(9), @@ -267,21 +130,15 @@ fn encode_kitty(ks: &gpui::Keystroke, kitty: KittyFlags) -> Option<Vec<u8>> { }; if let Some(code) = legacy_ctrl_code { if mods == 1 && !kitty.report_all_keys { - return None; // unmodified at the disambiguate level → legacy path + return None; } return Some(csi_u(code, mods, None)); } - // Functional keys encoded in the legacy CSI layout (letter- or tilde-suffixed). - // The Kitty protocol keeps these forms and just adds the modifier subfield. if let Some(seq) = kitty_functional(ks.key.as_str(), mods) { return Some(seq); } - // Text-producing keys. At the disambiguate level these are only escaped when a - // Ctrl/Alt modifier makes them ambiguous (e.g. Ctrl+I vs Tab); otherwise we - // return None so the legacy path sends the raw character. With - // REPORT_ALL_KEYS_AS_ESC, every text key is escaped. let modified = m.control || m.alt; if modified || kitty.report_all_keys { if let Some(code) = text_key_code(ks) { @@ -293,9 +150,6 @@ fn encode_kitty(ks: &gpui::Keystroke, kitty: KittyFlags) -> Option<Vec<u8>> { None } -/// Build a `CSI <code> ; <mods> [; <text>] u` sequence. The modifier subfield is -/// omitted when it's the default (1) and there's no text; when text is present the -/// (possibly-default) modifier subfield must be kept so the text lands in field 3. fn csi_u(code: u32, mods: u32, text: Option<&[u32]>) -> Vec<u8> { let mut s = format!("\x1b[{code}"); match text { @@ -310,10 +164,6 @@ fn csi_u(code: u32, mods: u32, text: Option<&[u32]>) -> Vec<u8> { s.into_bytes() } -/// Kitty encoding for the CSI-layout functional keys (arrows / Home / End as -/// `CSI [1;mods] letter`, Insert / Delete / Page keys as `CSI n[;mods] ~`). Returns -/// `None` for keys handled elsewhere. With no modifiers these collapse to exactly -/// the legacy forms, so unmodified navigation is unchanged. fn kitty_functional(key: &str, mods: u32) -> Option<Vec<u8>> { let letter = match key { "up" => Some('A'), @@ -350,9 +200,6 @@ fn kitty_functional(key: &str, mods: u32) -> Option<Vec<u8>> { None } -/// The primary Kitty key code for a text-producing key: the Unicode codepoint of the -/// key's *unshifted* value (lowercased for ASCII letters), per the spec. `None` for -/// multi-character named keys (which aren't single text keys). fn text_key_code(ks: &gpui::Keystroke) -> Option<u32> { match ks.key.as_str() { "space" => Some(0x20), @@ -360,24 +207,15 @@ fn text_key_code(ks: &gpui::Keystroke) -> Option<u32> { let mut chars = key.chars(); let c = chars.next()?; if chars.next().is_some() { - return None; // a multi-char key name, not a single text key + return None; } Some(c.to_ascii_lowercase() as u32) } } } -/// The associated text (field 3) for REPORT_ASSOCIATED_TEXT: the codepoints of the -/// character(s) the key would produce, or `None` when it produces none (e.g. a -/// control chord) so the field is omitted. fn associated_text(ks: &gpui::Keystroke) -> Option<Vec<u32>> { let ch = ks.key_char.as_deref()?; - // Drop control codes: the Kitty spec requires the associated-text field to - // contain no control characters — "code points below U+0020 and codepoints in - // the C0 and C1 blocks". That's C0 (< 0x20) plus DEL (0x7f) and the C1 block - // (0x80..=0x9f); leaving those in would emit a control codepoint a conformant - // receiver must reject. A control chord's "char" carries no meaningful text - // anyway, so filtering them just omits the field. let cps: Vec<u32> = ch .chars() .map(|c| c as u32) @@ -386,13 +224,10 @@ fn associated_text(ks: &gpui::Keystroke) -> Option<Vec<u32>> { (!cps.is_empty()).then_some(cps) } -/// The legacy (pre-Kitty) keystroke encoding. Untouched from the original -/// `keystroke_to_bytes` body, so behavior with the Kitty protocol off is unchanged. fn legacy_keystroke_to_bytes(ks: &gpui::Keystroke) -> Option<Vec<u8>> { let m = &ks.modifiers; let key = ks.key.as_str(); - // Control combinations → C0 control bytes. if m.control && !m.platform { let b = match key { "space" | "2" => Some(0x00), @@ -428,11 +263,6 @@ fn legacy_keystroke_to_bytes(ks: &gpui::Keystroke) -> Option<Vec<u8>> { _ => None, }; if let Some(b) = b { - // Alt (Meta) held with the Ctrl chord prefixes ESC, matching xterm's - // metaSendsEscape (default on) and the Alt handling in the special-key - // and printable branches below — so `Ctrl+Alt+c` sends `\x1b\x03`, not a - // bare `\x03` that's indistinguishable from plain Ctrl+C. Without this, - // `M-C-<key>` bindings (Emacs, readline, tmux) silently lose the Meta bit. if m.alt { return Some(vec![0x1b, b]); } @@ -440,7 +270,6 @@ fn legacy_keystroke_to_bytes(ks: &gpui::Keystroke) -> Option<Vec<u8>> { } } - // Named / special keys. let seq: Option<&[u8]> = match key { "enter" => Some(b"\r"), "tab" => Some(b"\t"), @@ -459,7 +288,6 @@ fn legacy_keystroke_to_bytes(ks: &gpui::Keystroke) -> Option<Vec<u8>> { _ => None, }; if let Some(seq) = seq { - // Alt + special key → ESC prefix. if m.alt { let mut v = vec![0x1b]; v.extend_from_slice(seq); @@ -468,7 +296,6 @@ fn legacy_keystroke_to_bytes(ks: &gpui::Keystroke) -> Option<Vec<u8>> { return Some(seq.to_vec()); } - // Printable text. Ignore when Cmd is held (app shortcut territory). if m.platform { return None; } @@ -485,16 +312,8 @@ fn legacy_keystroke_to_bytes(ks: &gpui::Keystroke) -> Option<Vec<u8>> { None } -/// Bridges the platform IME (NSTextInputClient on macOS) to the terminal. -/// -/// Without this, a CJK input method's composed text is never delivered: pinyin -/// keystrokes leak through as raw latin and the committed characters go nowhere. -/// `prefers_ime_for_printable_keys` is the crucial bit — it tells GPUI to route -/// printable keys to the IME first when a non-ASCII input source is active, so -/// composition actually starts. pub struct TerminalInputHandler { view: gpui::Entity<TerminalView>, - /// Cursor cell bounds in window coordinates, for placing the candidate window. cursor_bounds: Option<Bounds<Pixels>>, } @@ -596,17 +415,9 @@ impl InputHandler for TerminalInputHandler { } fn apple_press_and_hold_enabled(&mut self) -> bool { - // A terminal wants auto-repeat, not the accent palette: holding `j` in - // vim scrolls, it does not offer `ĵ`. This used to be moot because - // `on_key_down` consumed printable keys before gpui consulted it; now - // that text defers to the IME (see `defer_to_ime`), gpui reaches its - // held-key branch, and answering `false` there makes it repeat the - // character instead of handing the key to press-and-hold. false } - // `keystroke` only feeds the macOS Option/Meta split; elsewhere Alt already carries - // Meta and never reaches an IME. #[cfg_attr(not(target_os = "macos"), allow(unused_variables))] fn prefers_ime_for_printable_keys( &mut self, @@ -614,48 +425,16 @@ impl InputHandler for TerminalInputHandler { window: &mut Window, cx: &mut App, ) -> bool { - // An Option chord under Option-as-Meta belongs to `reshape_option_keystroke`, - // not the IME — see `meta_chord_bypasses_ime`. Answering per keystroke is why - // tty7 carries a gpui patch: upstream asks this once per view, with no key in - // hand, so it cannot say "IME for text, but not for this chord". #[cfg(target_os = "macos")] if meta_chord_bypasses_ime(keystroke, cx.global::<Config>().macos_option_as_alt) { return false; } - // REPORT_ALL_KEYS_AS_ESC wants every key as `CSI <code>;<mods>[;<text>]u`, - // which only `keystroke_to_bytes` produces — the IME path commits raw - // UTF-8. Keep printable keys on the dispatch path so they get encoded, - // matching the same gate in `on_key_down`. CJK composition and "escape - // every key" are mutually exclusive by construction; an app that asks - // for the latter gets it. if self.view.read(cx).kitty_flags().report_all_keys { return false; } - // While a multi-key keybinding is mid-sequence — e.g. the tmux preset's - // `ctrl-b` prefix is held pending — the next key belongs to the keymap, - // not the IME. macOS otherwise diverts printable keys straight to the IME - // when a CJK input source is active (see `query_prefers_ime_for_printable_keys` - // in gpui's macOS backend), so `ctrl-b x` would type an `x` and let the - // prefix time out instead of completing the sequence. Declining IME here - // lets the keystroke reach `dispatch_key` and finish the chord; when no - // sequence is pending this is a no-op, so normal CJK composition is - // unaffected. if window.has_pending_keystrokes() { return false; } - // Route printable keys to the IME so CJK composes. Whether the committed - // text lands in the terminal or the search query is decided by focus in - // `input_text` — so opening the search bar no longer disables CJK input in - // the terminal, and the search field composes too. - // - // Linux exception: gpui's IBus integration does not reliably commit plain - // ASCII back through `replace_text_in_range`, so forcing IME routing here - // swallows ordinary letters — the key never reaches the terminal at all - // (Enter/Tab/arrows still work because they bypass the IME as non-printable - // keys). Until that gpui path handles pass-through ASCII, keep printable - // keys on the direct `on_key_down`/`key_char` path on Linux. Trade-off: - // CJK composition is disabled on Linux for now (Linux support is still - // experimental); ASCII typing is restored. !cfg!(target_os = "linux") } } @@ -668,7 +447,6 @@ mod tests { }; use gpui::{Keystroke, Modifiers}; - /// Kitty full mode: every key escaped, with the produced text attached. fn full_mode() -> KittyFlags { KittyFlags { disambiguate: true, @@ -677,7 +455,6 @@ mod tests { } } - /// Level 1 only — the mode a shell leaves on after a TUI exits. fn disambiguate_only() -> KittyFlags { KittyFlags { disambiguate: true, @@ -686,8 +463,6 @@ mod tests { } } - /// The legacy call shape used by the pre-existing tests: encode with the Kitty - /// protocol off, exercising exactly the byte output shells see by default. fn legacy(ks: &Keystroke) -> Option<Vec<u8>> { keystroke_to_bytes(ks, KittyFlags::default()) } @@ -705,20 +480,15 @@ mod tests { let plain = Modifiers::default(); let a = ks(plain, "a", Some("a")); - // Default and disambiguate-only: text belongs to the IME, which is the - // only path that carries a synthesized event's real Unicode payload. assert!(defer_to_ime(&a, KittyFlags::default())); assert!(defer_to_ime(&a, disambiguate_only())); - // Full mode: the IME commits raw UTF-8, so deferring would drop the - // `CSI 97;1;97u` the app negotiated for. Stay on the encoder path. assert!(!defer_to_ime(&a, full_mode())); assert_eq!( keystroke_to_bytes(&a, full_mode()), Some(b"\x1b[97;1;97u".to_vec()), ); - // Space is text too, and follows the same rule. let space = ks(plain, "space", Some(" ")); assert!(defer_to_ime(&space, KittyFlags::default())); assert!(!defer_to_ime(&space, full_mode())); @@ -738,7 +508,6 @@ mod tests { #[test] fn non_text_keys_never_defer_to_the_ime() { let plain = Modifiers::default(); - // No `key_char` at all — arrows, F-keys, backspace, escape. assert!(!defer_to_ime( &ks(plain, "left", None), KittyFlags::default() @@ -747,7 +516,6 @@ mod tests { &ks(plain, "backspace", None), KittyFlags::default() )); - // Control chars are filtered even when a `key_char` is present. assert!(!defer_to_ime( &ks(plain, "enter", Some("\n")), KittyFlags::default() @@ -756,7 +524,6 @@ mod tests { &ks(plain, "tab", Some("\t")), KittyFlags::default() )); - // Chords belong to the encoders, not the IME. let ctrl = Modifiers { control: true, ..Default::default() @@ -780,9 +547,6 @@ mod tests { #[test] fn keystroke_to_bytes_ctrl_alt_letter_prefixes_meta_escape() { - // Ctrl+Alt+letter must carry the Meta ESC prefix (xterm metaSendsEscape), - // just like Alt+special-key and Alt+printable do below — otherwise the Alt - // bit is silently dropped and `Ctrl+Alt+c` is indistinguishable from Ctrl+C. let ctrl_alt = Modifiers { control: true, alt: true, @@ -791,7 +555,6 @@ mod tests { assert_eq!(legacy(&ks(ctrl_alt, "c", None)), Some(vec![0x1b, 0x03])); assert_eq!(legacy(&ks(ctrl_alt, "a", None)), Some(vec![0x1b, 0x01])); assert_eq!(legacy(&ks(ctrl_alt, "[", None)), Some(vec![0x1b, 0x1b])); - // Ctrl alone (no Alt) is unchanged: a bare C0 byte, no ESC prefix. let ctrl = Modifiers { control: true, ..Default::default() @@ -808,7 +571,6 @@ mod tests { alt: true, ..Default::default() }; - // Alt + a special key is prefixed with ESC. assert_eq!(legacy(&ks(alt, "up", None)), Some(b"\x1b\x1b[A".to_vec())); } @@ -816,7 +578,6 @@ mod tests { fn keystroke_to_bytes_emits_printable_text_but_not_under_cmd() { let none = Modifiers::default(); assert_eq!(legacy(&ks(none, "a", Some("a"))), Some(b"a".to_vec())); - // Cmd-held printable keys are app-shortcut territory -> no PTY bytes. let cmd = Modifiers { platform: true, ..Default::default() @@ -830,21 +591,16 @@ mod tests { control: true, ..Default::default() }; - // Ctrl+[ / Ctrl+\ / Ctrl+] map to the ESC/FS/GS control bytes. assert_eq!(legacy(&ks(ctrl, "[", None)), Some(vec![0x1b])); assert_eq!(legacy(&ks(ctrl, "\\", None)), Some(vec![0x1c])); assert_eq!(legacy(&ks(ctrl, "]", None)), Some(vec![0x1d])); - // Ctrl+2 is another spelling of NUL. assert_eq!(legacy(&ks(ctrl, "2", None)), Some(vec![0x00])); - // The full letter range boundaries. assert_eq!(legacy(&ks(ctrl, "h", None)), Some(vec![0x08])); assert_eq!(legacy(&ks(ctrl, "z", None)), Some(vec![0x1a])); } #[test] fn keystroke_to_bytes_ctrl_plus_cmd_is_not_a_c0_byte() { - // Ctrl held together with Cmd (platform) is app territory, not a C0 byte; - // it falls through the C0 table and, being non-printable under Cmd, yields None. let ctrl_cmd = Modifiers { control: true, platform: true, @@ -881,30 +637,25 @@ mod tests { #[test] fn keystroke_to_bytes_alt_prefixes_printable_and_ignores_empty_char() { - // Alt + a printable char is prefixed with ESC (meta) before the bytes. let alt = Modifiers { alt: true, ..Default::default() }; assert_eq!(legacy(&ks(alt, "b", Some("b"))), Some(b"\x1bb".to_vec())); - // An empty key_char produces no bytes (nothing to send). let none = Modifiers::default(); assert_eq!(legacy(&ks(none, "f7", Some(""))), None); - // An unknown key with no char is unmapped. assert_eq!(legacy(&ks(none, "f7", None)), None); } #[test] fn keystroke_to_bytes_emits_multibyte_utf8_char() { let none = Modifiers::default(); - // A composed character commits its UTF-8 bytes verbatim. assert_eq!( legacy(&ks(none, "é", Some("é"))), Some("é".as_bytes().to_vec()) ); } - /// A disambiguate-level `KittyFlags` for the encoder tests. fn kitty() -> KittyFlags { KittyFlags { disambiguate: true, @@ -920,8 +671,6 @@ mod tests { control: true, ..Default::default() }; - // Tab and Ctrl+I stay distinct: plain Tab keeps its legacy `\t` at the - // disambiguate level, while Ctrl+I is escaped to CSI 105;5 u. assert_eq!( keystroke_to_bytes(&ks(none, "tab", None), kitty()), Some(b"\t".to_vec()) @@ -930,13 +679,10 @@ mod tests { keystroke_to_bytes(&ks(ctrl, "i", None), kitty()), Some(b"\x1b[105;5u".to_vec()) ); - // Escape IS disambiguated to CSI 27 u at this level... assert_eq!( keystroke_to_bytes(&ks(none, "escape", None), kitty()), Some(b"\x1b[27u".to_vec()) ); - // ...but the spec keeps plain Enter / Backspace on their legacy bytes so a - // shell stays usable if a crashed app leaves the mode on. assert_eq!( keystroke_to_bytes(&ks(none, "enter", None), kitty()), Some(b"\r".to_vec()) @@ -949,10 +695,6 @@ mod tests { #[test] fn kitty_disambiguate_keeps_plain_enter_tab_backspace_legacy() { - // Regression: at the DISAMBIGUATE level, plain (unmodified) Enter / Tab / - // Backspace must stay legacy `\r` / `\t` / 0x7f — otherwise `reset⏎` can't - // rescue a shell after a crashed TUI leaves the mode set (the exact case the - // spec's exception exists for). Before the fix these emitted CSI 13/9/127 u. let none = Modifiers::default(); assert_eq!( keystroke_to_bytes(&ks(none, "enter", None), kitty()), @@ -967,9 +709,6 @@ mod tests { Some(b"\x7f".to_vec()) ); - // A modifier makes them ambiguous, so they DO escalate to CSI u carrying the - // modifier subfield: Ctrl+Enter -> CSI 13;5 u, Alt+Backspace -> CSI 127;3 u, - // Shift+Enter -> CSI 13;2 u. let ctrl = Modifiers { control: true, ..Default::default() @@ -998,8 +737,6 @@ mod tests { #[test] fn kitty_report_all_keys_escapes_plain_enter_tab_backspace() { - // Under REPORT_ALL_KEYS_AS_ESC every key is an escape code, including the - // three legacy control keys even with no modifier. let full = KittyFlags { disambiguate: true, report_all_keys: true, @@ -1022,16 +759,11 @@ mod tests { #[test] fn tab_bytes_follows_the_disambiguate_rule() { - // Tab reaches the PTY via the SendTab action, so its Kitty encoding lives in - // `tab_bytes`; it must follow the same rule as the on_key_down encoder. let off = KittyFlags::default(); - // Protocol off: legacy Tab / back-tab, unchanged. assert_eq!(tab_bytes(false, off), b"\t".to_vec()); assert_eq!(tab_bytes(true, off), b"\x1b[Z".to_vec()); - // Disambiguate: plain Tab stays legacy `\t`; Shift-Tab escalates to CSI 9;2 u. assert_eq!(tab_bytes(false, kitty()), b"\t".to_vec()); assert_eq!(tab_bytes(true, kitty()), b"\x1b[9;2u".to_vec()); - // Report-all: even plain Tab is escaped. let full = KittyFlags { disambiguate: true, report_all_keys: true, @@ -1043,7 +775,6 @@ mod tests { #[test] fn kitty_defers_plain_text_to_legacy() { let none = Modifiers::default(); - // A plain letter still sends raw text at the disambiguate level. assert_eq!( keystroke_to_bytes(&ks(none, "a", Some("a")), kitty()), Some(b"a".to_vec()) @@ -1052,9 +783,6 @@ mod tests { #[test] fn kitty_escapes_ctrl_and_alt_text_chords() { - // At the disambiguate level, a Ctrl/Alt modifier makes a text key - // ambiguous, so it escalates to CSI u with the modifier subfield — - // instead of the legacy ESC-prefix / C0 forms. let ctrl = Modifiers { control: true, ..Default::default() @@ -1063,12 +791,10 @@ mod tests { alt: true, ..Default::default() }; - // Ctrl+Space would be an ambiguous NUL byte → CSI 32;5 u. assert_eq!( keystroke_to_bytes(&ks(ctrl, "space", None), kitty()), Some(b"\x1b[32;5u".to_vec()) ); - // Alt+b escapes as CSI 98;3 u (not the legacy ESC-prefixed "b"). assert_eq!( keystroke_to_bytes(&ks(alt, "b", Some("b")), kitty()), Some(b"\x1b[98;3u".to_vec()) @@ -1082,7 +808,6 @@ mod tests { shift: true, ..Default::default() }; - // Shift+Up carries the modifier subfield; unmodified Up keeps the bare form. assert_eq!( keystroke_to_bytes(&ks(shift, "up", None), kitty()), Some(b"\x1b[1;2A".to_vec()) @@ -1091,7 +816,6 @@ mod tests { keystroke_to_bytes(&ks(none, "up", None), kitty()), Some(b"\x1b[A".to_vec()) ); - // Tilde-form keys likewise: Shift+Delete -> CSI 3;2 ~. assert_eq!( keystroke_to_bytes(&ks(shift, "delete", None), kitty()), Some(b"\x1b[3;2~".to_vec()) @@ -1106,7 +830,6 @@ mod tests { report_text: true, }; let none = Modifiers::default(); - // 'a' -> CSI 97 ; 1 ; 97 u (code ; mods ; text codepoint). assert_eq!( keystroke_to_bytes(&ks(none, "a", Some("a")), full), Some(b"\x1b[97;1;97u".to_vec()) @@ -1121,10 +844,6 @@ mod tests { report_text: true, }; let none = Modifiers::default(); - // The Kitty spec forbids control codes in the associated-text field (C0, - // DEL and the C1 block). A key whose reported char is a lone DEL (U+007F) - // or a C1 control (e.g. U+0085) must NOT land that codepoint in field 3; - // with no printable text left, the field is omitted entirely -> CSI 97 u. assert_eq!( keystroke_to_bytes(&ks(none, "a", Some("\u{7f}")), full), Some(b"\x1b[97u".to_vec()) @@ -1133,8 +852,6 @@ mod tests { keystroke_to_bytes(&ks(none, "a", Some("\u{85}")), full), Some(b"\x1b[97u".to_vec()) ); - // A printable char mixed with a control keeps only the printable codepoint - // in field 3 (the control is dropped, not the whole field): 'a' + DEL -> 97. assert_eq!( keystroke_to_bytes(&ks(none, "a", Some("a\u{7f}")), full), Some(b"\x1b[97;1;97u".to_vec()) @@ -1146,7 +863,6 @@ mod tests { let none = KittyFlags::default(); assert!(!none.active()); let mods = Modifiers::default(); - // With the protocol off, output matches the legacy path exactly. assert_eq!( keystroke_to_bytes(&ks(mods, "tab", None), none), Some(b"\t".to_vec()) @@ -1161,15 +877,11 @@ mod tests { ); } - /// Encode through the Option-key policy the way `on_key_down` does: reshape - /// first (macOS semantics), then hand the result to the shared encoder. fn reshaped_bytes(ks: &Keystroke, option_as_alt: bool, kitty: KittyFlags) -> Option<Vec<u8>> { let reshaped = reshape_option_keystroke(ks, option_as_alt); keystroke_to_bytes(reshaped.as_ref().unwrap_or(ks), kitty) } - /// An Option+B chord as gpui reports it on macOS: base key "b", the alt - /// bit, and the OS-composed character in `key_char`. fn option_b() -> Keystroke { let alt = Modifiers { alt: true, @@ -1180,12 +892,10 @@ mod tests { #[test] fn option_as_alt_on_sends_esc_plus_base_key() { - // Meta semantics: ESC + the plain key, not ESC + the composed char. assert_eq!( reshaped_bytes(&option_b(), true, KittyFlags::default()), Some(b"\x1bb".to_vec()) ); - // Shifted letters follow the shifted character: Option+Shift+B → ESC B. let alt_shift = Modifiers { alt: true, shift: true, @@ -1195,7 +905,6 @@ mod tests { reshaped_bytes(&ks(alt_shift, "b", Some("ı")), true, KittyFlags::default()), Some(b"\x1bB".to_vec()) ); - // Non-letter keys too: Option+2 composes "™" but Meta sends ESC 2. let alt = Modifiers { alt: true, ..Default::default() @@ -1208,29 +917,19 @@ mod tests { #[test] fn option_as_alt_off_sends_composed_text_bare() { - // macOS convention: the chord is text input — the composed character - // goes out with NO ESC prefix. (The unreshaped legacy path used to emit - // `ESC ∫`, wrong under either reading of the Option key.) assert_eq!( reshaped_bytes(&option_b(), false, KittyFlags::default()), Some("∫".as_bytes().to_vec()) ); } - /// The routing half of Option-as-Meta (#177): with a CJK input source active, - /// macOS hands ⌥-chords to the IME before the key handler runs, because ⌥B - /// composes printable text. The IME commits `∫` and eats the event, so the - /// reshape above never gets a say — unless the handler declines IME for exactly - /// these chords. Everything else keeps composing. #[test] fn meta_chords_skip_the_ime_only_when_option_is_meta() { let alt = Modifiers { alt: true, ..Default::default() }; - // The bug: ⌥B with the setting on must reach `on_key_down`, not the IME. assert!(meta_chord_bypasses_ime(&option_b(), true)); - // Shift rides along — ⌥⇧B is still a Meta chord. let alt_shift = Modifiers { alt: true, shift: true, @@ -1241,19 +940,13 @@ mod tests { true )); - // Setting off: ⌥ is text input, and the IME owns it — this is what makes - // dead keys (⌥E then E → `é`) compose. assert!(!meta_chord_bypasses_ime(&option_b(), false)); - // Plain text is never claimed, on either setting: CJK composition is the - // whole reason the handler prefers the IME in the first place. assert!(!meta_chord_bypasses_ime( &ks(Modifiers::default(), "n", Some("n")), true )); - // ⌘ chords are app shortcuts and ⌃ chords already bypass the IME upstream; - // both keep their existing routing rather than being claimed here. let cmd_alt = Modifiers { alt: true, platform: true, @@ -1267,8 +960,6 @@ mod tests { }; assert!(!meta_chord_bypasses_ime(&ks(ctrl_alt, "b", None), true)); - // Named keys carry the alt bit too and take the same route — Alt+Left must - // not be diverted into a composition either. assert!(meta_chord_bypasses_ime(&ks(alt, "left", None), true)); } @@ -1278,8 +969,6 @@ mod tests { alt: true, ..Default::default() }; - // Named keys compose nothing: Alt+Up keeps its ESC-prefixed form on - // both settings. for on in [true, false] { assert!(reshape_option_keystroke(&ks(alt, "up", None), on).is_none()); assert_eq!( @@ -1287,10 +976,7 @@ mod tests { Some(b"\x1b\x1b[A".to_vec()) ); } - // Enter's key_char is a control char ("\n"), not composed text: the - // chord stays a real Alt chord with the setting off. assert!(reshape_option_keystroke(&ks(alt, "enter", Some("\n")), false).is_none()); - // Ctrl+Alt chords keep the C0 + Meta-ESC encoding on both settings. let ctrl_alt = Modifiers { control: true, alt: true, @@ -1303,24 +989,18 @@ mod tests { Some(vec![0x1b, 0x03]) ); } - // No alt held → nothing to reshape, either setting. assert!( reshape_option_keystroke(&ks(Modifiers::default(), "a", Some("a")), true).is_none() ); - // A key_char already equal to the base key needs no clone. assert!(reshape_option_keystroke(&ks(alt, "b", Some("b")), true).is_none()); } #[test] fn option_reshape_composes_with_the_kitty_encoder() { - // Option-as-Meta keeps the alt bit, so a Kitty-aware app still sees the - // spec's alt-modified base key. assert_eq!( reshaped_bytes(&option_b(), true, kitty()), Some(b"\x1b[98;3u".to_vec()) ); - // Option-as-composed drops the alt bit: at the disambiguate level the - // chord is plain text, sent raw like any other typed character. assert_eq!( reshaped_bytes(&option_b(), false, kitty()), Some("∫".as_bytes().to_vec()) @@ -1329,8 +1009,6 @@ mod tests { #[test] fn kitty_never_encodes_cmd_chords() { - // Cmd (platform) chords stay app-shortcut territory even with Kitty on: - // the same `None`/legacy result as before. let cmd = Modifiers { platform: true, ..Default::default() diff --git a/src/terminal/marks.rs b/src/terminal/marks.rs index f7e3eda7..de68a0da 100644 --- a/src/terminal/marks.rs +++ b/src/terminal/marks.rs @@ -1,57 +1,15 @@ -//! Command marks: where each shell prompt started in the scrollback, so the -//! details panel's Outline can list a pane's commands and scroll back to one. -//! -//! Fed by the reader thread from OSC 133 (`A` prompt start, `C` command start, -//! `D` command done — the same shell-integration marks the daemon sniffs for -//! prompt state). The daemon reports only *whether* the shell is at its prompt; -//! positions have to come from the client, because only the client holds the -//! grid those positions are relative to. -//! -//! # Why a mark stores its text -//! -//! A grid row has no stable identity. Alacritty's `Line` is relative to the -//! viewport, so anything recorded in those coordinates slides as output arrives. -//! Converting to an absolute index from the top of history (`history_size - -//! display_offset + line`) is stable — *until the scrollback fills*. After that -//! alacritty discards the oldest row per new row, every surviving row's absolute -//! index silently decreases, and the amount discarded is not observable from -//! outside the emulator: `history_size` is pinned at the limit, and nothing else -//! exposes the scroll count. (Counting it exactly would mean wrapping -//! `vte::ansi::Handler` to intercept every line-producing sequence — 71 methods, -//! all with no-op defaults, so a future `vte` upgrade that adds one would -//! silently break rendering. Not worth it for this.) -//! -//! So the absolute index is treated as a *hint* and the row's text as the -//! *truth*: each mark records what its row said when it was made, and a reader -//! re-reads the row before trusting the position. A mark whose row no longer -//! matches has drifted out from under us and is reported stale rather than -//! silently scrolling somewhere wrong. Below the scrollback limit — which is -//! where a pane spends most of its life — the hint is exact and the check always -//! passes. - use std::sync::{Arc, Mutex}; -/// Cap on retained marks. Deep scrollback holds far more prompts than a panel -/// list is useful at, and the oldest are the likeliest to have drifted anyway. const MAX_MARKS: usize = 500; -/// One shell prompt, and the command run from it. #[derive(Clone, Debug, PartialEq, Eq)] pub struct CommandMark { - /// Row index from the top of the scrollback at record time — the position - /// hint. See the module docs for when it stops being exact. pub row: i64, - /// What the row said when the mark was made, used to detect drift. Empty - /// while the prompt has been printed but nothing has been typed yet. pub text: String, - /// Exit code from `OSC 133;D`, once the command finishes. pub exit: Option<i32>, - /// Whether the command has finished (a `D` mark arrived). Distinct from - /// `exit.is_some()`: a `D` without a code still means "done". pub done: bool, } -/// A pane's marks, shared between the reader thread (writer) and the UI (reader). #[derive(Clone, Default)] pub struct Marks(Arc<Mutex<Vec<CommandMark>>>); @@ -60,14 +18,8 @@ impl Marks { Self::default() } - /// Begin a mark at `row` (`OSC 133;A` — the shell is about to print a - /// prompt). `text` is the row's current content, which is normally empty at - /// this point and gets filled in by [`set_text`](Self::set_text) once the - /// command has been typed. pub fn begin(&self, row: i64, text: String) { let Ok(mut marks) = self.0.lock() else { return }; - // A prompt redraw (a resize, a `clear`, zle repainting the line) re-emits - // `A` on the same row. Update in place rather than stacking duplicates. if marks.last().is_some_and(|m| m.row == row && !m.done) { if let Some(last) = marks.last_mut() { last.text = text; @@ -80,16 +32,12 @@ impl Marks { exit: None, done: false, }); - // Trim from the front: oldest marks age out of the scrollback first. let overflow = marks.len().saturating_sub(MAX_MARKS); if overflow > 0 { marks.drain(..overflow); } } - /// Attach the command line to the open mark (`OSC 133;C` — the user hit - /// enter, so the prompt row now holds the command). Ignored when no mark is - /// open, which is what a `C` without a preceding `A` means. pub fn set_text(&self, text: String) { let Ok(mut marks) = self.0.lock() else { return }; if let Some(last) = marks.last_mut() { @@ -99,7 +47,6 @@ impl Marks { } } - /// Close the open mark (`OSC 133;D[;exit]`). pub fn finish(&self, exit: Option<i32>) { let Ok(mut marks) = self.0.lock() else { return }; if let Some(last) = marks.last_mut() { @@ -108,8 +55,6 @@ impl Marks { } } - /// Snapshot for rendering, newest last. Marks that never got a command are - /// dropped: a bare prompt the user typed nothing at is not an outline entry. pub fn list(&self) -> Vec<CommandMark> { let Ok(marks) = self.0.lock() else { return Vec::new(); @@ -121,7 +66,6 @@ impl Marks { .collect() } - /// Drop everything (the pane was cleared, so every position is meaningless). pub fn clear(&self) { if let Ok(mut marks) = self.0.lock() { marks.clear(); @@ -129,61 +73,34 @@ impl Marks { } } -/// Parse the exit code out of an `OSC 133;D` payload: `D`, `D;0`, `D;1`, and -/// zsh's `D;aborted` all occur. Anything unparseable is "done, code unknown". pub fn parse_done_exit(payload: &[u8]) -> Option<i32> { let rest = payload.strip_prefix(b"D")?; let rest = rest.strip_prefix(b";")?; std::str::from_utf8(rest).ok()?.trim().parse().ok() } -/// What a recognized `OSC 133` mark means for the outline. #[derive(Clone, Debug, PartialEq, Eq)] pub enum MarkEvent { - /// `A` — the shell is about to print a prompt. Prompt, - /// `C;<cmd>` — the command was submitted and its output starts here. tty7's - /// own shell integration always includes the command line, so the outline - /// never has to guess it back out of the grid (where it would be tangled up - /// with the user's prompt string). Command(String), - /// `D[;exit]` — the command finished. Done(Option<i32>), } -/// Finds `OSC 133` marks in the output stream and reports *where* each one lands -/// — the byte offset just past the sequence — so the caller can advance the -/// emulator up to exactly that point and read the grid position there. -/// -/// Separate from [`OscTokenizer`](crate::core::osc::OscTokenizer), which reports -/// payloads but not offsets. Carries its state across feeds, so a mark split over -/// two socket reads is still recognized (and attributed to the batch its -/// terminator lands in, which is the correct row either way). #[derive(Default)] pub struct MarkScanner { state: ScanState, - /// Payload bytes collected so far, possibly spanning feeds. Bounded: a - /// "payload" that runs past any plausible command line is a desync, not a - /// mark, so it's abandoned rather than grown without limit. payload: Vec<u8>, } #[derive(Default, PartialEq, Eq)] enum ScanState { - /// Ordinary output. #[default] Text, - /// Saw `ESC`, waiting to see whether `]` follows. Esc, - /// Inside an OSC payload, collecting until BEL or ST. Osc, - /// Saw `ESC` inside an OSC payload — an ST (`ESC \`) if `\` follows. OscEsc, } -/// Ceiling on a collected OSC payload. Long enough for any real command line, -/// short enough that a stream that never terminates its OSC can't grow a buffer -/// unboundedly. const MAX_PAYLOAD: usize = 64 * 1024; impl MarkScanner { @@ -191,21 +108,10 @@ impl MarkScanner { Self::default() } - /// Feed one batch. `on_mark(offset, event)` fires for each recognized mark, - /// where `offset` is an index into `bytes` just past the mark's terminator. - /// Ordinary output is the overwhelming majority of every batch, and the only - /// byte that can end it is `ESC` — so that state skips ahead with SIMD - /// `memchr` rather than stepping per byte, exactly as - /// [`OscTokenizer::feed`](crate::core::osc::OscTokenizer::feed) does. This - /// scanner runs over every batch the client receives, alongside three - /// tokenizers that already did this; measured on an 8 MB batch of plausible - /// output it was the difference between 1.6 GB/s and 8.3 GB/s. pub fn feed(&mut self, bytes: &[u8], mut on_mark: impl FnMut(usize, MarkEvent)) { let mut i = 0; while i < bytes.len() { if self.state == ScanState::Text { - // No ESC in the rest of the batch means nothing here can matter: - // the state stays `Text`, which is where the next feed resumes. let Some(off) = memchr::memchr(0x1b, &bytes[i..]) else { return; }; @@ -215,14 +121,12 @@ impl MarkScanner { } let b = bytes[i]; match self.state { - // Handled by the skip-ahead above. ScanState::Text => unreachable!(), ScanState::Esc => { if b == b']' { self.state = ScanState::Osc; self.payload.clear(); } else { - // Some other escape sequence; `ESC ESC` restarts. self.state = if b == 0x1b { ScanState::Esc } else { @@ -242,8 +146,6 @@ impl MarkScanner { if self.payload.len() < MAX_PAYLOAD { self.payload.push(b); } else { - // Runaway payload: give up on this sequence rather - // than buffer the rest of the stream into it. self.state = ScanState::Text; self.payload.clear(); } @@ -256,10 +158,6 @@ impl MarkScanner { } self.state = ScanState::Text; } else { - // Not an ST after all — the ESC was payload. Bounded like - // the ordinary payload byte below it: a stream of bare - // ESCs inside an unterminated OSC would otherwise grow the - // buffer a byte at a time, never reaching the check there. if self.payload.len() < MAX_PAYLOAD { self.payload.push(0x1b); self.state = ScanState::Osc; @@ -274,15 +172,12 @@ impl MarkScanner { } } - /// Interpret the collected payload, clearing it either way. fn take(&mut self) -> Option<MarkEvent> { let payload = std::mem::take(&mut self.payload); let body = payload.strip_prefix(b"133;")?; match body.first()? { b'A' => Some(MarkEvent::Prompt), b'C' => { - // `C` alone (no command) still marks output start; the shells - // that can't report the line send it bare. let cmd = body .strip_prefix(b"C;") .map(|c| String::from_utf8_lossy(c).into_owned()) @@ -290,8 +185,6 @@ impl MarkScanner { Some(MarkEvent::Command(cmd)) } b'D' => Some(MarkEvent::Done(parse_done_exit(body))), - // `B` (prompt end) and `V` (tty7's edit-mode extension) carry no - // position the outline cares about. _ => None, } } @@ -318,8 +211,6 @@ mod tests { let marks = Marks::new(); marks.begin(10, String::new()); marks.set_text("cargo t".into()); - // zle repaints the prompt on the same row (a resize, a completion menu - // closing) and the shell re-emits `A`. marks.begin(10, "cargo test".into()); let got = marks.list(); assert_eq!(got.len(), 1, "a redraw is the same prompt, not a new one"); @@ -332,7 +223,6 @@ mod tests { marks.begin(10, String::new()); marks.set_text("ls".into()); marks.finish(Some(0)); - // Same row is possible after a `clear`. marks.begin(10, String::new()); marks.set_text("pwd".into()); let got = marks.list(); @@ -353,8 +243,6 @@ mod tests { assert_eq!(got[0].text, "cmd10", "the oldest aged out, not the newest"); } - /// Collect `(offset, event)` pairs from feeding `chunks` in order, so a test - /// can assert on a stream split at arbitrary boundaries. fn scan(chunks: &[&[u8]]) -> Vec<(usize, MarkEvent)> { let mut scanner = MarkScanner::new(); let mut out = Vec::new(); @@ -364,24 +252,14 @@ mod tests { out } - /// The `Text` state skips to the next `ESC` with `memchr` instead of - /// walking byte by byte, which means it — not the loop — decides where - /// scanning resumes. Splitting one stream at *every* offset and comparing - /// against the unsplit scan pins that: an off-by-one in the resume index, - /// or a state that the skip forgets to carry across a feed, shows up as a - /// shifted offset or a lost mark at exactly one split point. #[test] fn splitting_anywhere_yields_the_same_marks() { - // Deliberately awkward: bare ESCs, an `ESC ESC` restart, a non-OSC - // escape, an ST-terminated mark and a BEL-terminated one. let stream: &[u8] = b"out\x1b\x1b[32mmore\x1b]133;C;git status\x07text\x1b]133;D;0\x1b\\tail\x1b"; let whole = scan(&[stream]); assert_eq!(whole.len(), 2, "both marks found in one pass"); for at in 0..=stream.len() { - // Offsets are relative to the feed they came from, so rebase the - // second half onto the whole stream before comparing. let mut scanner = MarkScanner::new(); let mut got = Vec::new(); scanner.feed(&stream[..at], |off, ev| got.push((off, ev))); @@ -394,8 +272,6 @@ mod tests { fn reports_marks_just_past_their_terminator() { let got = scan(&[b"ab\x1b]133;A\x07cd"]); assert_eq!(got, vec![(10, MarkEvent::Prompt)]); - // The offset must point past the BEL, so advancing `bytes[..offset]` - // consumes the whole sequence and nothing of what follows. assert_eq!(&b"ab\x1b]133;A\x07cd"[10..], b"cd"); } @@ -411,8 +287,6 @@ mod tests { #[test] fn accepts_st_terminated_marks() { - // `ESC \` instead of BEL — both are legal OSC terminators and the - // integrations use ST on some shells. let got = scan(&[b"\x1b]133;D;130\x1b\\"]); assert_eq!(got, vec![(13, MarkEvent::Done(Some(130)))]); } diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index d3ed546c..cb0c3e84 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -1,20 +1,3 @@ -//! The `terminal` subsystem, split by concern: -//! - [`size`] — `TermSize`, the grid dimensions shared by the remote terminal -//! and the view. -//! - [`remote`] — the daemon-backed `RemoteTerminal`: owns a socket + a local -//! mirror emulator, fed bytes the daemon replays instead of owning a PTY. -//! - [`view`] — the GPUI view that hosts a terminal and renders the chrome. -//! - [`element`] — the custom element that paints the character grid. -//! - [`palette`] — the terminal color scheme. -//! -//! Shell integration (the rc files that emit OSC 7 / OSC 133) used to live here -//! but now sits in `daemon::shell_integration`, beside the PTY-owning `pane` -//! that is its only injector — which is what keeps `daemon` from depending back -//! on `terminal`. -//! -//! `TermSize` / `RemoteTerminal` are re-exported here so the rest of the crate -//! can refer to `terminal::RemoteTerminal` without reaching into submodules. - mod boxdraw; mod cmd_editor; mod completion; diff --git a/src/terminal/palette.rs b/src/terminal/palette.rs index b693f7b4..c23bcf07 100644 --- a/src/terminal/palette.rs +++ b/src/terminal/palette.rs @@ -1,21 +1,6 @@ -//! tty7 terminal color scheme. -//! -//! A self-contained, hand-tuned palette (not derived from any other terminal -//! theme) covering the ANSI 16 colors for both dark and light backgrounds, the -//! 256-color xterm fallback cube, and the text-selection colors. The goal is a -//! calm, slightly cool-neutral look where every accent stays legible on its -//! intended background and the bright variants are clearly lifted from the -//! normal ones without becoming neon. - use alacritty_terminal::vte::ansi::Rgb; use gpui::Global; -/// The terminal-facing slice of the active color scheme: the ANSI-16 set and -/// the selection surface for the current (preset, mode) — the base the search -/// match washes derive from (the selection itself paints as a translucent -/// foreground wash; see `element::PaintColors`). Published as a GPUI global by -/// the UI layer's `apply_theme` so the renderer always paints the active -/// scheme without the terminal layer depending on `ui`. #[derive(Debug, Clone)] pub struct ActivePalette { pub ansi16: [Rgb; 16], @@ -24,8 +9,6 @@ pub struct ActivePalette { impl Global for ActivePalette {} -/// Convert a GPUI `Hsla` to an alacritty `Rgb` (8-bit per channel, rounded and -/// clamped). Shared by the renderer and the OSC color-query replies. pub fn hsla_to_rgb(c: gpui::Hsla) -> Rgb { let rgba = gpui::Rgba::from(c); Rgb { @@ -35,37 +18,28 @@ pub fn hsla_to_rgb(c: gpui::Hsla) -> Rgb { } } -/// Dark-theme ANSI 16 set, tuned for the warm "soft charcoal" background -/// (~#232220 — see ui/theme.rs). The neutral slots (0/7/8/15) carry the same -/// faint warm cast as the shell so grays don't read cool-and-dirty against the -/// warm base; the colored accents stay slightly desaturated for long sessions. const DARK_ANSI16: [(u8, u8, u8); 16] = [ - (0x2c, 0x2a, 0x26), // 0 black (warm, lifted off the bg so it's not invisible) - (0xec, 0x6a, 0x78), // 1 red - (0x8f, 0xbf, 0x6e), // 2 green - (0xe0, 0xb0, 0x72), // 3 yellow - (0x6f, 0xa8, 0xe6), // 4 blue - (0xc0, 0x8a, 0xdf), // 5 magenta - (0x5f, 0xc2, 0xc9), // 6 cyan - (0xd2, 0xcf, 0xc8), // 7 white (warm light gray — matches default foreground) - (0x6b, 0x66, 0x5d), // 8 bright black (warm comment gray) - (0xf5, 0x86, 0x8f), // 9 bright red - (0xa8, 0xd9, 0x8a), // 10 bright green - (0xef, 0xc7, 0x8a), // 11 bright yellow - (0x8f, 0xc0, 0xf5), // 12 bright blue - (0xd2, 0xa6, 0xec), // 13 bright magenta - (0x84, 0xd6, 0xdc), // 14 bright cyan - (0xf6, 0xf3, 0xec), // 15 bright white (warm) + (0x2c, 0x2a, 0x26), + (0xec, 0x6a, 0x78), + (0x8f, 0xbf, 0x6e), + (0xe0, 0xb0, 0x72), + (0x6f, 0xa8, 0xe6), + (0xc0, 0x8a, 0xdf), + (0x5f, 0xc2, 0xc9), + (0xd2, 0xcf, 0xc8), + (0x6b, 0x66, 0x5d), + (0xf5, 0x86, 0x8f), + (0xa8, 0xd9, 0x8a), + (0xef, 0xc7, 0x8a), + (0x8f, 0xc0, 0xf5), + (0xd2, 0xa6, 0xec), + (0x84, 0xd6, 0xdc), + (0xf6, 0xf3, 0xec), ]; -/// Build the full 256-entry xterm palette (dark-theme ANSI 16 in slots 0-15). -/// -/// Slots 0-15 are a sensible default only: the renderer overwrites them every -/// paint with the active preset's ANSI set (see `ui::presets::ActivePalette`). pub fn build() -> [Rgb; 256] { let mut p = [Rgb { r: 0, g: 0, b: 0 }; 256]; - // 0-15: ANSI 16. for (i, (r, g, b)) in DARK_ANSI16.iter().enumerate() { p[i] = Rgb { r: *r, @@ -74,7 +48,6 @@ pub fn build() -> [Rgb; 256] { }; } - // 16-231: 6×6×6 color cube. let steps = [0u8, 95, 135, 175, 215, 255]; let mut idx = 16; for r in 0..6 { @@ -90,7 +63,6 @@ pub fn build() -> [Rgb; 256] { } } - // 232-255: grayscale ramp. for i in 0..24 { let v = 8 + i as u8 * 10; p[232 + i] = Rgb { r: v, g: v, b: v }; @@ -105,10 +77,8 @@ mod tests { #[test] fn hsla_to_rgb_round_trips_a_known_color() { - // A `#rrggbb` literal → Hsla → Rgb should recover the byte channels. let rgb = hsla_to_rgb(gpui::rgb(0x123456).into()); assert_eq!((rgb.r, rgb.g, rgb.b), (0x12, 0x34, 0x56)); - // Pure black and white clamp cleanly. let black = hsla_to_rgb(gpui::rgb(0x000000).into()); assert_eq!((black.r, black.g, black.b), (0, 0, 0)); let white = hsla_to_rgb(gpui::rgb(0xffffff).into()); @@ -118,17 +88,13 @@ mod tests { #[test] fn build_lays_out_the_256_color_cube_and_ramp() { let p = build(); - // Slots 0-15 are the dark ANSI set. for (i, (r, g, b)) in DARK_ANSI16.iter().enumerate() { assert_eq!((p[i].r, p[i].g, p[i].b), (*r, *g, *b)); } - // The 6×6×6 cube runs 16..=231: first is black, last is white. assert_eq!((p[16].r, p[16].g, p[16].b), (0, 0, 0)); assert_eq!((p[231].r, p[231].g, p[231].b), (255, 255, 255)); - // The grayscale ramp is 232..=255, starting at 8 and stepping by 10. assert_eq!(p[232].r, 8); assert_eq!(p[255].r, 8 + 23 * 10); - // Ramp entries are true grays. assert_eq!(p[240].r, p[240].g); assert_eq!(p[240].g, p[240].b); } diff --git a/src/terminal/pane_liveness.rs b/src/terminal/pane_liveness.rs index cb4e5a6c..822c5f06 100644 --- a/src/terminal/pane_liveness.rs +++ b/src/terminal/pane_liveness.rs @@ -1,65 +1,3 @@ -//! Which of a workspace's saved panes are still running — asked of **the -//! machine that workspace lives on**, cached per machine, probed off the UI -//! thread. -//! -//! # The bug this exists to close -//! -//! A `pane_id` is minted by one daemon and means nothing to any other. Two -//! machines hand out `1`, `2`, `3` in the same order, so a remote workspace's -//! saved ids overlap this computer's almost perfectly. Every liveness question -//! that skipped the route therefore had *two* wrong answers available: the -//! benign one (the remote's ids are absent here, so its sessions read as -//! stopped) and the misleading one — the id happens to name a live *local* -//! pane, and a workspace on a box that has been off for a week lights up green -//! because somebody's shell on this laptop holds the number. The title-bar -//! workspace menu was doing exactly the latter. -//! -//! Routing alone does not fix a *cross-workspace* view. The picker and the -//! workspace menu list several workspaces at once, on several machines at once, -//! so there is no single route to send: it takes one query per machine, and -//! those queries cannot be waited on in turn from `render`. -//! -//! # Three states, not two -//! -//! | State | Means | Drawn as | -//! |---|---|---| -//! | [`Liveness::Alive`] | the machine answered, and it still has one of these panes | green corner dot | -//! | [`Liveness::Stopped`] | the machine answered, and none of them are left | no dot | -//! | [`Liveness::Unknown`] | we could not ask | muted corner dot | -//! -//! `Unknown` is the state remote workspaces made necessary. A failed query to -//! another machine is not evidence that anything died — the sessions are very -//! probably fine and the *link* is what broke — and rendering it as "stopped" -//! would tell the user their work is gone every time the network blinks. -//! -//! **A local `List` failing is not `Unknown`.** It travels a unix socket to a -//! daemon whose absence is itself the answer: no daemon, no live panes. So a -//! local host with no cached *liveness* answer reads `Stopped`, which is what -//! this page has always drawn — the async cache changes remote behaviour and -//! leaves local pixels alone. -//! -//! Not knowing which panes to ask about is a different thing, and it is -//! `Unknown` on every machine. The ids live in the machine's tree -//! ([`crate::ui::machine_mirror`]), so until that first pull lands there is no -//! question to put to the daemon — and "no ids yet" must not be read as "no -//! sessions", which is a claim about the user's work founded on our own -//! ignorance. Locally the pull lands within a frame or two of launch; where -//! there is no control link at all, a muted dot is exactly the truth. -//! -//! # How it is filled -//! -//! [`sweep`] is called from the render paths that show liveness. It never -//! blocks: it looks at the workspace list, and for each machine whose answer is -//! missing or past its TTL it starts one background query. All of them fly at -//! once — N machines cost one round trip, not N in a row — and [`InFlight`] -//! keeps a frame that re-asks before the answer lands from starting a second. -//! Landing goes through `update_global`, so the `observe_global` hook in -//! [`crate::ui::app`] repaints whatever is on screen. -//! -//! A machine this process has no connection to is **not** probed: asking would -//! mean dialling SSH, and a liveness dot is not a reason to open a connection -//! (or raise a passphrase prompt). It stays `Unknown`, which is the truth. - use std::cell::Cell; use std::collections::{HashMap, HashSet}; use std::time::{Duration, Instant}; @@ -70,70 +8,31 @@ use crate::core::session::{WindowView, WorkspaceId, WorkspaceStore}; use crate::terminal::{PaneRoute, RemoteTerminal}; use crate::ui::host_ops::{HostId, InFlight}; -/// How long this machine's answer stays fresh. The value the home picker's -/// blocking cache used before any of this was routed, kept so a local -/// workspace's dot updates on exactly the cadence it always did. const LOCAL_TTL: Duration = Duration::from_millis(2_000); -/// How long another machine's answer stays fresh. Longer than the local one -/// because the query is a routed round trip rather than a unix socket, and a -/// dot is not worth a heartbeat. const REMOTE_TTL: Duration = Duration::from_secs(10); -/// How long to sit on a *failed* answer before asking again. -/// -/// Shorter than the success TTL, which looks backwards until you notice the two -/// are decaying different things. A landed answer is a fact, and facts about -/// which shells are running go stale slowly. A failure is the absence of a fact: -/// it carries nothing, it is the state the user most wants corrected, and the -/// thing that would correct it — the link coming back — is exactly what this -/// interval decides how fast we notice. const UNREACHABLE_TTL: Duration = Duration::from_secs(6); -/// How often [`sweep`] is allowed to walk the workspace list. -/// -/// The sweep itself is called from `render`, which on a 120Hz display is 120 -/// times a second; the walk builds a `pane_ids()` vector and a connection key -/// per workspace, which is not free enough to do that often. Everything past -/// this gate is idempotent, so the only cost of the gate is that a probe may -/// start up to a quarter-second late. const SWEEP_INTERVAL: Duration = Duration::from_millis(250); thread_local! { - /// When [`sweep`] last walked the list. - /// - /// Deliberately *not* a field of [`PaneLivenessCache`]: reaching a global - /// mutably notifies its observers, the app repaints on that notification, - /// and the repaint sweeps again — a stamp stored in the global would spin - /// the render loop at full speed forever. It is also honestly thread-local - /// state, since only the UI thread ever sweeps. - static LAST_SWEEP: Cell<Option<Instant>> = const { Cell::new(None) }; + static LAST_SWEEP: Cell<Option<Instant>> = const { Cell::new(None) }; } -/// What is known about a workspace's panes. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum Liveness { - /// The machine answered and at least one of the workspace's panes is still - /// running: reopening it reattaches to live shells. Alive, - /// The machine answered and none of them are: the saved layout is all that - /// is left, and reopening spawns fresh. Stopped, - /// The machine could not be asked. Not the same as `Stopped` — see the - /// module docs. Unknown, } -/// One machine's last landed answer. struct Answer { - /// When it landed, for the TTL. at: Instant, - /// The live pane ids the daemon reported, or `None` when the query failed. alive: Option<HashSet<u64>>, } impl Answer { - /// Whether this answer may still be used without re-asking. fn fresh(&self, host: HostId) -> bool { let ttl = match (&self.alive, host.is_local()) { (None, _) => UNREACHABLE_TTL, @@ -144,35 +43,16 @@ impl Answer { } } -/// The process-wide liveness store (a gpui [`Global`](gpui::Global)), keyed by -/// machine. -/// -/// Per **machine**, not per workspace: two workspaces on one box share a pane -/// registry, so they share one query. That is the same granularity -/// [`HostId`] already has everywhere else in the client. #[derive(Default)] pub struct PaneLivenessCache { answers: HashMap<HostId, Answer>, - /// Queries out, so a render that re-asks before one lands does not start a - /// second. There is nothing to supersede a liveness answer with, so only - /// the in-flight half of [`InFlight`] is used. probes: InFlight<HostId>, } impl gpui::Global for PaneLivenessCache {} impl PaneLivenessCache { - /// Whether any of `pane_ids` is still running on `host`. - /// - /// `pane_ids` must be the ids **that machine** minted — i.e. the ids of a - /// workspace whose `host_id()` is `host`. Mixing them is the bug the whole - /// module exists to prevent, and keying by host is how it is prevented: - /// there is no way to spell the question without naming the machine. pub fn liveness(&self, host: HostId, pane_ids: &[u64]) -> Liveness { - // Claims nothing, so nothing about it is in question — not even on a - // machine we cannot reach. Answered before the cache is consulted so an - // empty workspace never draws the "unknown" dot while it waits for an - // answer that could not change it. if pane_ids.is_empty() { return Liveness::Stopped; } @@ -184,23 +64,15 @@ impl PaneLivenessCache { Liveness::Stopped } } - // Never asked, or asked and refused. On this machine that is not a - // mystery — an unreachable local daemon is a daemon with no panes - // in it — so local resolves to `Stopped` and keeps the pre-remote - // rendering exactly as it was. None if host.is_local() => Liveness::Stopped, None => Liveness::Unknown, } } - /// The live ids `host` last reported, or `None` when the last word from it - /// was a failure (or there has been no word at all). fn alive_set(&self, host: HostId) -> Option<&HashSet<u64>> { self.answers.get(&host)?.alive.as_ref() } - /// Whether a probe for `host` is worth starting: nothing fresh cached, and - /// nothing already in flight. pub fn needs_probe(&self, host: HostId) -> bool { !self.probes.is_pending(&host) && !self @@ -209,13 +81,10 @@ impl PaneLivenessCache { .is_some_and(|answer| answer.fresh(host)) } - /// Claim the probe for `host`. `false` when someone else already has it. pub fn begin_probe(&mut self, host: HostId) -> bool { self.probes.begin(host) } - /// Fold a landed probe in: `Some(ids)` when the daemon answered, `None` - /// when it could not be reached. pub fn finish_probe(&mut self, host: HostId, alive: Option<HashSet<u64>>) { self.probes.finish(&host); self.answers.insert( @@ -227,46 +96,22 @@ impl PaneLivenessCache { ); } - /// Forget what `host` said, so the next sweep asks again. - /// - /// For the moments the app itself made the answer wrong — stopping a - /// workspace kills panes the cache still lists — where waiting out the TTL - /// would leave a green dot on a workspace the user just shut down. pub fn invalidate(&mut self, host: HostId) { self.answers.remove(&host); } } -/// [`PaneLivenessCache::liveness`] for a whole workspace, read-only. -/// -/// The one call the render sites make. It cannot ask the wrong machine: the -/// host and the ids both come off the same [`WindowView`]. pub fn liveness_of(cx: &App, workspace: &WindowView) -> Liveness { let host = workspace.host_id(); - // The ids live in the machine's tree; its mirror is where they are read. A - // machine whose tree has not been pulled leaves us with no question to ask, - // which is `Unknown` on any machine — reading it as `Stopped` would tell the - // user their sessions are gone on the strength of our own ignorance. See the - // module docs for why this is *not* the same as a failed local `List`. let Some(ids) = crate::ui::machine_mirror::pane_ids(cx, workspace) else { return Liveness::Unknown; }; match cx.try_global::<PaneLivenessCache>() { Some(cache) => cache.liveness(host, &ids), - // Before the app has installed the global. Asked of an empty cache - // rather than answered here, so there is exactly one place that decides - // what "nothing known yet" looks like and the first frame draws what - // every later one will. None => PaneLivenessCache::default().liveness(host, &ids), } } -/// Start whatever liveness queries the workspace list is missing. -/// -/// Safe to call from `render`: it reads the cache, may start background work, -/// and never blocks or waits. Rate-limited by [`SWEEP_INTERVAL`], deduplicated -/// per machine by [`InFlight`], and gated per machine by the TTL — so a picker -/// sitting open does not turn into a query loop. pub fn sweep(cx: &mut App) { let now = Instant::now(); if LAST_SWEEP.get().is_some_and(|at| now < at + SWEEP_INTERVAL) { @@ -274,10 +119,6 @@ pub fn sweep(cx: &mut App) { } LAST_SWEEP.set(Some(now)); - // One workspace per machine is enough to build that machine's route, and - // only workspaces that claim panes have anything to ask about. Collected - // first so the borrow of the store is released before the probes, which - // need `cx` mutably. let mut targets: Vec<(HostId, WorkspaceId)> = Vec::new(); for w in &WorkspaceStore::all(cx).views { let host = w.host_id(); @@ -294,10 +135,6 @@ pub fn sweep(cx: &mut App) { } } -/// Ask one machine, in the background. -/// -/// `workspace` is only used to build the route; the answer is stored against -/// the machine, and every workspace on it reads the same one. fn probe_host(cx: &mut App, host: HostId, workspace: WorkspaceId) { if !cx .try_global::<PaneLivenessCache>() @@ -305,31 +142,16 @@ fn probe_host(cx: &mut App, host: HostId, workspace: WorkspaceId) { { return; } - // Never dial for a dot. A routed query to a machine this process is not - // connected to would have the daemon open an SSH session — with whatever - // passphrase prompt and multi-second handshake that entails — because a - // picker row was on screen. Unconnected stays `Unknown`, which is exactly - // what it is. - // - // Recorded as a landed failure rather than returned from: a bare `return` - // would leave `needs_probe` true, so the next frame would re-decide this, - // and `HostLinks::get` reaches its global mutably — which notifies, - // which repaints, which sweeps. Storing the answer puts the decision behind - // the same TTL as every other one. if !host.is_local() && crate::ui::remote_connect::HostLinks::get(cx, host).is_none() { cx.update_global::<PaneLivenessCache, _>(|cache, _| cache.finish_probe(host, None)); return; } let route = crate::ui::remote_workspace::pane_route_for(cx, workspace); - // `global_mut` notifies, so the claim is taken last: everything above can - // decline without costing a repaint. if !cx.global_mut::<PaneLivenessCache>().begin_probe(host) { return; } cx.spawn(async move |cx| { let alive = cx.background_spawn(async move { query(&route) }).await; - // Landed through `update_global`, which is what wakes the - // `observe_global` hook that repaints the picker and the title bar. cx.update(|cx| { cx.update_global::<PaneLivenessCache, _>(|cache, _| cache.finish_probe(host, alive)); }); @@ -337,9 +159,6 @@ fn probe_host(cx: &mut App, host: HostId, workspace: WorkspaceId) { .detach(); } -/// The blocking half: one `List` down `route`, reduced to the ids that are -/// still running. `None` is "could not ask", which is the distinction the whole -/// three-state rendering rests on. fn query(route: &PaneRoute) -> Option<HashSet<u64>> { match RemoteTerminal::try_list_panes_on(route) { Ok(panes) => Some( @@ -360,8 +179,6 @@ fn query(route: &PaneRoute) -> Option<HashSet<u64>> { mod tests { use super::*; - /// A remote machine, and a second one, so "keyed by host" can be tested - /// rather than asserted. fn box_a() -> HostId { HostId::from_connection_key("ssh-direct:me@a:22") } @@ -369,65 +186,42 @@ mod tests { HostId::from_connection_key("ssh-direct:me@b:22") } - /// The three states, each from the input that produces it. #[test] fn the_three_states_come_from_three_different_situations() { let mut cache = PaneLivenessCache::default(); let host = box_a(); - // Never asked: not "stopped" — unknown. assert_eq!(cache.liveness(host, &[1, 2]), Liveness::Unknown); - // Asked, and the machine listed one of them. cache.finish_probe(host, Some(HashSet::from([2, 9]))); assert_eq!(cache.liveness(host, &[1, 2]), Liveness::Alive); - // Asked, and none of this workspace's panes are in the answer. assert_eq!(cache.liveness(host, &[1, 3]), Liveness::Stopped); - // Asked and could not be reached: back to unknown, *not* stopped — - // the panes are very probably still running over there. cache.finish_probe(host, None); assert_eq!(cache.liveness(host, &[1, 2]), Liveness::Unknown); } - /// The bug. Two machines mint the same small pane ids, and a workspace on - /// one must never be lit up by the other's registry. #[test] fn one_machines_answer_never_speaks_for_another() { let mut cache = PaneLivenessCache::default(); - // The local daemon is running panes 1 and 2 — the ids a fresh daemon - // on any machine hands out first. cache.finish_probe(HostId::LOCAL, Some(HashSet::from([1, 2]))); - // The box has been off for a week: nothing of its own is claimed here. assert_eq!(cache.liveness(box_a(), &[1, 2]), Liveness::Unknown); - // And a *third* machine's answer does not leak into the second's. cache.finish_probe(box_b(), Some(HashSet::from([1, 2]))); assert_eq!(cache.liveness(box_a(), &[1, 2]), Liveness::Unknown); assert_eq!(cache.liveness(box_b(), &[1, 2]), Liveness::Alive); - // Local still reads exactly as it always did. assert_eq!(cache.liveness(HostId::LOCAL, &[1, 2]), Liveness::Alive); assert_eq!(cache.liveness(HostId::LOCAL, &[7]), Liveness::Stopped); } - /// This machine never shows "unknown": an unreachable local daemon is a - /// daemon with nothing running in it, and the picker drew that as a plain - /// badge long before any of this was routed. #[test] fn local_never_renders_as_unknown() { let mut cache = PaneLivenessCache::default(); - // Nothing asked yet — the state every first frame is in. assert_eq!(cache.liveness(HostId::LOCAL, &[1]), Liveness::Stopped); - // Asked, and no daemon answered. cache.finish_probe(HostId::LOCAL, None); assert_eq!(cache.liveness(HostId::LOCAL, &[1]), Liveness::Stopped); } - /// A workspace that claims no panes is stopped on any machine — there is - /// nothing for an answer to contain, so it is not "unknown" either, even on - /// a machine that was never asked. Caught in the GUI: an empty remote - /// workspace sat in the picker wearing the muted dot, which reads as "we - /// could not check" about a workspace there is nothing to check. #[test] fn a_workspace_with_no_claimed_panes_is_never_alive() { let mut cache = PaneLivenessCache::default(); @@ -439,8 +233,6 @@ mod tests { assert_eq!(cache.liveness(box_a(), &[]), Liveness::Stopped); } - /// One query per machine in flight, and a fresh answer stops the asking — - /// this is what keeps a picker on screen from becoming a query loop. #[test] fn probes_are_deduplicated_and_then_throttled_by_the_ttl() { let mut cache = PaneLivenessCache::default(); @@ -454,19 +246,13 @@ mod tests { cache.finish_probe(host, Some(HashSet::from([1]))); assert!(!cache.needs_probe(host), "the answer is fresh"); - // A failure is cached too, or an unreachable machine would be retried - // on every frame — each retry a connect timeout on a background task. cache.finish_probe(host, None); assert!(!cache.needs_probe(host)); - // Invalidation is the way back to asking, for the moments the app - // itself made the answer wrong. cache.invalidate(host); assert!(cache.needs_probe(host)); } - /// Each machine's freshness is its own: a fresh local answer must not stop - /// the sweep from asking the box. #[test] fn freshness_is_per_machine() { let mut cache = PaneLivenessCache::default(); @@ -475,10 +261,6 @@ mod tests { assert!(cache.needs_probe(box_a())); } - /// The TTLs are ordered the way the comments claim: a local socket may be - /// re-asked far sooner than a routed round trip, and a failure — which is - /// not a fact and is the state the user most wants corrected — is retried - /// sooner than a success is refreshed. #[test] fn ttls_are_ordered_by_what_the_query_costs() { assert!(LOCAL_TTL < UNREACHABLE_TTL); diff --git a/src/terminal/remote.rs b/src/terminal/remote.rs index 738cd53b..a5002af1 100644 --- a/src/terminal/remote.rs +++ b/src/terminal/remote.rs @@ -1,25 +1,4 @@ -//! Client-side `RemoteTerminal`: the GUI half of the persistent-daemon design. -//! -//! It owns **nothing but a socket and a local mirror**. The PTY + child live in -//! the daemon (`daemon::pane`); we hold one Unix-domain-socket connection to it -//! (one connection == one pane) and a local `alacritty_terminal::Term` that we -//! feed from the bytes the daemon replays. The render path is the usual one (an -//! `ansi::Processor` advancing a `Term`); only the *source* of those bytes is a -//! "daemon socket" rather than a "PTY master fd". -//! -//! `RemoteTerminal` exposes the fields the view reads directly (`term`, `events`, -//! `palette`, `exited`) and the methods it calls (`write`, `resize`, -//! `foreground_cwd`, `at_prompt`, `size`), so the view treats it like any local -//! terminal. -//! -//! Threading model: a dedicated reader thread blocking-reads -//! framed [`DaemonMsg`]s and advances the local `Term`, while UI-thread calls -//! (`write`/`resize`) push framed [`ClientMsg`]s out the write half. Because both -//! the reader thread and the UI thread touch the connection, we `try_clone` the -//! stream into independent read/write halves and guard the write half with a -//! `Mutex`. - -#![allow(dead_code)] // Phase 4: not wired into the view yet (integration is later). +#![allow(dead_code)] use std::borrow::Cow; use std::io::Read as _; @@ -51,20 +30,9 @@ use crate::daemon::transport::{self, Stream}; use super::size::TermSize; -/// Bridges reader-thread events back to the GPUI side through an async channel -/// the view drains. #[derive(Clone)] pub struct EventProxy { tx: smol::channel::Sender<AlacEvent>, - /// True while the reader thread replays an attach `Snapshot` (the daemon's - /// byte ring). Queries parsed out of that history — DSR/CPR, OSC 10/11/12 - /// color probes, OSC 52 clipboard reads — were already answered when they - /// ran live; answering them *again* would write the replies to a shell - /// that never asked, which echoes them at the current prompt as if typed - /// (a literal `11;rgb:…` after every restore). Historical OSC 52 writes - /// would likewise clobber the user's clipboard, and historical BELs would - /// flash on attach. Those events are dropped at the source while this is - /// set; everything else (Title, Wakeup…) still flows. replaying: Arc<AtomicBool>, } @@ -82,38 +50,19 @@ impl EventListener for EventProxy { { return; } - // try_send: an overfull channel just means the view is behind; dropping a - // redundant Wakeup is harmless (the next one repaints the latest grid). let _ = self.tx.try_send(event); } } -/// Shell prompt/command state cached from the daemon's `Prompt` messages. The -/// daemon does all the OSC 133 sniffing PTY-side; we just remember the last -/// reported values so `at_prompt()` can answer cheaply without any IPC. #[derive(Default, Clone, Copy)] struct ShellState { active: bool, at_prompt: bool, last_exit: Option<i32>, - /// Monotonic count of `Prompt` reports applied. Lets the view tell a - /// *fresh* prompt (the shell cycled through the submitted command and came - /// back) from the stale pre-submit state — even when 1 Hz polling misses - /// the intermediate not-at-prompt window of a fast command. seq: u64, - /// Monotonic count of *entered-prompt edges*: bumped only when a report - /// flips `at_prompt` false → true. Unlike `seq` it ignores same-prompt - /// redraws — prompt frameworks re-emit the PS1-embedded `133;B` on every - /// `reset-prompt` / completion-list reprint, and each re-emission is - /// another `Prompt` frame. The Tab handoff keys its release off this - /// (see `TerminalView::editor_handoff`): only a command actually running - /// (`133;C` → not-at-prompt) starts a new cycle. cycle: u64, } -/// The shared handles the reader thread writes into as daemon frames arrive; -/// `RemoteTerminal` keeps the other ends for the view to read. Bundled so -/// `spawn_reader`'s signature stays readable as signals accrue. struct ReaderSignals { cwd: Arc<Mutex<Option<PathBuf>>>, shell: Arc<Mutex<ShellState>>, @@ -124,79 +73,28 @@ struct ReaderSignals { child_exited: Arc<AtomicBool>, zle_reading: Arc<AtomicBool>, shell_vi_mode: Arc<AtomicBool>, - /// FIFO of pending native-SSH auth/host-key prompts (and banners, id 0) - /// pushed by the reader as `DaemonMsg::AuthPrompt` frames arrive. The view - /// drains these into the in-pane auth sheet (`ui::ssh_prompt`). Keyed per - /// pane implicitly — one `RemoteTerminal` is one pane — so switching tabs - /// never misroutes a prompt. auth: Arc<Mutex<VecDeque<(u64, AuthPromptKind)>>>, - /// Latest native-SSH spawn phase from `DaemonMsg::SshStatus`, for the status - /// line. `None` until the first status frame (a plain shell pane never sets it). phase: Arc<Mutex<Option<SshPhase>>>, - /// Command marks (OSC 133 prompt positions) for the details panel's Outline. marks: crate::terminal::marks::Marks, } -/// The remote workspace a pane belongs to, and how the local daemon reaches its -/// machine. -/// -/// A pane of a remote workspace runs on the *remote* `tty7-server`, so nothing -/// about it is addressable here by `pane_id`. This is what a pane carries -/// instead, and it is the input to every workspace-scoped request: the id says -/// what a forward is *owned* by, the spec says which connection it runs *on*. -/// The two are separate because several workspaces on one machine share one -/// connection but must not share forwards. -/// -/// `None` on a `TerminalView` means "not a remote-workspace pane" — a local pane -/// or an SSH pane — and every path here falls back to the pane-addressed one. #[derive(Clone, Debug, PartialEq)] pub struct PaneWorkspace { - /// Identity of the workspace on its machine. pub workspace: crate::core::session::WorkspaceId, - /// How the machine is reached. Read for the WSL special case, which shares - /// `localhost` with the Windows host and so needs no forward at all. pub target: crate::core::session::RemoteTarget, - /// Names the connection for the daemon's lookup. **Secret-free** - /// ([`NativeSshSpec::without_secrets`]) — the daemon only matches it against - /// an already-authenticated connection, so no credential needs to ride here. - /// - /// `None` for WSL, which has no SSH connection and needs none. pub spec: Option<Box<NativeSshSpec>>, } impl PaneWorkspace { - /// Whether this workspace shares `localhost` with the client, so a - /// `localhost:PORT` link resolves without any forward (the WSL - /// exception). pub fn shares_localhost(&self) -> bool { matches!(self.target, crate::core::session::RemoteTarget::Wsl { .. }) } - /// The route header a pane of this workspace opens its connection with. - /// - /// **`channel: Pane`, not the default `Control`.** A remote `tty7-server` - /// listens twice, and the two dialects are not interchangeable: a pane sent - /// to the control socket gets an `InvalidData` on its first `Spawn`, which - /// is how "the window opens but nothing runs in it" looked before this - /// existed. - /// - /// The spec travels secret-free, which is deliberate and is what - /// [`PaneWorkspace::spec`] documents: the daemon matches it against the - /// connection it already authenticated for this machine's control stream. - /// If that connection is gone the daemon re-authenticates, and the router's - /// setup relay is what carries the prompt back here. pub fn route_header(&self) -> anyhow::Result<crate::daemon::router::RouteHeader> { use crate::core::session::RemoteTarget; use crate::daemon::router::RouteHeader; let header = match (&self.target, &self.spec) { (RemoteTarget::Wsl { distro }, _) => RouteHeader::wsl(distro.clone()), - // Like WSL, this target carries its own address and needs no spec. - // - // `--pane` is added *here* rather than by the router: `LocalStdio` - // runs the argv verbatim (there is no shell command line for - // `RouteChannel::bridge_command` to rewrite), so the caller is the - // only one that can pick the dialect. Same choice the SSH path makes - // one layer down, made explicit. (RemoteTarget::LocalStdio { program, args }, _) => { let mut argv: Vec<&str> = args.iter().map(String::as_str).collect(); if !argv.contains(&"--pane") { @@ -216,42 +114,15 @@ impl PaneWorkspace { } } -/// Where a pane's daemon connection lands. -/// -/// A pane is the *only* thing in tty7 that can be on a different machine from -/// the window showing it, and this is the whole of how it says so. The transport -/// underneath is identical either way — the same local socket, the same -/// `try_clone`, the same reader thread — because the local daemon forwards a -/// routed connection byte for byte. #[derive(Clone, Debug, Default)] pub enum PaneRoute { - /// This machine's daemon. Every pane before remote workspaces existed, and - /// still every pane of a local window: **not one byte on the wire changes** - /// for these, because [`connect_routed`] writes nothing extra. #[default] Local, - /// A remote workspace's machine. The connection opens with a route header - /// and does not carry a `ClientMsg` until the daemon has acked it. Remote(Box<crate::daemon::router::RouteHeader>), - /// A pane that belongs to a remote workspace whose machine cannot be - /// addressed — no SSH details on file for it. - /// - /// **Not `Local`.** Falling back to the local daemon would send this pane's - /// `Kill { pane_id }` to a daemon where that id names somebody else's pane, - /// so a route that cannot be built has to fail rather than land somewhere. - /// Every connection through this variant returns the reason. Unroutable(String), } impl PaneRoute { - /// The route a pane of `workspace` takes; [`PaneRoute::Local`] when the pane - /// belongs to no remote workspace. - /// - /// Infallible on purpose: the callers that need a route most are the ones - /// with nowhere to put an error (a close, a restore probe), and for those - /// [`PaneRoute::Unroutable`] is the safe answer rather than the local - /// daemon. The reason still surfaces — at connect time, from the one place - /// that has somewhere to report it. pub fn for_workspace(workspace: Option<&PaneWorkspace>) -> PaneRoute { match workspace { None => PaneRoute::Local, @@ -262,12 +133,6 @@ impl PaneRoute { } } - /// The header this route prefixes its connection with, or `None` when it - /// prefixes nothing. - /// - /// The single place that decides whether a connection carries an extra - /// frame, so "a local pane's wire bytes are unchanged" is one assertion - /// rather than a reading of [`connect_routed`]. pub fn header(&self) -> Option<&crate::daemon::router::RouteHeader> { match self { PaneRoute::Remote(header) => Some(header), @@ -275,122 +140,39 @@ impl PaneRoute { } } - /// Whether this pane's failures are the *local* daemon's to answer for. - /// - /// The distinction is not cosmetic. On a routed pane the local daemon is a - /// byte forwarder: a connection that drops mid-`Spawn` says the far end - /// failed, and the local daemon is fine. Recovery paths that restart it — - /// which drains and kills every pane it hosts — would then let one - /// unreachable remote destroy all of the user's local sessions. - /// - /// `Unroutable` counts as not-local for the same reason: nothing was ever - /// asked of the local daemon, so nothing about it is worth restarting. pub fn is_local(&self) -> bool { matches!(self, PaneRoute::Local) } } -/// A terminal whose PTY lives in the daemon. Mirrors `backend::Terminal`'s public -/// surface so the view can treat the two interchangeably. pub struct RemoteTerminal { - /// Local mirror emulator. Same type and feeding discipline as `Terminal`. pub term: Arc<FairMutex<Term<EventProxy>>>, pub events: smol::channel::Receiver<AlacEvent>, pub palette: [alacritty_terminal::vte::ansi::Rgb; 256], - /// Whether the pane's child has exited. The reader thread can't touch `&mut - /// self`, so the *authoritative* flag lives in `exited_flag` (an - /// `Arc<AtomicBool>`); this field is a cheap field-readable copy the view can - /// poll. `poll_exited()` syncs the flag into it. See the struct docs / the - /// handoff note for why both exist. pub exited: bool, size: TermSize, - /// Whether the first layout's `Resize` has been sent. Until then `size` is - /// a pre-layout placeholder and the daemon-side PTY may disagree with it - /// (attach no longer resizes the PTY), so the first `resize()` must go - /// through even when the laid-out size happens to equal the placeholder. synced_size: bool, - /// Write half of the pane connection. Guarded by a `Mutex` because UI-thread - /// `write`/`resize` calls (and potentially others) all push frames out the - /// same socket; the reader thread uses its own cloned read half. writer: Mutex<Stream>, - /// Foreground cwd, last reported by the daemon via `Cwd`. Shared with the - /// reader thread, which updates it as new reports arrive. cwd: Arc<Mutex<Option<PathBuf>>>, - /// Shell prompt/command state, last reported by the daemon via `Prompt`. shell_state: Arc<Mutex<ShellState>>, - /// Trusted foreground remote context, last reported by the daemon. remote_context: Arc<Mutex<Option<RemoteContext>>>, - /// Set true by the reader thread once the child exits or the daemon - /// disconnects. `poll_exited()` copies this into the `exited` field. exited_flag: Arc<AtomicBool>, - /// Set true only on a *genuine* child exit (`DaemonMsg::Exited` — the - /// shell ended: `exit`, Ctrl-D, a crash), never on a daemon disconnect or - /// protocol desync, which also flip `exited_flag`. The distinction gates - /// pane auto-close: a pane whose shell ended closes itself, while a pane - /// that merely lost its connection stays visible (auto-closing it would - /// silently discard — and `close_tab` would try to kill — a session that - /// may still be alive daemon-side). child_exited: Arc<AtomicBool>, - /// Whether zle is reading the keyboard right now, sniffed client-side from - /// *live* OSC 133 marks: `B` (prompt end — zle takes over immediately - /// after) arms it, any other mark disarms it, and Snapshot replays never - /// touch it (a historical `B` says nothing about now). Gates the typeahead - /// wipe: a `^U` written before zle reads is kernel-echoed as literal junk. zle_reading: Arc<AtomicBool>, - /// Whether the shell reports vi editing mode for the current prompt. Sniffed - /// client-side from tty7's shell integration marker (`OSC 133;V;0/1`) so the - /// daemon/client wire protocol stays compatible across versions. shell_vi_mode: Arc<AtomicBool>, - /// Pending native-SSH auth/host-key prompts, filled by the reader thread. The - /// view drains these each event batch (`take_auth_prompt`) into the in-pane - /// sheet. Shared with the reader thread. auth_prompts: Arc<Mutex<VecDeque<(u64, AuthPromptKind)>>>, - /// Latest native-SSH spawn phase (`SshStatus`), for the status line. ssh_phase: Arc<Mutex<Option<SshPhase>>>, - /// The endpoint (`host`, `port`) this pane connected to, retained from the - /// `NativeSshSpec` at spawn so the auth sheet can build the keychain account - /// (`user@host:port`) for a "remember" checkbox — the `Password` prompt only - /// carries user+host, not the port. `None` for non-native panes. ssh_endpoint: Option<(String, u16)>, - /// Whether this connect attempt was launched with a keychain-resolved stored - /// password pre-filled into the spec. Drives FR-A6: a `Password` prompt that - /// arrives *after* an auto-supplied stored password means the server rejected - /// it, so the sheet warns and offers to overwrite/clear the stale entry. auto_supplied_password: bool, - /// The third-party CLI coding agent running in the pane's foreground, last - /// reported by the daemon via `Agent` (detected from the foreground `argv`). - /// `None` when no known agent runs. Drives the tab avatar's brand mark — see - /// [`crate::core::cli_agent`]. agent: Arc<Mutex<Option<CLIAgent>>>, - /// The agent's rich session status (idle/working/waiting/done + native - /// session id), last reported by the daemon via `AgentStatus`. Drives the - /// status dot, "needs your input" notifications, and session resume. agent_session: Arc<Mutex<Option<AgentSessionState>>>, - /// Command marks recorded by the reader thread from OSC 133, for the details - /// panel's Outline. Positions are grid rows, so they can only be taken here - /// on the client — the daemon has no grid. marks: crate::terminal::marks::Marks, - /// Which machine this pane's connection landed on. Kept so the *other* - /// operations a pane needs — `Kill`, a `List` at restore — go to the same - /// daemon the pane lives in. A remote pane's id means nothing here, and - /// sending `Kill { pane_id }` to the local daemon would name whichever local - /// pane happened to be allocated the same number. route: PaneRoute, - /// The event sink the reader thread publishes through, kept so a - /// [`relink`](Self::relink) can start a *new* reader against the *same* - /// channel. The view subscribes to `events` once, at construction, and - /// never again — a relink that handed the daemon a fresh channel would - /// leave the pane on screen and permanently deaf. proxy: EventProxy, reader_thread: Option<JoinHandle<()>>, } impl RemoteTerminal { - /// Connect to the daemon, spawn a fresh pane (shell) sized to `size`, and - /// 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, @@ -401,19 +183,6 @@ impl RemoteTerminal { Self::spawn_on(&PaneRoute::Local, size, cell_w, cell_h, cwd, shell, None) } - /// [`spawn`](Self::spawn) onto a particular machine. - /// - /// The retry ladder below is about the **local** daemon — the one this - /// process starts and owns — so it applies unchanged to a routed pane: a - /// route header cannot be written to a socket nobody is listening on either. - /// What it deliberately does *not* do is restart anything on the far side; a - /// remote daemon that is missing or mismatched is `install`'s business, and - /// it has already run by the time the ack arrives. - /// `owner` is the workspace the pane will belong to. It only ever reaches - /// the wire for a **local** spawn against a daemon that advertises - /// `pane-owner` — the gate lives in [`spawn_once`](Self::spawn_once), so - /// the retry legs (which may talk to a *different*, freshly started - /// daemon) re-decide it per attempt. pub fn spawn_on( route: &PaneRoute, size: TermSize, @@ -429,10 +198,6 @@ impl RemoteTerminal { match Self::spawn_once(route, size, cell_w, cell_h, cwd, shell, owner) { Ok(term) => Ok(term), Err(first_err) if daemon_not_listening(&first_err) => { - // Nothing is on the socket: the daemon died (crash, OOM, a stray - // `kill`) since the last pane was opened. Every later spawn would - // fail the same way, so bring one back up and retry rather than - // leaving the window unable to open another terminal. if let Err(start_err) = crate::daemon::spawn::ensure_running() { return Err(anyhow::anyhow!( "daemon not running ({first_err}); starting one failed: {start_err}" @@ -445,20 +210,9 @@ impl RemoteTerminal { ) }) } - // **Local panes only.** On a routed pane the connection this reads - // as "disconnected" belongs to the *remote* — the local daemon is - // only forwarding bytes across it, and it is fine. Restarting it - // would not fix anything on the far side, and `restart` drains and - // kills every pane it hosts: one unreachable remote would take out - // all of the user's local sessions. Report the far end's failure - // instead. Err(first_err) if route.is_local() && daemon_disconnected_before_spawn_reply(&first_err) => { - // A live-but-old daemon can accept the connection, panic while - // handling Spawn, and close before replying. Restart once so an - // upgraded GUI cuts over cleanly instead of crashing on a stale - // background service. if let Err(restart_err) = crate::daemon::spawn::restart() { return Err(anyhow::anyhow!( "daemon disconnected before Spawn reply ({first_err}); restart failed: {restart_err}" @@ -486,10 +240,6 @@ impl RemoteTerminal { let mut stream = connect_routed(route)?; let win = win_size(size, cell_w, cell_h); - // An owner only goes on the wire when this daemon is known to read the - // `SPAWN_OWNED` frame — an older one drops the connection over the - // unknown kind. Local only for now: a routed spawn's capability set is - // the *remote* server's, which nothing here has interrogated. let owner = owner.filter(|_| { route.is_local() && crate::daemon::spawn::local_daemon_supports( @@ -497,9 +247,6 @@ 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, @@ -524,18 +271,10 @@ impl RemoteTerminal { Ok((term, pane_id)) } - /// Connect to the daemon and re-attach to an existing pane `pane_id`, then - /// start mirroring it. The daemon answers with a `Snapshot` (its byte ring) - /// that the reader thread replays to rebuild the current screen + scrollback, - /// followed by live `Output`. pub fn attach(size: TermSize, cell_w: u16, cell_h: u16, pane_id: u64) -> anyhow::Result<Self> { Self::attach_on(&PaneRoute::Local, size, cell_w, cell_h, pane_id) } - /// [`attach`](Self::attach) on a particular machine. A remote workspace's - /// pane ids are the *remote* daemon's, so a reattach has to take the same - /// route the spawn did or it would find a stranger's pane — or, far more - /// likely, none. pub fn attach_on( route: &PaneRoute, size: TermSize, @@ -547,32 +286,12 @@ impl RemoteTerminal { let win = win_size(size, cell_w, cell_h); ClientMsg::Attach { pane_id, size: win }.encode(&mut stream)?; - // Far enough into the reply to know whether the pane is still there. - // Everything read here is handed to the reader thread rather than - // consumed: a successful attach's first frame is part of the replay. let buffered = attach_reply_prefix(&mut stream, pane_id, attach_reply_wait(route))?; let mut term = Self::from_stream_with(stream, size, buffered)?; term.route = route.clone(); Ok(term) } - // ── The pane half of a reconnect ──────────────────────────────── - // - // For one pane: **reopen the channel, `Attach`, take the replay, resize to - // this client's geometry.** It happens *in place* — the same `Term`, the - // same event channel, the same shared signals the view already holds - // handles to. Building a fresh `RemoteTerminal` and swapping it into the - // view would look simpler and would silently break the pane: the view's - // event pump subscribes to `events` once, at construction, and would go on - // listening to the dead terminal's channel for ever. - - /// The **blocking** half of a relink: reach the machine and re-`Attach`. - /// - /// Split from [`adopt_relink`](Self::adopt_relink) because this is a - /// network round trip — an SSH connect on a cold machine, possibly with a - /// password sheet in the middle — and the terminal it is for is a gpui - /// entity that can only be touched on the UI thread. So the wait happens on - /// a background task and only the cheap swap runs where the view lives. pub fn open_relink( route: &PaneRoute, pane_id: u64, @@ -589,19 +308,6 @@ impl RemoteTerminal { Ok(stream) } - /// The **cheap** half: adopt an already-attached stream from - /// [`open_relink`](Self::open_relink) as this pane's link. - /// - /// # Why the grid is reset first - /// - /// The daemon answers `Attach` by replaying its `ReplayRing` from the - /// start. Advancing that onto a grid that still holds the pre-disconnect - /// screen would append a second copy of everything. So the mirror is reset - /// and the machine's own record becomes the whole truth — which is also the - /// honest presentation of the replay boundary: the ring holds - /// 8 MiB, a pane that outran it comes back with the daemon's current grid - /// and **the middle is genuinely gone**. Nothing here interpolates it, and - /// nothing upstream may imply it will fill in later. pub fn adopt_relink( &mut self, stream: Stream, @@ -610,30 +316,16 @@ impl RemoteTerminal { cell_w: u16, cell_h: u16, ) -> anyhow::Result<()> { - // Retire the old link first. No `Detach`: this path exists because the - // socket is already gone, and on the one case where it is not (a - // deliberate re-attach) the server treats a closed stream as a detach - // anyway. if let Ok(writer) = self.writer.lock() { let _ = writer.shutdown(std::net::Shutdown::Both); } if let Some(handle) = self.reader_thread.take() { let _ = handle.join(); } - // The retired reader has been joined, so everything it will ever emit is - // already in the channel — including its `Exit`. Left there it would be - // delivered *after* the swap and put "process exited" on a pane that is - // demonstrably alive. Dropping the rest of that backlog is right for the - // same reason the grid is reset below: it describes a screen the replay - // is about to redraw from the machine's own record. while self.events.try_recv().is_ok() {} let read_half = stream.try_clone()?; - // The dead link set these on its way out (`teardown`). A pane that is - // being re-attached is by definition not finished, so they go back — - // except `child_exited`, which records that the *shell* ended and is - // still true no matter how many times the client reconnects. self.exited_flag.store(false, Ordering::SeqCst); self.exited = false; { @@ -646,11 +338,6 @@ impl RemoteTerminal { self.term.clone(), self.proxy.clone(), read_half, - // Nothing pre-read: unlike `attach_on`, a relink does not classify - // the reply. A pane that is gone leaves this one disconnected on - // purpose — the supervisor's retry is the answer here, and spawning - // a fresh shell into a pane the user is still looking at would - // discard the screen it is showing. Vec::new(), ReaderSignals { cwd: self.cwd.clone(), @@ -672,33 +359,20 @@ impl RemoteTerminal { } self.reader_thread = Some(reader); self.route = route.clone(); - // The last step: "以新客户端的尺寸 Resize". `Attach` carries a - // size but deliberately does not resize the PTY, so the geometry only - // becomes real when this frame lands — and `synced_size = false` is what - // lets it through when the size happens to equal the last one. self.synced_size = false; self.resize(size, cell_w, cell_h); Ok(()) } - /// Shared tail of `spawn`/`attach`: build the local `Term`, split the socket - /// into read/write halves, and launch the reader thread. pub(super) fn from_stream(stream: Stream, size: TermSize) -> anyhow::Result<Self> { Self::from_stream_with(stream, size, Vec::new()) } - /// [`from_stream`](Self::from_stream) for a caller that has already read - /// part of the stream. `buffered` is where the reader thread starts, ahead - /// of anything still on the socket — `attach_reply_prefix` reads far enough - /// to classify the reply, and those bytes are the front of the replay. pub(super) fn from_stream_with( stream: Stream, size: TermSize, buffered: Vec<u8>, ) -> anyhow::Result<Self> { - // Two independent handles to the same connection: the reader thread owns - // the read half, the UI thread writes through the (mutex-guarded) write - // half. Reads and writes are independent directions, so this is safe. let read_half = stream.try_clone()?; let write_half = stream; @@ -708,9 +382,6 @@ impl RemoteTerminal { replaying: Arc::new(AtomicBool::new(false)), }; - // Scrollback depth comes from user config (clamped in `Config::sanitize` - // to alacritty's ceiling). Read fresh from disk here: a pane spawn/attach - // is rare, and this runs on the daemon side too, which has no GPUI global. let user_config = crate::core::config::Config::load(); let config = terminal_config_from_user(&user_config); let term = Term::new(config, &size, proxy.clone()); @@ -773,28 +444,17 @@ impl RemoteTerminal { agent, agent_session, marks, - // Overwritten by the routed constructors; `from_stream` itself is - // handed a stream whose destination it cannot see. route: PaneRoute::Local, proxy, reader_thread: Some(reader_thread), }) } - /// Close this pane's link, leaving the pane running on its machine. - /// - /// The same two frames `Drop` sends, without dropping: the - /// takeover needs the client to *stop being attached* while the view stays - /// on screen in its read-only state. pub fn detach_link(&mut self) { if let Ok(mut writer) = self.writer.lock() { let _ = ClientMsg::Detach.encode(&mut *writer); let _ = writer.shutdown(std::net::Shutdown::Both); } - // The reader observes the close and runs its own teardown, so the pane - // lands in exactly the state a dropped network link leaves it in — which - // is the state wanted after a takeover, reached by the code - // path that is already exercised every time a connection fails. if let Some(handle) = self.reader_thread.take() { let _ = handle.join(); } @@ -806,24 +466,10 @@ impl RemoteTerminal { term.set_options(terminal_config_from_user(user_config)); } - /// The reader thread: decodes framed `DaemonMsg`s off the socket and applies - /// each. `Snapshot`/`Output` feed the same `ansi::Processor` → `Term` path as - /// the in-process backend (so a multi-MB Snapshot is one `advance` call), - /// `Cwd` / `Prompt` refresh the cached state, and `Exited`/EOF end the thread. - /// Every grid-changing message is followed by a `Wakeup` so the view repaints. - /// - /// Frames are decoded resumably (`protocol::take_frame`) from reads that - /// carry a timeout whenever a DEC 2026 synchronized update is pending: an - /// app that opens a sync frame (BSU) and never closes it (ESU) would - /// otherwise freeze this pane's rendering forever, since the buffered bytes - /// only flush inside `advance`. When the deadline lapses with no ESU, - /// `stop_sync` force-flushes — the same policy as alacritty's event loop. fn spawn_reader( term: Arc<FairMutex<Term<EventProxy>>>, proxy: EventProxy, read_half: Stream, - // Bytes already off the socket (see `from_stream_with`), which the loop - // resumes from before its first read. buffered: Vec<u8>, signals: ReaderSignals, ) -> JoinHandle<()> { @@ -844,54 +490,17 @@ impl RemoteTerminal { phase, marks, } = signals; - // The client end of the visible-output path: keep it off the - // efficiency cores (see `core::threads`). crate::core::threads::promote_to_user_interactive(); let mut stream = read_half; - // The VT parser is the same type the upstream event loop uses; - // `Term` is its `Handler`. let mut processor: ansi::Processor = ansi::Processor::new(); - // Sniffs OSC 9 / OSC 777 desktop-notification sequences out of the - // live output stream. The Zed alacritty fork's `Term` doesn't surface - // these as events (its `Event` enum has no notification variant), and - // we already see every output byte here, so a tiny side-channel - // scanner is the cleanest interception point — no daemon-protocol or - // view-channel plumbing needed. Its state persists across frames so a - // sequence split over two `Output` reads is still recognized. let mut osc = OscNotifyScanner::default(); - // Sniffs tty7's OSC 133;V edit-mode metadata from both replayed - // snapshots and live output. Unlike zle_reading, this is durable - // prompt state: an attached client should inherit the last mode - // marker already present in the replay ring. let mut mode_tok = OscTokenizer::new(&[b"133"]); - // Sniffs OSC 133 marks out of the live stream to track whether - // zle is reading (see the `zle_reading` field docs). Historical - // Snapshot replays deliberately do not feed this tokenizer. let mut zle_tok = OscTokenizer::new(&[b"133"]); - // Positional OSC 133 marks for the details panel's Outline. Unlike - // the tokenizers above this one reports byte *offsets*, because a - // mark's value is the grid row it lands on — see `terminal::marks`. let mut mark_scan = MarkScanner::new(); - // Bytes read but not yet framed, plus the recorded geometry - // waiting for its paired Snapshot: the attach replay is a - // `Size` → `Snapshot` pair per ring segment, and each pair - // must apply under ONE grid lock — with two separate lock - // scopes, the UI thread's layout `resize()` could slot in - // between and that segment would replay at the layout width, - // mis-wrapping history (the exact defect the Size frame - // exists to prevent). The guarantee is per pair: a layout - // resize landing *between* pairs only re-reflows already- - // applied history, and the next pair's Size (ultimately the - // final pair, which carries the PTY's current geometry) - // restores the recorded width before more bytes advance. let mut pending: Vec<u8> = buffered; let mut pending_size: Option<WinSize> = None; - // Sized to the daemon writer's coalesced-frame cap so one large - // Output frame lands in a few reads instead of dozens. let mut scratch = vec![0u8; 256 * 1024]; - // TTY7_TRACE=1: per-second reader-loop accounting on stderr, to - // localize throughput stalls (socket wait vs lock wait vs parse). let trace = std::env::var("TTY7_TRACE").is_ok_and(|v| !v.is_empty() && v != "0"); let mut tr_last = std::time::Instant::now(); let mut tr_bytes: u64 = 0; @@ -901,8 +510,6 @@ impl RemoteTerminal { let mut tr_adv_t = std::time::Duration::ZERO; let mut tr_frames: u32 = 0; - // Shared teardown: child exit, daemon disconnect, or a protocol - // desync all end the pane the same way. let teardown = || { term.lock().exit(); exited_flag.store(true, Ordering::SeqCst); @@ -910,31 +517,12 @@ impl RemoteTerminal { proxy.send_event(AlacEvent::Exit); }; - // Consecutive `Output` frames coalesce here and apply as ONE - // parser pass: one term-lock, one advance, one Wakeup per - // burst instead of per frame. The daemon's writer merges - // queued frames too, but a fast socket drains its channel - // before runs build up, so at full throughput frames arrive - // 1-2 PTY reads small and per-frame costs dominate this - // thread. Latency-free: the batch flushes as soon as no - // complete frame is left in `pending` — it never waits for - // bytes that haven't arrived. let mut out_batch: Vec<u8> = Vec::new(); 'main: loop { - // Apply a batched run of Output bytes (if any): parser under - // the terminal lock, scanners outside it, one view wakeup. - // A macro so call sites stay one line without threading a - // dozen &muts through a helper fn. macro_rules! flush_batch { () => { if !out_batch.is_empty() { - // Where the batch's OSC 133 marks land, so the - // advance can stop at each one and read the grid - // row it fell on. Scanned before the lock (it's a - // pure byte pass) and normally empty — a batch - // with no marks takes the single-advance path - // below, exactly as before. let mut cuts: Vec<(usize, MarkEvent)> = Vec::new(); mark_scan.feed(&out_batch, |off, ev| cuts.push((off, ev))); { @@ -957,8 +545,6 @@ impl RemoteTerminal { tr_adv_t += t1.elapsed(); } } - // Scan outside the terminal lock (the scanners are - // independent of the grid), then post notifications. let mut notes = Vec::new(); osc.feed(&out_batch, &mut notes); for (title, body) in notes { @@ -972,10 +558,6 @@ impl RemoteTerminal { ); } }); - // Live 133 marks: `B` = prompt fully printed, zle - // takes the keyboard right after; anything else - // (C command start, D precmd, A prompt start) - // means it isn't reading. zle_tok.feed(&out_batch, |payload| { if let Some(mark) = payload.strip_prefix(b"133;") { match mark.first() { @@ -999,7 +581,6 @@ impl RemoteTerminal { }; } - // 1) Apply every complete frame already buffered. loop { let frame = match crate::daemon::protocol::take_frame(&mut pending) { Ok(Some(frame)) => frame, @@ -1017,29 +598,15 @@ impl RemoteTerminal { } }; match msg { - // The geometry the attach replay was recorded under, - // held until its Snapshot arrives (see `pending_size`). DaemonMsg::Size(ws) => { flush_batch!(); pending_size = Some(ws); } DaemonMsg::Snapshot(bytes) => { flush_batch!(); - // A Snapshot is a historical replay (rebuilding the - // screen on attach). `Term` emits its events - // synchronously from inside `advance`, so bracketing - // it with the `replaying` flag suppresses exactly the - // replay's query replies / clipboard / bell effects - // (see `EventProxy::replaying`); it fires no desktop - // notifications either (only live Output is scanned). proxy.replaying.store(true, Ordering::Relaxed); { let mut term = term.lock(); - // Size the grid to the recorded geometry *before* - // replaying, or history wraps at the wrong column - // and relative cursor motion lands on the wrong - // rows. The view's first layout then resizes both - // sides to the real pane size. if let Some(ws) = pending_size.take() { term.resize(TermSize::new( ws.cols as usize, @@ -1047,12 +614,6 @@ impl RemoteTerminal { )); } processor.advance(&mut *term, &bytes); - // The ring can end inside a sync frame (a BSU - // whose ESU fell past the recording): flush it - // now, still under the replaying flag — trapped - // replay bytes flushing later would count as - // *live* and re-answer historical queries, the - // exact leak replay suppression exists to stop. if processor.sync_timeout().sync_timeout().is_some() { processor.stop_sync(&mut *term); } @@ -1069,9 +630,6 @@ impl RemoteTerminal { proxy.send_event(AlacEvent::Wakeup); } DaemonMsg::Output(bytes) => { - // Defer: the batch applies when this run of - // Output frames ends (a control frame, or no - // complete frame left buffered). out_batch.extend_from_slice(&bytes); tr_frames += 1; } @@ -1097,24 +655,6 @@ impl RemoteTerminal { + u64::from(at_prompt && !guard.at_prompt), }; } - // The shell just reported a fresh prompt, so at - // this position in the byte stream no full-screen - // program owns the pane. Any TUI state still in - // the grid — a stranded alt screen, a DECTCEM- - // hidden cursor, mouse/focus reporting, kitty - // keyboard flags — is residue from a program that - // died without restoring it (an ssh session - // dropping mid-TUI is the canonical case: the - // restore sequences can never arrive). Feed the - // resets through the same parser path as PTY - // output, right here between frames: every byte - // the dead program did send has already applied - // (`flush_batch!` above), and the prompt text / - // next command's bytes only come in later frames, - // so this can never fight a live program's own - // mode changes. Runs on the attach path too — - // the daemon sends `Prompt` after `Snapshot` — - // so a stale replay ring self-heals on reattach. if active && at_prompt { let mut term = term.lock(); let resets = stale_mode_resets(*term.mode()); @@ -1127,14 +667,6 @@ impl RemoteTerminal { } DaemonMsg::RemoteContext(ctx) => { flush_batch!(); - // Crossing the local/remote boundary invalidates - // the cwd: it names a directory in the namespace - // we just left. Drop it so the pane reports none - // until the new shell's OSC 7 lands — otherwise - // an `exit` from `ssh` leaves the remote's last - // path in place, and a local shell without shell - // integration never overwrites it, so the local - // `git` probe keeps running against it. if let Ok(mut guard) = cwd.lock() { *guard = None; } @@ -1142,11 +674,6 @@ impl RemoteTerminal { *guard = ctx; } } - // A native-SSH pane's interactive auth/host-key - // request. Queue it and wake the view; the sheet is - // rendered and its reply sent via `respond_auth`. - // Banners (id 0) ride the same queue and the UI shows - // them without a reply. DaemonMsg::AuthPrompt { request_id, prompt } => { flush_batch!(); if let Ok(mut guard) = auth.lock() { @@ -1154,7 +681,6 @@ impl RemoteTerminal { } proxy.send_event(AlacEvent::Wakeup); } - // Native-SSH spawn progress for the status line. DaemonMsg::SshStatus { phase: p } => { flush_batch!(); if let Ok(mut guard) = phase.lock() { @@ -1173,43 +699,24 @@ impl RemoteTerminal { if let Ok(mut guard) = agent_session.lock() { *guard = state; } - // Status changes repaint the tab chip / sidebar - // dot even when the pane printed nothing. proxy.send_event(AlacEvent::Wakeup); } DaemonMsg::Exited { .. } => { - // Child gone: apply what it printed last, then - // mark the emulator exited and flip the shared - // flag so the next `poll_exited()` surfaces it. - // This is the one exit path where the child - // *really* ended (vs the connection dying), so - // record that before the teardown's events fire - // — the view reads it to decide whether the - // pane should close itself. flush_batch!(); child_exited.store(true, Ordering::SeqCst); teardown(); break 'main; } - // Spawned/PaneList/Error aren't expected on a pane stream - // after the handshake; ignore them defensively rather than - // tearing down a live pane over a stray control frame. _ => {} } } - // No complete frame left buffered: apply the batched run - // before blocking on the socket for more. flush_batch!(); - // 2) Refill. While a synchronized update is pending, bound the - // read by its deadline; an expired deadline force-flushes. let timeout = match processor.sync_timeout().sync_timeout() { Some(deadline) => { let left = deadline.saturating_duration_since(std::time::Instant::now()); if left.is_zero() { - // No ESU within the window: flush the buffered frame - // (as live output — it is) and re-enter the loop. let mut term = term.lock(); processor.stop_sync(&mut *term); drop(term); @@ -1220,8 +727,6 @@ impl RemoteTerminal { } None => None, }; - // Best effort: if the timeout can't be set the read just - // blocks, degrading to the old flush-on-next-output behavior. let _ = stream.set_read_timeout(timeout); if trace && tr_last.elapsed() >= std::time::Duration::from_secs(1) { eprintln!( @@ -1244,9 +749,6 @@ impl RemoteTerminal { } let tr0 = trace.then(std::time::Instant::now); match stream.read(&mut scratch) { - // EOF or any I/O error == the daemon went away. Same - // teardown as a child exit so the view stops drawing a - // dead pane. Ok(0) => { teardown(); break; @@ -1259,9 +761,6 @@ impl RemoteTerminal { } pending.extend_from_slice(&scratch[..n]); } - // The sync deadline passed with no ESU (or a spurious - // early wake): loop back — the deadline re-check above - // flushes if it truly expired. Err(e) if matches!( e.kind(), @@ -1278,50 +777,27 @@ impl RemoteTerminal { .expect("spawn remote reader thread") } - /// Sync the reader thread's shared `exited_flag` into the field the view reads - /// directly (`self.terminal.exited`). The view currently reads `exited` as a - /// field, and the reader thread can't touch `&mut self`, so the integration - /// layer calls this on each event drain to keep the field current. pub fn poll_exited(&mut self) { if self.exited_flag.load(Ordering::SeqCst) { self.exited = true; } } - /// Whether the pane's child process genuinely exited (as opposed to the - /// daemon connection dropping — see the `child_exited` field docs). pub fn child_exited(&self) -> bool { self.child_exited.load(Ordering::SeqCst) } - /// Send raw bytes (keyboard input, pasted text, query replies) to the pane as - /// a `ClientMsg::Input` frame. Mirrors `Terminal::write`'s signature exactly. pub fn write<B: Into<Cow<'static, [u8]>>>(&self, bytes: B) { let bytes = bytes.into(); if bytes.is_empty() { return; } if let Ok(mut writer) = self.writer.lock() { - // A failed write means the daemon is gone; the reader thread will - // observe the same disconnect and mark us exited, so swallow it here. let _ = ClientMsg::Input(bytes.into_owned()).encode(&mut *writer); } } - /// Resize the local grid and tell the daemon to resize the real PTY. Mirrors - /// `Terminal::resize`: no-op when unchanged, updates `self.size`. pub fn resize(&mut self, size: TermSize, cell_w: u16, cell_h: u16) { - // Dedup repeats, but always let the *first* layout through even if it - // matches the placeholder: attach leaves the PTY size untouched, so - // until this frame lands the daemon may disagree with `self.size`. - // - // The dedup also checks the *local grid's* actual dimensions, not just - // the last requested size: the reader thread applies the daemon's - // recorded `Size` (the attach-replay geometry) on its own schedule, and - // when that lands *after* the first layout's resize, deduping on the - // remembered request alone would leave the local grid stuck at the - // replay geometry forever while the PTY runs at the layout size. - // Re-checking the grid lets the next layout pass self-heal. if self.synced_size && size == self.size { use alacritty_terminal::grid::Dimensions as _; let term = self.term.lock(); @@ -1331,8 +807,6 @@ impl RemoteTerminal { } self.synced_size = true; self.size = size; - // Resize the local mirror first so the view reflows immediately; the - // daemon resizes its PTY (and SIGWINCHes the child) when it gets the frame. self.term.lock().resize(size); let win = win_size(size, cell_w, cell_h); @@ -1341,8 +815,6 @@ impl RemoteTerminal { } } - /// Foreground cwd, as last reported by the daemon (OSC 7 / proc lookup happens - /// daemon-side). Cheap cache read — no IPC, no proc query on the client. pub fn foreground_cwd(&self) -> Option<PathBuf> { self.cwd.lock().ok().and_then(|g| g.clone()) } @@ -1351,10 +823,6 @@ impl RemoteTerminal { self.remote_context.lock().ok().and_then(|g| g.clone()) } - /// Whether the shell sits idle at its prompt, from the daemon's last `Prompt` - /// report. Only meaningful once `active` (the daemon has seen OSC 133); - /// before that we conservatively answer `false`, matching `Terminal`'s - /// non-macOS fallback shape. pub fn at_prompt(&self) -> bool { self.shell_state .lock() @@ -1362,54 +830,30 @@ impl RemoteTerminal { .unwrap_or(false) } - /// Monotonic count of `Prompt` reports applied so far — see - /// [`ShellState::seq`]. Comparing values from before and after a submit - /// tells whether the shell has reported back since. pub fn prompt_seq(&self) -> u64 { self.shell_state.lock().map(|s| s.seq).unwrap_or(0) } - /// Monotonic count of entered-prompt edges — see [`ShellState::cycle`]. - /// Stable across same-prompt redraws (which bump `seq` but not this); - /// only leaving the prompt for a command and coming back advances it. pub fn prompt_cycle(&self) -> u64 { self.shell_state.lock().map(|s| s.cycle).unwrap_or(0) } - /// Exit code of the most recently completed foreground command, as sniffed - /// from OSC 133;D daemon-side. `None` before any command has finished. pub fn last_exit_code(&self) -> Option<i32> { self.shell_state.lock().ok().and_then(|s| s.last_exit) } - /// Whether shell integration has engaged at all (the daemon has seen any - /// OSC 133 from this pane). False for the whole rc-sourcing window after - /// spawn, and forever for shells without integration. Gates the gap-input - /// hold: without integration no prompt report will ever come to adopt - /// held keys, so holding would only add latency. pub fn shell_active(&self) -> bool { self.shell_state.lock().map(|s| s.active).unwrap_or(false) } - /// Whether zle is reading the keyboard right now (live `133;B` seen, no - /// later mark). See the field docs; this is the gate for writing the - /// typeahead wipe without it echoing into the scrollback. - /// The third-party CLI coding agent (Claude Code, Codex, …) running in the - /// pane's foreground, as last reported by the daemon, or `None`. Cheap cache - /// read — detection runs daemon-side. See [`crate::core::cli_agent`]. pub fn foreground_agent(&self) -> Option<CLIAgent> { self.agent.lock().ok().and_then(|g| *g) } - /// Command marks recorded from OSC 133, oldest first — the Outline's source. - /// Cheap clone of a shared handle; the caller snapshots via `Marks::list`. pub fn marks(&self) -> crate::terminal::marks::Marks { self.marks.clone() } - /// The rich agent-session status (idle/working/waiting/done + native - /// session id), as last reported by the daemon, or `None` when no agent - /// session is live. Cheap cache read — sniffing runs daemon-side. pub fn agent_session(&self) -> Option<AgentSessionState> { self.agent_session.lock().ok().and_then(|g| g.clone()) } @@ -1426,35 +870,14 @@ impl RemoteTerminal { self.size } - /// Query the daemon for its live panes over a short-lived control connection. - /// Used at session restore to decide, per saved leaf, whether to `attach` to a - /// still-running pane or `spawn` a fresh one. Returns an empty list on any - /// error (no daemon, refused, malformed reply) so restore degrades to - /// all-fresh. pub fn list_panes() -> Vec<crate::daemon::protocol::PaneInfo> { Self::list_panes_on(&PaneRoute::Local) } - /// [`list_panes`](Self::list_panes) on a particular machine. A remote - /// workspace restores from the *remote* daemon's registry; asking the local - /// one would report every saved leaf as dead and respawn the lot, silently - /// abandoning whatever was still running there — the precise failure remote - /// workspaces exist to prevent. pub fn list_panes_on(route: &PaneRoute) -> Vec<crate::daemon::protocol::PaneInfo> { Self::try_list_panes_on(route).unwrap_or_default() } - /// [`list_panes_on`](Self::list_panes_on) with the failure kept. - /// - /// Swallowing the error into an empty list is right for *restore*, where - /// "no answer" and "nothing alive" lead to the same action (spawn fresh). - /// It is wrong for anything that **shows** liveness: on this machine an - /// unreachable daemon really does mean no pane is running, but a routed - /// `List` that failed says nothing about the remote's registry — the panes - /// are very probably still there, we just could not ask. A picker that - /// renders that as "stopped" tells the user their sessions are gone every - /// time the link hiccups, so the two cases have to stay distinguishable - /// this far up (see [`crate::terminal::pane_liveness`]). pub fn try_list_panes_on( route: &PaneRoute, ) -> anyhow::Result<Vec<crate::daemon::protocol::PaneInfo>> { @@ -1466,25 +889,13 @@ impl RemoteTerminal { } } - /// Tell the daemon to terminate a pane's child and forget it, over a - /// short-lived control connection. Used when the user explicitly closes a tab - /// or split pane (as opposed to quitting the app, where panes are *detached* - /// and kept alive for restore). Best-effort: a missing daemon means there's - /// nothing to kill anyway. pub fn kill_pane(pane_id: u64) { Self::kill_pane_on(&PaneRoute::Local, pane_id) } - /// [`kill_pane`](Self::kill_pane) on a particular machine. - /// - /// Routing this one is not an optimisation. Pane ids are per-daemon, so - /// `Kill { pane_id }` sent to the wrong daemon does not fail — it succeeds - /// against a stranger. pub fn kill_pane_on(route: &PaneRoute, pane_id: u64) { if let Ok(mut stream) = connect_routed(route) { let _ = ClientMsg::Kill { pane_id }.encode(&mut stream); - // Give the daemon a moment to read the frame before the connection - // closes; a tiny blocking read of EOF is enough to order it. let _ = stream.shutdown(std::net::Shutdown::Write); } } @@ -1538,16 +949,6 @@ impl RemoteTerminal { query(id).unwrap_or_default() } - // ── Native SSH (WS3): auth/host-key prompt plumbing ────────────────────── - - /// Spawn a native russh-backed pane for `spec`, mirroring [`spawn`] but over - /// the `SpawnNativeSsh` path. The connection's auth/host-key prompts and - /// status arrive on this pane's own stream and are surfaced via - /// [`take_auth_prompt`]/[`ssh_phase`]. Returns the terminal + daemon pane id. - /// - /// The single place a secret-bearing spec crosses to the daemon; the caller - /// (the GUI spec-builder, `ui::ssh_connect`) has already resolved keychain - /// secrets into `spec`. pub fn spawn_native_ssh( size: TermSize, cell_w: u16, @@ -1555,11 +956,6 @@ impl RemoteTerminal { cwd: Option<PathBuf>, spec: Box<NativeSshSpec>, ) -> anyhow::Result<(Self, u64)> { - // Mirror `spawn`'s stale-daemon protection: a running daemon from a - // pre-SSH build drops the connection on the unknown message kind without - // replying (and never sends an Error frame pre-dispatch), which reads as - // EOF here. Restart it once and retry so the first SSH connect after an - // upgrade recovers instead of failing. match Self::spawn_native_ssh_once(size, cell_w, cell_h, cwd.clone(), spec.clone()) { Err(first_err) if daemon_disconnected_before_spawn_reply(&first_err) => { if let Err(restart_err) = crate::daemon::spawn::restart() { @@ -1586,9 +982,6 @@ impl RemoteTerminal { ) -> anyhow::Result<(Self, u64)> { let mut stream = connect()?; let win = win_size(size, cell_w, cell_h); - // Retain what the auth sheet needs before the spec moves onto the wire: - // the endpoint (for the keychain account) and whether we pre-filled a - // stored password (FR-A6). let endpoint = (spec.host.clone(), spec.port); let auto_supplied_password = spec.password.is_some(); @@ -1616,9 +1009,6 @@ impl RemoteTerminal { Ok((term, pane_id)) } - /// Pop the next pending native-SSH auth/host-key prompt (or banner, id 0), in - /// FIFO order. `None` when the queue is empty. The view calls this while - /// draining its event batch. pub fn take_auth_prompt(&self) -> Option<(u64, AuthPromptKind)> { self.auth_prompts .lock() @@ -1626,10 +1016,6 @@ impl RemoteTerminal { .and_then(|mut q| q.pop_front()) } - /// Pop the next pending prompt only when it is a banner; a real - /// (interactive) prompt stays queued. Used while another pane's sheet is - /// active — popping a real prompt then would drop it (there is no re-queue), - /// silently failing that pane's auth after the broker timeout. pub fn take_auth_banner(&self) -> Option<String> { let mut q = self.auth_prompts.lock().ok()?; if matches!(q.front(), Some((_, AuthPromptKind::Banner { .. }))) { @@ -1640,8 +1026,6 @@ impl RemoteTerminal { None } - /// Whether any native-SSH prompt is waiting (cheap check the view uses to - /// decide whether to emit an `AuthPromptReady` up to the app). pub fn has_pending_auth(&self) -> bool { self.auth_prompts .lock() @@ -1649,27 +1033,18 @@ impl RemoteTerminal { .unwrap_or(false) } - /// The latest native-SSH spawn phase, if any (`None` for a plain pane). pub fn ssh_phase(&self) -> Option<SshPhase> { self.ssh_phase.lock().ok().and_then(|g| g.clone()) } - /// The `(host, port)` this native-SSH pane connected to, for building the - /// keychain account in the auth sheet. `None` for a non-native pane. pub fn ssh_endpoint(&self) -> Option<(String, u16)> { self.ssh_endpoint.clone() } - /// Whether this connect pre-supplied a keychain-stored password (FR-A6): a - /// later `Password` prompt then means the server rejected the stored value. pub fn auto_supplied_password(&self) -> bool { self.auto_supplied_password } - /// Reply to a `DaemonMsg::AuthPrompt` with the given `request_id`, sending a - /// `ClientMsg::AuthResponse` over this pane's own connection (the same socket - /// the prompt arrived on). Best-effort: a dead socket just fails the auth step - /// daemon-side, which surfaces as the usual disconnect. pub fn respond_auth(&self, request_id: u64, response: AuthResponse) { if let Ok(mut writer) = self.writer.lock() { let _ = ClientMsg::AuthResponse { @@ -1680,9 +1055,6 @@ impl RemoteTerminal { } } - /// List the daemon's `known_hosts` entries over a short-lived control - /// connection (for the "SSH → Known hosts" settings section). Empty on any - /// error. pub fn list_known_hosts() -> Vec<KnownHostEntry> { fn query() -> anyhow::Result<Vec<KnownHostEntry>> { let mut stream = connect()?; @@ -1697,7 +1069,6 @@ impl RemoteTerminal { query().unwrap_or_default() } - /// Delete one `known_hosts` entry, returning the refreshed list. pub fn delete_known_host(id: KnownHostId) -> Vec<KnownHostEntry> { fn query(id: KnownHostId) -> anyhow::Result<Vec<KnownHostEntry>> { let mut stream = connect()?; @@ -1712,13 +1083,6 @@ impl RemoteTerminal { query(id).unwrap_or_default() } - // --- SFTP (Workstream 5) ------------------------------------------------- - // - // Each is a synchronous one-shot control request modeled on the loopback - // helpers above: connect, send one `ClientMsg`, read one `DaemonMsg`. SFTP - // targets a native-SSH pane; the daemon errors if `pane_id` isn't one. - - /// List a remote directory over the pane's SFTP session. pub fn sftp_list(pane_id: u64, path: &str) -> Result<Vec<SftpEntry>, String> { fn query(pane_id: u64, path: String) -> anyhow::Result<Result<Vec<SftpEntry>, String>> { let mut stream = connect()?; @@ -1732,7 +1096,6 @@ impl RemoteTerminal { query(pane_id, path.to_string()).unwrap_or_else(|e| Err(e.to_string())) } - /// Run a one-shot SFTP filesystem operation. pub fn sftp_op(pane_id: u64, op: SftpOp) -> SftpOpResult { fn query(pane_id: u64, op: SftpOp) -> anyhow::Result<SftpOpResult> { let mut stream = connect()?; @@ -1746,7 +1109,6 @@ impl RemoteTerminal { query(pane_id, op).unwrap_or_else(|e| SftpOpResult::Error(e.to_string())) } - /// Start a background transfer job; returns its id. pub fn sftp_transfer_start(spec: SftpTransferSpec) -> Result<u64, String> { fn query(spec: SftpTransferSpec) -> anyhow::Result<Result<u64, String>> { let mut stream = connect()?; @@ -1760,7 +1122,6 @@ impl RemoteTerminal { query(spec).unwrap_or_else(|e| Err(e.to_string())) } - /// Cancel a transfer job; returns the pane's refreshed progress list. pub fn sftp_transfer_cancel(job_id: u64) -> Vec<SftpJobProgress> { fn query(job_id: u64) -> anyhow::Result<Vec<SftpJobProgress>> { let mut stream = connect()?; @@ -1775,7 +1136,6 @@ impl RemoteTerminal { query(job_id).unwrap_or_default() } - /// Poll the transfer jobs for a pane (drives the tray while it is visible). pub fn sftp_transfer_list(pane_id: u64) -> Vec<SftpJobProgress> { fn query(pane_id: u64) -> anyhow::Result<Vec<SftpJobProgress>> { let mut stream = connect()?; @@ -1790,9 +1150,6 @@ impl RemoteTerminal { query(pane_id).unwrap_or_default() } - /// Establish a managed forward (Local/Remote/Dynamic) on a native-SSH pane over - /// a short-lived control connection; returns the pane's forwards after the add. - /// One-shot, modeled on `list_loopback_forwards`. pub fn add_forward(pane_id: u64, rule: SshForwardRule) -> Vec<ManagedForward> { fn query(pane_id: u64, rule: SshForwardRule) -> anyhow::Result<Vec<ManagedForward>> { let mut stream = connect()?; @@ -1806,7 +1163,6 @@ impl RemoteTerminal { query(pane_id, rule).unwrap_or_default() } - /// Tear down one managed forward by id; returns the pane's remaining forwards. pub fn remove_forward(pane_id: u64, forward_id: u64) -> Vec<ManagedForward> { fn query(pane_id: u64, forward_id: u64) -> anyhow::Result<Vec<ManagedForward>> { let mut stream = connect()?; @@ -1825,7 +1181,6 @@ impl RemoteTerminal { query(pane_id, forward_id).unwrap_or_default() } - /// List a native-SSH pane's managed forwards. pub fn list_forwards(pane_id: u64) -> Vec<ManagedForward> { fn query(pane_id: u64) -> anyhow::Result<Vec<ManagedForward>> { let mut stream = connect()?; @@ -1840,38 +1195,10 @@ impl RemoteTerminal { query(pane_id).unwrap_or_default() } - // ── Remote workspaces ─────────────────────────────────────── - - /// Send one workspace-scoped request and return the daemon's reply. - /// - /// How long a workspace-addressed request waits for the daemon. - /// - /// Generous, because behind it is an SSH round trip to the workspace's own - /// machine and possibly a connection being established — but finite, which - /// is the point. const WORKSPACE_OP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); - /// The counterpart of the `pane_id`-addressed helpers above for a pane that - /// lives on a *remote workspace*: there is no pane on the local daemon to - /// name, so the request carries the workspace and a secret-free spec naming - /// its machine, and the daemon resolves the connection the workspace already - /// authenticated (`ssh::workspace::handle`). - /// - /// `DaemonMsg::Error` is surfaced as an `Err` so callers can show it — a - /// disconnected workspace has to be *reported*, not silently treated as an - /// empty list. pub fn on_workspace(req: WorkspaceRequest) -> anyhow::Result<DaemonMsg> { let mut stream = connect()?; - // Bounded, because the daemon's answer is not just its own work: it - // resolves the workspace's SSH connection and, for the forward ops, - // waits for the *server* to acknowledge a `cancel_tcpip_forward`. On a - // box that has gone unreachable — lid closed, VPN dropped, which is - // exactly when someone reaches for Stop Workspace — that acknowledgement - // never comes. Without a deadline this read parks forever, and the - // thread with it. - // - // Best effort: a transport that will not take a timeout degrades to the - // old unbounded read rather than failing the request outright. let _ = stream.set_read_timeout(Some(Self::WORKSPACE_OP_TIMEOUT)); ClientMsg::OnWorkspace(Box::new(req)).encode(&mut stream)?; match DaemonMsg::read(&mut stream)? { @@ -1880,8 +1207,6 @@ impl RemoteTerminal { } } - /// [`on_workspace`](Self::on_workspace) for the calls whose only sane failure - /// mode is "show nothing": a list the panel is about to render. pub fn on_workspace_forwards(req: WorkspaceRequest) -> Vec<ManagedForward> { match Self::on_workspace(req) { Ok(DaemonMsg::ForwardList(list)) => list, @@ -1896,7 +1221,6 @@ impl RemoteTerminal { } } - /// Build a [`WorkspaceRequest`] for `op` against `ws`, as seen from `view_pane`. pub fn workspace_request( ws: &PaneWorkspace, view_pane: u64, @@ -1910,10 +1234,6 @@ impl RemoteTerminal { }) } - /// A pane's process tree and listening ports, for the details panel. One-shot - /// over a short-lived control connection, like the forward queries — this is - /// polled only while the panel is open, so it never rides the pane's hot - /// output connection. pub fn query_procs(pane_id: u64) -> PaneProcs { fn query(pane_id: u64) -> anyhow::Result<PaneProcs> { let mut stream = connect()?; @@ -1927,12 +1247,6 @@ impl RemoteTerminal { } } -/// Apply one OSC 133 mark at the emulator's current position. -/// -/// Called with the terminal lock held and the parser advanced to exactly the -/// mark's byte, so `cursor.point.line` is the row the mark fell on. That row is -/// converted to an index from the top of the scrollback, which is stable as long -/// as history hasn't saturated — see the `terminal::marks` module docs. fn record_mark(term: &Term<EventProxy>, marks: &crate::terminal::marks::Marks, event: MarkEvent) { use alacritty_terminal::grid::Dimensions as _; match event { @@ -1947,10 +1261,6 @@ fn record_mark(term: &Term<EventProxy>, marks: &crate::terminal::marks::Marks, e } } -/// Whether the failure is "nothing is listening on the socket" — the daemon is -/// gone, as opposed to alive but unhappy. On Unix a dead daemon leaves the -/// socket file behind (`ConnectionRefused`) or removed it on the way out -/// (`NotFound`); on Windows the named pipe simply isn't there (`NotFound`). fn daemon_not_listening(err: &anyhow::Error) -> bool { err.chain().any(|cause| { cause.downcast_ref::<std::io::Error>().is_some_and(|io| { @@ -1962,18 +1272,6 @@ fn daemon_not_listening(err: &anyhow::Error) -> bool { }) } -/// How long to wait for the daemon's first frame after an `Attach` before -/// giving up on *classifying* the reply. Not a deadline on the attach — only on -/// being able to tell "this pane is gone" from "this pane has not said anything -/// yet" — so lapsing costs nothing but the old behaviour. The connection is -/// already open by the time the wait starts (the SSH setup happened inside -/// `connect_routed`), so what is being waited on is one round trip. -/// -/// **The two routes are not the same wait.** A remote attach runs on a -/// background thread and answers over an SSH channel, so it can afford to be -/// patient. A local one is on the UI thread — `ui::pending_pane` explains why -/// that path stayed synchronous — where the ceiling is a window freeze, and a -/// local daemon that has not answered in two seconds is not about to. fn attach_reply_wait(route: &PaneRoute) -> std::time::Duration { match route.is_local() { true => std::time::Duration::from_secs(2), @@ -1981,33 +1279,6 @@ fn attach_reply_wait(route: &PaneRoute) -> std::time::Duration { } } -/// Read the head of an `Attach` reply, turning "no such pane" into an `Err`, and -/// hand back whatever was read so the reader thread starts from it. -/// -/// # Why this exists -/// -/// `Attach` has no synchronous reply, so for a long time the client's attach -/// could not fail: it wrote the frame and returned `Ok`, and a pane id that was -/// gone showed up much later as the reader thread hitting EOF — which the view -/// paints as `tty7 — disconnected` and deliberately does *not* close, because -/// on a remote workspace a dropped link and a dead pane look the same from -/// there. So the ordinary case of "that pane isn't there any more" landed the -/// user in the failure state meant for "your machine is unreachable", and -/// `start_pane_spawn`'s fall back to a fresh pane — the whole reason a stale id -/// is survivable — never ran. -/// -/// The daemon does answer, it just answers out of band: `Error` on a miss -/// (`daemon::server`), `Size` + `Snapshot` on a hit. Classifying on the **kind -/// byte** rather than the decoded message is what keeps this cheap — the header -/// is 5 bytes and the snapshot behind it can be megabytes. -/// -/// Two non-answers are deliberately *not* failures, because neither is evidence -/// the pane is gone and both used to work: -/// -/// | | | -/// |---|---| -/// | The read times out | The pane is quiet. Return what we have and let the reader carry on | -/// | Anything but `Error` arrives | It is the replay. Same | fn attach_reply_prefix( stream: &mut Stream, pane_id: u64, @@ -2022,18 +1293,12 @@ fn attach_reply_prefix( while kind.is_none() { match stream.read(&mut scratch) { Ok(0) => { - // The daemon hung up without saying anything. Only a `Kill` - // racing this attach gets here, and the answer is the same one - // the `Error` frame carries: this pane is not attachable. let _ = stream.set_read_timeout(None); return Err(anyhow::anyhow!( "the daemon closed the connection without answering Attach for pane {pane_id}" )); } Ok(n) => buffered.extend_from_slice(&scratch[..n]), - // A timeout leaves the partial frame in `buffered`, where the - // reader thread resumes it — `take_frame` is written for exactly - // this. Err(e) if would_block(&e) => break, Err(e) => { let _ = stream.set_read_timeout(None); @@ -2048,16 +1313,11 @@ fn attach_reply_prefix( if !kind.is_some_and(crate::daemon::protocol::is_error_kind) { return Ok(buffered); } - // An `Error` payload is small and its text is the daemon's own wording for - // what went wrong, so it is worth finishing the frame to quote it. let message = read_error_frame(stream, &mut buffered, wait) .unwrap_or_else(|| format!("no such pane {pane_id}")); Err(anyhow::anyhow!("daemon refused Attach: {message}")) } -/// Finish decoding an `Error` frame whose header has already landed in `buffered`. -/// `None` when the rest never arrives — the caller has a serviceable fallback -/// message and no reason to wait around for a better one. fn read_error_frame( stream: &mut Stream, buffered: &mut Vec<u8>, @@ -2085,9 +1345,6 @@ fn read_error_frame( message } -/// Whether a read failed because its timeout lapsed rather than because the -/// connection broke. The two platforms disagree on which kind a lapsed -/// `SO_RCVTIMEO` produces, so both count. fn would_block(err: &std::io::Error) -> bool { matches!( err.kind(), @@ -2110,13 +1367,8 @@ fn daemon_disconnected_before_spawn_reply(err: &anyhow::Error) -> bool { impl Drop for RemoteTerminal { fn drop(&mut self) { - // Detach (don't kill): the daemon keeps the pane running so a later - // `attach` can reconnect. Best-effort — if the socket's already dead the - // pane is detached anyway. if let Ok(mut writer) = self.writer.lock() { let _ = ClientMsg::Detach.encode(&mut *writer); - // Shutting the connection down unblocks the reader thread's blocking - // read (it sees the peer close), so its `join` below returns promptly. let _ = writer.shutdown(std::net::Shutdown::Both); } if let Some(handle) = self.reader_thread.take() { @@ -2125,20 +1377,8 @@ impl Drop for RemoteTerminal { } } -/// The reset sequence that clears stale full-screen-TUI state from a grid that -/// provably has no full-screen owner (the shell just drew its prompt). Each -/// reset is emitted only when the corresponding mode is actually set, because -/// some are not idempotent when idle: `?1049l` on the primary screen performs -/// a cursor *restore*, so it must never fire as a blanket reset. -/// -/// Deliberately left alone: bracketed paste and application cursor keys — -/// zle/fish own those around the prompt and re-arm them on every read, so -/// resetting here could race the line editor's own enable — and anything the -/// parser doesn't track (nothing to detect staleness against). fn stale_mode_resets(mode: TermMode) -> Vec<u8> { let mut seq = Vec::new(); - // Leave the alternate screen first: the resets below then apply to the - // primary screen's state (kitty keyboard flags are tracked per screen). if mode.contains(TermMode::ALT_SCREEN) { seq.extend_from_slice(b"\x1b[?1049l"); } @@ -2157,30 +1397,12 @@ fn stale_mode_resets(mode: TermMode) -> Vec<u8> { if mode.contains(TermMode::FOCUS_IN_OUT) { seq.extend_from_slice(b"\x1b[?1004l"); } - // While ALT_SCREEN is set, `mode` shows the *alt* screen's kitty flags; - // the `?1049l` above restores the primary screen's stack, which may - // itself be polluted (e.g. a remote kitty-protocol app ran before the - // TUI that died). So zero the flags whenever either screen could be - // dirty — at a shell prompt zero is always correct, since kitty-aware - // line editors re-arm on every read. if mode.intersects(TermMode::KITTY_KEYBOARD_PROTOCOL) || mode.contains(TermMode::ALT_SCREEN) { seq.extend_from_slice(b"\x1b[=0;1u"); } seq } -/// Post a best-effort desktop notification via `notify-rust`. The single -/// notification entry point for the whole app: both the OSC 9 / 777 escape-sequence -/// path (the reader thread) and the "long command finished" heuristic in the view -/// route through here, so there's exactly one place that talks to the OS toast API. -/// -/// `.show()` can block briefly on some platforms (a DBus round-trip on Linux, the -/// `NSUserNotification` bridge on macOS), so it runs on a detached thread — the -/// caller (the reader thread, or the UI) is never stalled, and a failure to show is -/// swallowed rather than allowed to disturb the terminal. -/// -/// Note: `notify-rust`'s macOS backend uses the deprecated `NSUserNotification`, -/// which is acceptable for a completion toast. pub(crate) fn notify_desktop(title: Option<&str>, body: &str) { let summary = title.unwrap_or("tty7").to_string(); let body = body.to_string(); @@ -2194,32 +1416,17 @@ pub(crate) fn notify_desktop(title: Option<&str>, body: &str) { }); } -/// macOS delivers notifications *on behalf of* a registered app bundle. Pin that -/// bundle once, up front — otherwise `notify-rust` falls back to a placeholder -/// identifier (`use_default`) that Launch Services can't resolve, and macOS pops -/// a "Choose Application" file picker instead of showing the toast. -/// -/// We prefer our own bundle id, which is registered once the shipped `.app` has -/// been launched; when we're an unbundled `cargo dev` binary that id isn't -/// registered (so `set_application` errors), and we fall back to Terminal's id, -/// which always exists — the notification just shows under Terminal's name. #[cfg(target_os = "macos")] fn ensure_notification_app() { use std::sync::Once; static ONCE: Once = Once::new(); ONCE.call_once(|| { - // `com.github.tty7` matches the bundle id written by `bundle.sh`. if notify_rust::set_application("com.github.tty7").is_err() { let _ = notify_rust::set_application("com.apple.Terminal"); } }); } -/// Extracts OSC 9 and OSC 777 desktop-notification sequences from a raw -/// terminal-output byte stream. The streaming OSC framing (terminators, split -/// reads, resync, payload cap) lives in `core::osc::OscTokenizer`, shared with -/// the daemon's cwd/prompt sniffer; this wrapper just names the identifiers we -/// care about and parses completed payloads into `(title, body)` notifications. struct OscNotifyScanner { tok: OscTokenizer, } @@ -2233,8 +1440,6 @@ impl Default for OscNotifyScanner { } impl OscNotifyScanner { - /// Feed one chunk of output; push any recognized `(title, body)` notifications - /// into `out` (title `None` for OSC 9, which carries only a body). fn feed(&mut self, bytes: &[u8], out: &mut Vec<(Option<String>, String)>) { self.tok.feed(bytes, |payload| { if let Some(note) = parse_osc_notification(payload) { @@ -2244,35 +1449,19 @@ impl OscNotifyScanner { } } -/// Parse a buffered OSC payload (the bytes after `ESC ]`, e.g. `9;Build done` or -/// `777;notify;Title;Body`) into a `(title, body)` notification, or `None` if it -/// isn't a notification we surface. The parsing itself lives in -/// [`crate::core::osc::parse_notification`] (shared with the daemon's agent -/// sniffer); this wrapper additionally drops tty7's own agent-event sentinel — -/// those payloads are machine-to-machine JSON for the daemon's state machine, -/// and toasting them would show raw JSON to the user. fn parse_osc_notification(payload: &[u8]) -> Option<(Option<String>, String)> { if crate::core::cli_agent::parse_agent_event(payload).is_some() { return None; } let (title, body) = crate::core::osc::parse_notification(payload)?; - // A sentinel-titled payload whose JSON failed to parse is still not a - // user-facing notification; never toast it. if title.as_deref() == Some(crate::core::cli_agent::AGENT_EVENT_SENTINEL) { return None; } Some((title, body)) } -/// Open a fresh connection to the daemon's listening endpoint. The endpoint is -/// resolved through the config dir so it inherits the active `--config-dir` -/// isolation (dev vs. real config dir), exactly like every other config-dir file. fn connect() -> anyhow::Result<Stream> { transport::connect().map_err(|e| { - // `context`, not a formatted `anyhow!`: callers classify the failure by - // downcasting to `io::Error` (see `daemon_not_listening`), and - // interpolating the cause into a string would leave the chain with - // nothing to find. anyhow::Error::new(e).context(format!( "connect to daemon at {}", transport::endpoint_display() @@ -2280,17 +1469,6 @@ fn connect() -> anyhow::Result<Stream> { }) } -/// Open a pane connection and, when the pane is a remote workspace's, hand it to -/// the daemon's router before a single `ClientMsg` goes out. -/// -/// **A local pane takes the identical path it always did.** `PaneRoute::Local` -/// is `connect()` and nothing else — no extra frame, no extra round trip, no -/// behaviour to regress. Every remote-specific step is inside the `Remote` arm. -/// -/// The routed arm blocks for as long as the setup takes, including any question -/// the daemon relays back (a password, install consent). Callers are already on -/// a background thread for the plain `connect()`, and this is the same wait a -/// pane on a cold SSH host has always had. fn connect_routed(route: &PaneRoute) -> anyhow::Result<Stream> { if let PaneRoute::Unroutable(reason) = route { return Err(anyhow::anyhow!("{reason}")); @@ -2299,23 +1477,8 @@ fn connect_routed(route: &PaneRoute) -> anyhow::Result<Stream> { return connect(); }; - // Past this point the call blocks on *another computer* — the daemon has to - // open an SSH channel (doing the whole handshake if nothing is pooled) and - // the remote `tty7-server` has to answer. The doc above says callers are on - // a background thread; this is what makes that a rule rather than a hope. - // - // The same guard the `Host` trait uses for its filesystem calls, for the - // same reason and with the same blast radius: `debug_assert!` compiles away - // in release, so a shipped build never trades a slow pane for a dead app. - // It fires in development the moment a routed connect is reintroduced on - // the UI thread — which is how spawning, restoring, listing and killing - // remote panes each froze the window in turn. tty7_core::host::guard_off_ui(); - // WSL installs from the GUI process, never from the daemon: consent has to - // be raised where it can be answered, and this machine *is* the machine - // (see `install::wsl::ensure_wsl_server`'s own doc). The daemon's call a - // moment later finds the binary in place and asks nobody. if let crate::daemon::router::RouteTarget::Wsl { distro } = &header.target { crate::daemon::install::wsl::ensure_wsl_server(distro) .map_err(|e| anyhow::anyhow!("prepare tty7-server in WSL `{distro}`: {e}"))?; @@ -2337,9 +1500,6 @@ fn terminal_config_from_user(user_config: &crate::core::config::Config) -> Confi scrolling_history: user_config.scrollback_limit, default_cursor_style: alacritty_cursor_style(user_config.cursor_style), semantic_escape_chars: user_config.word_separators.clone(), - // `alacritty_terminal` leaves this off for embedders by default. tty7's - // input encoder supports CSI-u, so allow foreground applications to - // negotiate it instead of collapsing modified keys to legacy bytes. kitty_keyboard: true, ..Config::default() } @@ -2357,7 +1517,6 @@ fn alacritty_cursor_style(style: ConfigCursorStyle) -> CursorStyle { } } -/// Build the protocol `WinSize` from our `TermSize` + cell pixel size. fn win_size(size: TermSize, cell_w: u16, cell_h: u16) -> WinSize { WinSize { cols: size.cols as u16, @@ -2367,19 +1526,12 @@ fn win_size(size: TermSize, cell_w: u16, cell_h: u16) -> WinSize { } } -// Uses `UnixStream::pair()` to stand in for the daemon connection, so it only -// runs on Unix. On Windows the transport is loopback TCP (no `pair` helper); the -// reader logic it exercises is platform-agnostic, so Unix coverage suffices. #[cfg(all(test, unix))] mod tests { use super::*; use std::io::Write; use std::os::unix::net::UnixStream; - // ----------------------------------------------------------------------- - // Routing: a local pane must not change, a remote pane must not be local. - // ----------------------------------------------------------------------- - fn ssh_workspace() -> PaneWorkspace { PaneWorkspace { workspace: crate::core::session::WorkspaceId::new(), @@ -2397,11 +1549,6 @@ mod tests { } } - /// **A local pane writes no extra byte.** The whole compatibility promise of - /// this milestone in one assertion: `header()` is the only thing that puts a - /// frame in front of a connection, and a pane with no workspace has none — - /// so `connect_routed` is a bare `connect()` and the daemon's `handle_conn` - /// sees the same opening `Spawn` it always did. #[test] fn a_local_pane_prefixes_nothing() { assert!(PaneRoute::Local.header().is_none()); @@ -2410,11 +1557,6 @@ mod tests { assert!(matches!(PaneRoute::default(), PaneRoute::Local)); } - /// A remote workspace's pane routes to its machine, on the **pane** channel. - /// - /// The channel is the load-bearing half: a header that defaulted to - /// `Control` would reach the remote's control socket, where the first - /// `Spawn` is an unknown frame. #[test] fn a_remote_pane_routes_to_its_machine_on_the_pane_channel() { let route = PaneRoute::for_workspace(Some(&ssh_workspace())); @@ -2427,8 +1569,6 @@ mod tests { assert_eq!(header.describe(), "ssh me@build-box:22"); } - /// WSL routes by distro and carries no spec, because there is no connection - /// to name. #[test] fn a_wsl_workspace_routes_by_distro() { let ws = PaneWorkspace { @@ -2444,14 +1584,6 @@ mod tests { assert_eq!(header.channel, crate::daemon::router::RouteChannel::Pane); } - /// A `--stdio` workspace on this computer routes to a child process and, - /// crucially, asks it for the **pane** dialect. - /// - /// `LocalStdio` runs its argv verbatim — there is no remote shell command - /// line for the router's `bridge_command` to rewrite — so the `--pane` flag - /// has to be added here. Without it the pane lands on the control socket - /// and its first `Spawn` comes back `InvalidData`, which is exactly what - /// "the window opens but nothing runs in it" looked like. #[test] fn a_local_stdio_workspace_routes_to_a_child_process_on_the_pane_dialect() { let ws = PaneWorkspace { @@ -2474,11 +1606,6 @@ mod tests { } } - /// **A workspace that cannot be routed does not fall back to local.** - /// - /// Pane ids are per-daemon, so a remote pane whose route is missing must not - /// address the local daemon: `Kill { pane_id }` there would name a stranger's - /// pane and succeed. #[test] fn an_unroutable_workspace_is_not_treated_as_local() { let ws = PaneWorkspace { @@ -2495,15 +1622,6 @@ mod tests { assert!(err.to_string().contains("cannot be routed"), "{err}"); } - /// **Only a local pane may make the local daemon restart.** - /// - /// `spawn`'s recovery path reads "the connection dropped before the `Spawn` - /// reply" as a stale local daemon and restarts it — which drains and kills - /// every pane it hosts. On a routed pane that same symptom means the *far - /// end* failed while the local daemon was faithfully forwarding bytes, so - /// acting on it would let one unreachable remote destroy every local - /// session the user had open. Observed for real: a remote whose - /// `tty7-server` could not be exec'd took the local daemon down with it. #[test] fn only_a_local_pane_may_restart_the_local_daemon() { assert!(PaneRoute::Local.is_local()); @@ -2527,8 +1645,6 @@ mod tests { let (client_side, mut daemon_side) = UnixStream::pair().unwrap(); let term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap(); - // Pi and other modern TUIs push their requested progressive-enhancement - // flags, then query the active mode before deciding how to parse keys. DaemonMsg::Output(b"\x1b[>7u\x1b[?u".to_vec()) .encode(&mut daemon_side) .unwrap(); @@ -2556,21 +1672,11 @@ mod tests { ); } - /// Guards the `alacritty_terminal` pin, not our own code. Upstream's - /// `push_keyboard_mode` trims its stack by removing from `title_stack` — a - /// copy-paste slip from `push_title` — so once the title stack is empty the - /// `Vec::remove(0)` panics and takes the reader thread with it. Enabling - /// `kitty_keyboard` made that reachable from any foreground program: ~20KB of - /// unpopped pushes is enough. Our fork fixes it; a bump back to an unpatched - /// rev must fail here rather than in the field. #[test] fn deep_keyboard_mode_pushes_leave_the_reader_alive() { let (client_side, mut daemon_side) = UnixStream::pair().unwrap(); let term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap(); - // One past alacritty's KEYBOARD_MODE_STACK_MAX_DEPTH (4096): the push that - // overflows is the one that used to panic. The trailing query is the - // liveness probe — a dead reader thread simply never answers. let mut payload = b"\x1b[>1u".repeat(4097); payload.extend_from_slice(b"\x1b[?u"); DaemonMsg::Output(payload).encode(&mut daemon_side).unwrap(); @@ -2596,19 +1702,11 @@ mod tests { ); } - /// Guards the `alacritty_terminal` pin, not our own code. Upstream reserves - /// columns one `char` at a time, so an emoji written as base + `U+FE0F` - /// (`❤️`, `🗂️`, `⚠️` — anything whose base is East Asian Width Neutral) gets - /// one column instead of two and shoves the rest of the line left by one. - /// Our fork re-scores the sequence and widens the cell; a bump back to an - /// unpatched rev must fail here rather than in the field (issue #203). #[test] fn emoji_presentation_sequences_reserve_two_columns() { let (client_side, mut daemon_side) = UnixStream::pair().unwrap(); let term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap(); - // `x` marks where the emoji ended: column 2 if ❤️ got its two columns, - // column 1 if the selector was counted as free. DaemonMsg::Output("\u{2764}\u{FE0F}x".as_bytes().to_vec()) .encode(&mut daemon_side) .unwrap(); @@ -2650,9 +1748,6 @@ mod tests { assert!(!daemon_disconnected_before_spawn_reply(&refused)); } - /// A dead daemon is the one failure the client can fix by itself, and it - /// must be told apart from a live daemon saying no — restarting on *that* - /// would kill every running pane over a bad shell setting. #[test] fn only_a_dead_daemon_is_worth_starting_one_for() { let connect_failed = |kind| -> anyhow::Error { @@ -2666,8 +1761,6 @@ mod tests { std::io::ErrorKind::NotFound ))); - // A daemon that answered and refused, and one that hung up mid-Spawn: - // neither is "not running", and each has its own recovery. let refused = anyhow::anyhow!("daemon refused Spawn: configured shell missing"); assert!(!daemon_not_listening(&refused)); let eof: anyhow::Error = @@ -2675,18 +1768,6 @@ mod tests { assert!(!daemon_not_listening(&eof)); } - // ----------------------------------------------------------------------- - // Attach: telling "that pane is gone" from "that pane is quiet". - // ----------------------------------------------------------------------- - - /// **A pane that is gone makes the attach fail.** The regression this - /// exists for: `Attach` has no synchronous reply, so the client used to - /// return `Ok` unconditionally and the daemon's `Error` frame was read much - /// later by the reader thread, which has no arm for it — the socket then - /// closed and the pane landed in the *link is down* state (`tty7 — - /// disconnected`, kept on screen, never respawned) instead of falling back - /// to a fresh shell in `start_pane_spawn`. Ending a workspace's sessions - /// and reopening it hit exactly this. #[test] fn an_attach_to_a_missing_pane_is_an_error_not_a_disconnect() { let (mut client_side, mut daemon_side) = UnixStream::pair().unwrap(); @@ -2703,8 +1784,6 @@ mod tests { ); } - /// A daemon that hangs up without answering is the same answer by other - /// means — a `Kill` racing the attach closes the connection. #[test] fn an_attach_the_daemon_hangs_up_on_is_an_error() { let (mut client_side, daemon_side) = UnixStream::pair().unwrap(); @@ -2714,12 +1793,6 @@ mod tests { ); } - /// **A local attach's wait is bounded by the UI, not by the network.** It - /// runs synchronously on the UI thread (`ui::pending_pane` explains why), - /// so the wait for the daemon's first frame is a possible window freeze; - /// the remote one is on a background thread and can be patient. Equal - /// numbers here would mean a wedged local daemon freezing restore for - /// fifteen seconds per pane. #[test] fn a_local_attach_does_not_wait_as_long_as_a_remote_one() { let local = attach_reply_wait(&PaneRoute::Local); @@ -2731,10 +1804,6 @@ mod tests { ); } - /// **The bytes read to classify the reply are not consumed.** A successful - /// attach's first frame is the head of the replay, so anything the check - /// pulled off the socket has to reach the reader thread — losing it would - /// mean reopening a workspace to a screen missing its first segment. #[test] fn a_live_attach_hands_its_replay_bytes_to_the_reader() { crate::core::config::pin_test_config_dir(); @@ -2779,21 +1848,13 @@ mod tests { ); } - /// Without a real daemon, drive the reader path directly: a `UnixStream::pair` - /// stands in for the connection. We hand `RemoteTerminal` one half (as if it - /// were the attach'd socket) and push framed `DaemonMsg`s down the other, then - /// assert the bytes landed in the local `Term`'s grid and the cwd was cached. #[test] fn reader_feeds_local_grid() { let (client_side, mut daemon_side) = UnixStream::pair().unwrap(); let size = TermSize::new(80, 24); - // Build a RemoteTerminal around the client half exactly like `from_stream` - // does after the handshake. (We can't call `spawn`/`attach` here because - // there's no daemon to perform the handshake.) let term = RemoteTerminal::from_stream(client_side, size).unwrap(); - // Send some visible output and a cwd report. DaemonMsg::Output(b"hello".to_vec()) .encode(&mut daemon_side) .unwrap(); @@ -2802,8 +1863,6 @@ mod tests { .unwrap(); daemon_side.flush().unwrap(); - // The reader thread applies frames asynchronously; poll the grid briefly - // until "hello" shows up on row 0 (avoids a fixed-sleep flake). let mut got = String::new(); for _ in 0..200 { { @@ -2823,8 +1882,6 @@ mod tests { } assert_eq!(got, "hello", "reader thread should have fed the grid"); - // The `Cwd` frame is processed after `Output`, so it may land a moment - // after "hello" shows up; poll for it rather than reading once. let mut cwd = None; for _ in 0..200 { cwd = term.foreground_cwd(); @@ -2835,7 +1892,6 @@ mod tests { } assert_eq!(cwd, Some(PathBuf::from("/tmp/work"))); - // Drop the daemon side: the reader hits EOF, marks exited, and exits. drop(daemon_side); for _ in 0..200 { if term.exited_flag.load(Ordering::SeqCst) { @@ -2859,7 +1915,6 @@ mod tests { let mut shape = term.term.lock().cursor_style().shape; assert_eq!(shape, CursorShape::Underline); - // DECSCUSR 6 = steady beam, the sequence nvim uses for insert mode. DaemonMsg::Output(b"\x1b[6 q".to_vec()) .encode(&mut daemon_side) .unwrap(); @@ -2873,8 +1928,6 @@ mod tests { } assert_eq!(shape, CursorShape::Beam); - // DECSCUSR 0 clears the application override, so the configured - // terminal default is visible again. DaemonMsg::Output(b"\x1b[0 q".to_vec()) .encode(&mut daemon_side) .unwrap(); @@ -2889,8 +1942,6 @@ mod tests { assert_eq!(shape, CursorShape::Underline); } - /// Native-SSH `AuthPrompt` and `SshStatus` frames must surface through the - /// reader thread into the per-pane queue / phase cell the auth sheet reads. #[test] fn reader_surfaces_auth_prompt_and_status() { let (client_side, mut daemon_side) = UnixStream::pair().unwrap(); @@ -2912,7 +1963,6 @@ mod tests { .unwrap(); daemon_side.flush().unwrap(); - // Poll until the prompt lands (reader applies frames asynchronously). let mut prompt = None; for _ in 0..200 { if let Some(p) = term.take_auth_prompt() { @@ -2925,18 +1975,11 @@ mod tests { assert_eq!(id, 7); assert!(matches!(kind, AuthPromptKind::Password { .. })); assert_eq!(term.ssh_phase(), Some(SshPhase::Authenticating)); - // The queue is now drained. assert!(!term.has_pending_auth()); } - /// A `DaemonMsg::Exited` frame (the child really ended) must set - /// `child_exited`; a bare daemon disconnect (EOF) must not — both flip - /// `exited_flag`. The distinction is what keeps pane auto-close from - /// firing on a lost connection and destroying a session that may still be - /// alive daemon-side. #[test] fn child_exit_is_distinguished_from_daemon_disconnect() { - // A genuine child exit: the daemon reports it explicitly. let (client_side, mut daemon_side) = UnixStream::pair().unwrap(); let term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap(); DaemonMsg::Exited { code: Some(0) } @@ -2954,7 +1997,6 @@ mod tests { "an Exited frame is a genuine child exit" ); - // A daemon disconnect: the socket just closes. let (client_side, daemon_side) = UnixStream::pair().unwrap(); let term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap(); drop(daemon_side); @@ -2971,25 +2013,14 @@ mod tests { ); } - /// `stale_mode_resets` maps each residue bit to its reset — and nothing - /// more. The guards matter as much as the resets: `?1049l` on a grid that - /// is *not* on the alt screen performs a cursor restore, so a clean (or - /// merely cursor-hidden) mode must never emit it. #[test] fn stale_mode_resets_target_only_the_dirty_bits() { - // A healthy prompt-time mode: nothing to reset. let clean = TermMode::SHOW_CURSOR | TermMode::LINE_WRAP | TermMode::BRACKETED_PASTE; assert!(stale_mode_resets(clean).is_empty()); - // Hidden cursor alone (a Claude-Code-style TUI, no alt screen): - // exactly `?25h`, and crucially no `?1049l`. let hidden = TermMode::LINE_WRAP; assert_eq!(stale_mode_resets(hidden), b"\x1b[?25h"); - // The full ssh-drop-mid-htop residue: alt screen + hidden cursor + - // mouse reporting. The alt-screen exit leads (later resets must land - // on the primary screen), and the kitty zeroing rides along because - // the primary screen's flags are unobservable from the alt screen. let residue = TermMode::ALT_SCREEN | TermMode::MOUSE_DRAG | TermMode::SGR_MOUSE; let seq = stale_mode_resets(residue); let text = String::from_utf8_lossy(&seq).into_owned(); @@ -2999,27 +2030,18 @@ mod tests { assert!(text.contains("\x1b[?1006l")); assert!(text.ends_with("\x1b[=0;1u")); - // Kitty keyboard flags alone (the same drop during a kitty-protocol - // app): just the zeroing, nothing screen-related. let kitty = TermMode::SHOW_CURSOR | TermMode::DISAMBIGUATE_ESC_CODES; assert_eq!(stale_mode_resets(kitty), b"\x1b[=0;1u"); } - /// End-to-end through the reader thread: a TUI's mode changes arrive as - /// `Output`, the connection "dies" (no restore sequences), and the host - /// shell's next prompt report must scrub the residue from the local grid. - /// This is the ssh-drop-mid-TUI bug at the transport level. #[test] fn prompt_report_scrubs_stale_tui_modes() { let (client_side, mut daemon_side) = UnixStream::pair().unwrap(); let term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap(); - // htop over ssh: alt screen, hidden cursor, drag + SGR mouse. Then the - // network drops — no `?1049l`/`?25h`/mouse-off ever arrives. DaemonMsg::Output(b"\x1b[?1049h\x1b[?25l\x1b[?1002h\x1b[?1006h".to_vec()) .encode(&mut daemon_side) .unwrap(); - // ssh exits; the host shell's integration reports a fresh prompt. DaemonMsg::Prompt { active: true, at_prompt: true, @@ -3055,26 +2077,16 @@ mod tests { ); } - /// Regression for the "restored pane types `11;rgb:…` at the prompt" bug: - /// queries replayed from an attach `Snapshot` must NOT be re-answered — - /// they were answered when they ran live, and answering again writes the - /// reply to a shell that never asked (it echoes at the current prompt as - /// if typed). Historical OSC 52 must not touch the clipboard and BELs must - /// not flash either. The same sequences in *live* output keep working. #[test] fn snapshot_replay_suppresses_query_replies_and_side_effects() { let (client_side, mut daemon_side) = UnixStream::pair().unwrap(); let term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap(); - // Replayed history: a cursor-position query (CSI 6n), an OSC 11 - // background probe, an OSC 52 clipboard write ("hi"), and a BEL. DaemonMsg::Snapshot(b"\x1b[6n\x1b]11;?\x07\x1b]52;c;aGk=\x07\x07replayed".to_vec()) .encode(&mut daemon_side) .unwrap(); daemon_side.flush().unwrap(); - // The reader sends a Wakeup after the advance; collect every event up - // to (and past) it, then assert none of the suppressed kinds leaked. let mut events = Vec::new(); for _ in 0..200 { while let Ok(ev) = term.events.try_recv() { @@ -3101,7 +2113,6 @@ mod tests { "replayed history must not re-answer queries or replay side effects" ); - // The same cursor-position query in live output is answered as usual. DaemonMsg::Output(b"\x1b[6n".to_vec()) .encode(&mut daemon_side) .unwrap(); @@ -3121,11 +2132,6 @@ mod tests { assert!(got_reply, "live queries must still be answered"); } - /// TUIs (Claude Code among them) probe DECRQM `?2026` before wrapping - /// frames in BSU/ESU synchronized updates. The probe must come back - /// "supported" (`;2` = reset) — otherwise the app streams frames - /// unwrapped and a mid-frame state (rows cleared but not yet rewritten) - /// can be painted. #[test] fn decrqm_probe_reports_sync_update_supported() { let (client_side, mut daemon_side) = UnixStream::pair().unwrap(); @@ -3164,14 +2170,11 @@ mod tests { assert_eq!(ws.cell_h, 17); } - /// `write` frames non-empty input as a `ClientMsg::Input`; the empty case sends - /// nothing so the daemon never sees a zero-byte frame. #[test] fn write_sends_input_frames() { let (client_side, mut daemon_side) = UnixStream::pair().unwrap(); let term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap(); - // An empty write is a no-op (asserted first so no frame precedes the real one). term.write(Vec::<u8>::new()); term.write(b"echo hi\r".to_vec()); @@ -3181,19 +2184,11 @@ mod tests { } } - /// Regression for the "restored pane scribbles typed text over old prompts" - /// bug: the daemon reports the geometry the ring was recorded under - /// (`DaemonMsg::Size`, ahead of the `Snapshot`), and the reader must apply - /// it *before* replaying — otherwise history wraps at the placeholder - /// width and ZLE's relative cursor motion lands on the wrong rows. #[test] fn attach_replay_runs_at_the_daemon_reported_size() { let (client_side, mut daemon_side) = UnixStream::pair().unwrap(); - // 80×24 placeholder, exactly like the real pre-layout attach path. let term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap(); - // The ring was recorded on a 120-column PTY: a 100-char line fits there - // without wrapping, but would wrap at the 80-column placeholder. DaemonMsg::Size(WinSize { cols: 120, rows: 30, @@ -3207,9 +2202,6 @@ mod tests { .unwrap(); daemon_side.flush().unwrap(); - // Poll until the replay landed (column 99 of row 0 filled). Don't index - // past column 79 until the `Size` frame has widened the grid — before - // that the placeholder grid is only 80 columns. let (mut tail, mut wrapped) = (' ', ' '); for _ in 0..200 { { @@ -3237,24 +2229,17 @@ mod tests { ); } - /// The first layout always syncs the daemon, even at the placeholder size: - /// attach no longer resizes the PTY, so until the first `Resize` frame the - /// PTY may disagree with the client grid. Only *subsequent* same-size - /// resizes are deduplicated. #[test] fn first_resize_always_syncs_then_dedups() { let (client_side, mut daemon_side) = UnixStream::pair().unwrap(); let mut term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap(); - // Laid out at exactly the placeholder size: the frame must still go out. term.resize(TermSize::new(80, 24), 8, 17); match ClientMsg::read(&mut daemon_side).unwrap() { ClientMsg::Resize(ws) => assert_eq!((ws.cols, ws.rows), (80, 24)), other => panic!("expected the first Resize to be sent, got {other:?}"), } - // The same size again is deduplicated: the next frame on the wire is - // the Input written afterwards, not another Resize. term.resize(TermSize::new(80, 24), 8, 17); term.write(b"marker".to_vec()); match ClientMsg::read(&mut daemon_side).unwrap() { @@ -3263,25 +2248,16 @@ mod tests { } } - /// Regression: a DEC 2026 synchronized update opened (BSU) but never closed - /// (ESU) must not freeze the pane — after the sync deadline the buffered - /// frame force-flushes, exactly like alacritty's event loop. Before the - /// fix the reader blocked on the socket and the bytes stayed trapped until - /// the next output happened to arrive. #[test] fn sync_update_without_esu_flushes_after_the_deadline() { let (client_side, mut daemon_side) = UnixStream::pair().unwrap(); let term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap(); - // BSU, then visible text — and no ESU, ever. DaemonMsg::Output(b"\x1b[?2026habc".to_vec()) .encode(&mut daemon_side) .unwrap(); daemon_side.flush().unwrap(); - // The text must appear without any further frames: only the reader's - // own deadline enforcement can flush it. (Bounded poll well past the - // 150ms sync window.) let mut got = String::new(); for _ in 0..600 { { @@ -3304,23 +2280,16 @@ mod tests { assert_eq!(got, "abc", "dangling BSU must flush on the sync deadline"); } - /// A replay ring cut mid-sync-frame (BSU recorded, its ESU past the cut) - /// must flush as part of the replay — with query suppression still active. - /// Trapped bytes flushing later would count as live and re-answer - /// historical queries, the exact leak replay suppression exists to stop. #[test] fn snapshot_replay_flushes_a_dangling_sync_frame_suppressed() { let (client_side, mut daemon_side) = UnixStream::pair().unwrap(); let term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap(); - // The ring ends inside a sync frame that contains a cursor query. DaemonMsg::Snapshot(b"\x1b[?2026h\x1b[6nhi".to_vec()) .encode(&mut daemon_side) .unwrap(); daemon_side.flush().unwrap(); - // The replayed text appears promptly (flushed with the snapshot, not - // 150ms later as live output)… let mut got = String::new(); for _ in 0..200 { { @@ -3345,7 +2314,6 @@ mod tests { "the trapped replay tail must flush with the snapshot" ); - // …and the historical query was NOT re-answered. let mut events = Vec::new(); while let Ok(ev) = term.events.try_recv() { events.push(ev); @@ -3356,27 +2324,18 @@ mod tests { ); } - /// Regression for the attach-time geometry race: when the daemon's recorded - /// `Size` (replay geometry) lands *after* the view's first layout resize, - /// deduping on the remembered request alone froze the local grid at the - /// replay geometry forever (every later same-size layout was swallowed - /// while the PTY ran at the layout size). The dedup must re-check the local - /// grid, so the next layout pass self-heals. #[test] fn layout_resize_reasserts_geometry_after_a_late_size_frame() { use alacritty_terminal::grid::Dimensions as _; let (client_side, mut daemon_side) = UnixStream::pair().unwrap(); let mut term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap(); - // First layout: 100×40. Grid follows immediately; a Resize frame goes out. term.resize(TermSize::new(100, 40), 8, 17); assert!(matches!( ClientMsg::read(&mut daemon_side).unwrap(), ClientMsg::Resize(_) )); - // The daemon's attach replay (Size + Snapshot) arrives late — after the - // layout — and rewrites the local grid to the recorded 120×30. DaemonMsg::Size(WinSize { cols: 120, rows: 30, @@ -3397,9 +2356,6 @@ mod tests { } assert_eq!(term.term.lock().columns(), 120, "replay geometry applied"); - // The next layout pass reports the same 100×40 as before. The stale - // dedup swallowed this; now it must resize the grid back and re-sync - // the daemon. term.resize(TermSize::new(100, 40), 8, 17); assert_eq!(term.term.lock().columns(), 100); assert_eq!(term.term.lock().screen_lines(), 40); @@ -3409,8 +2365,6 @@ mod tests { )); } - /// `resize` to a new geometry updates the cached size and sends a `Resize` - /// frame; repeating the same size afterwards is a no-op. #[test] fn resize_updates_size_and_notifies_daemon() { let (client_side, mut daemon_side) = UnixStream::pair().unwrap(); @@ -3426,17 +2380,12 @@ mod tests { } } - /// `at_prompt` requires the shell to be *active* (integration engaged): a - /// report carrying `at_prompt: true` but `active: false` must not flip it — - /// otherwise the line editor would engage during the rc-sourcing window. #[test] fn at_prompt_stays_false_while_shell_integration_is_inactive() { let (client_side, mut daemon_side) = UnixStream::pair().unwrap(); let term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap(); assert!(!term.shell_active(), "no report yet → integration inactive"); - // An inactive report, then an Output marker we can poll for so we know - // the reader has processed both frames (they're applied in order). DaemonMsg::Prompt { active: false, at_prompt: true, @@ -3465,14 +2414,11 @@ mod tests { assert!(!term.at_prompt(), "inactive shell must gate at_prompt off"); } - /// `at_prompt` reflects the daemon's last `Prompt` report, and is conservatively - /// false until the daemon has reported an active shell. #[test] fn at_prompt_follows_daemon_prompt_reports() { let (client_side, mut daemon_side) = UnixStream::pair().unwrap(); let term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap(); - // Before any report, we conservatively answer false. assert!(!term.at_prompt()); DaemonMsg::Prompt { @@ -3495,9 +2441,6 @@ mod tests { assert!(at, "at_prompt should become true after the Prompt report"); } - /// `foreground_agent` reflects the daemon's last `Agent` report — `None` - /// before any report, the detected agent after one, and back to `None` when - /// the agent exits (the daemon reports `Agent(None)`). #[test] fn foreground_agent_follows_daemon_agent_reports() { let (client_side, mut daemon_side) = UnixStream::pair().unwrap(); @@ -3526,9 +2469,6 @@ mod tests { assert!(poll(None), "agent exit should clear it"); } - /// `DaemonMsg::AgentStatus` frames must land in the client's session - /// cache (and a `None` clear it) — the reader half of the rich-status - /// channel the daemon's sniffer feeds. #[test] fn agent_session_follows_daemon_status_reports() { use crate::core::cli_agent::{AgentSessionState, AgentStatus}; @@ -3573,11 +2513,6 @@ mod tests { assert!(poll(&|s| s.is_none()), "a None report clears the session"); } - /// End-to-end check of the Outline's data path: OSC 133 marks arriving in the - /// output stream must land in `Marks` with the *grid row they fell on*, not - /// the row at the end of the batch. This is the whole reason the reader - /// splits its advance at mark offsets, so it's worth an integration test — - /// a regression here looks fine (marks appear) but scrolls to the wrong place. #[test] fn marks_record_the_row_each_one_landed_on() { let (client_side, mut daemon_side) = UnixStream::pair().unwrap(); @@ -3592,15 +2527,12 @@ mod tests { false }; - // Two full prompt cycles in ONE batch, separated by output lines. If the - // reader advanced the batch in a single pass and read the cursor after, - // both marks would report the same (final) row. let mut stream = Vec::new(); - stream.extend_from_slice(b"\x1b]133;A\x07"); // prompt 1 at row 0 + stream.extend_from_slice(b"\x1b]133;A\x07"); stream.extend_from_slice(b"\x1b]133;C;echo one\x07"); stream.extend_from_slice(b"one\r\n"); stream.extend_from_slice(b"\x1b]133;D;0\x07"); - stream.extend_from_slice(b"\x1b]133;A\x07"); // prompt 2, two rows down + stream.extend_from_slice(b"\x1b]133;A\x07"); stream.extend_from_slice(b"\x1b]133;C;false\x07"); stream.extend_from_slice(b"\r\n"); stream.extend_from_slice(b"\x1b]133;D;1\x07"); @@ -3622,13 +2554,6 @@ mod tests { ); } - /// The typeahead wipe (^U) may only be written once zle actually reads the - /// keyboard; the client learns that from a *live* `133;B` (prompt end) in - /// the output stream. `133;D` (command done, but precmd hooks still running - /// with the terminal in canonical mode) must keep the flag off — a wipe - /// written there is kernel-echoed as a literal `^U` into the scrollback — - /// and a historical `B` replayed from an attach Snapshot is not "zle is - /// reading right now" either. #[test] fn zle_reading_follows_live_prompt_end_marks() { let (client_side, mut daemon_side) = UnixStream::pair().unwrap(); @@ -3644,8 +2569,6 @@ mod tests { }; assert!(!term.zle_reading(), "conservative false before any mark"); - // Snapshot replay carrying a historical B, then a live D with a marker - // cell we can wait on — both applied in order by the reader. DaemonMsg::Snapshot(b"\x1b]133;B\x07".to_vec()) .encode(&mut daemon_side) .unwrap(); @@ -3670,7 +2593,6 @@ mod tests { "replayed B / live D must not arm the flag" ); - // The live B arms it; the next command start (C) disarms it. DaemonMsg::Output(b"\x1b]133;B\x07".to_vec()) .encode(&mut daemon_side) .unwrap(); @@ -3766,8 +2688,6 @@ mod tests { assert!(poll(false), "a replayed V;0 should clear vi-mode state"); } - /// Everything in the grid — screen rows plus scrollback — flattened to one - /// string, one row per line, for substring counting in the replay test. fn full_dump(term: &RemoteTerminal) -> String { use alacritty_terminal::grid::Dimensions as _; let t = term.term.lock(); @@ -3785,10 +2705,6 @@ mod tests { out } - /// One Claude-Code/ink-style redraw: return to the frame's first row with - /// CR + cursor-up, erase below, reprint every line. `prev_rows` is the row - /// count the *app* believes the previous frame occupied — correct only if - /// the terminal wrapped it at the width the app rendered for. fn tui_frame(lines: &[String], prev_rows: usize) -> Vec<u8> { let mut b = Vec::new(); if prev_rows > 1 { @@ -3798,27 +2714,14 @@ mod tests { b } - /// Regression for the "Claude Code output duplicated all over scrollback - /// after restart" bug. The daemon's replay ring is raw bytes; a TUI's - /// cursor-up redraws only replay cleanly at the width they were rendered - /// for. The daemon therefore segments the ring by geometry and attach - /// replays a `Size` → `Snapshot` pair per segment (see - /// `daemon/pane.rs::ReplayRing`) — this test drives the reader with - /// exactly that frame sequence and asserts the replay reproduces the live - /// rendering, no duplication. The final leg replays the same bytes the - /// pre-segmentation way (one Snapshot at the final width) and shows the - /// duplication, pinning that the segmented path is what prevents it. #[test] fn segmented_ring_replay_reproduces_live_rendering() { const MARK: &str = "DUPMARK"; - // 10 logical lines of 90 chars: one row on a 100-col grid, two on 80. let frame_lines = |f: usize| -> Vec<String> { (0..10) .map(|i| format!("{MARK} f{f:02} l{i:02} {:.<74}", "")) .collect() }; - // The app renders 8 frames believing each line is one row (true at the - // 100-col width it was written for). let mut history = Vec::new(); for f in 0..8 { history.extend(tui_frame(&frame_lines(f), if f == 0 { 0 } else { 10 })); @@ -3842,8 +2745,6 @@ mod tests { cell_h: 17, }; - // Live: the bytes stream into a 100-col grid as PTY output, then the - // pane shrinks to 80 (reflow) — the sequence the ring recorded. let (client_side, mut daemon_side) = UnixStream::pair().unwrap(); let mut live = RemoteTerminal::from_stream(client_side, TermSize::new(100, 24)).unwrap(); DaemonMsg::Output(history.clone()) @@ -3860,9 +2761,6 @@ mod tests { so exactly one 10-line copy survives the resize" ); - // Attach replay, as the daemon now sends it: the 100-col segment at - // its recorded width, then the (empty) post-resize segment's pair - // ending the grid at the current 80 cols. let (client_side, mut daemon_side) = UnixStream::pair().unwrap(); let replay = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap(); DaemonMsg::Size(ws(100)).encode(&mut daemon_side).unwrap(); @@ -3893,10 +2791,6 @@ mod tests { "the segmented replay must reproduce the live rendering exactly" ); - // Contrast (and guard that the markers actually exercise the wrap - // hazard): the pre-segmentation replay — everything in one Snapshot at - // the final 80-col width — mis-wraps the frames, the redraws land - // mid-frame, and stale copies flood scrollback. let (client_side, mut daemon_side) = UnixStream::pair().unwrap(); let flat = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap(); DaemonMsg::Size(ws(80)).encode(&mut daemon_side).unwrap(); @@ -3914,13 +2808,10 @@ mod tests { } } -/// OSC notification scanner tests. Not `unix`-gated: the scanner is pure byte logic -/// with no socket dependency, so it exercises on every platform. #[cfg(test)] mod osc_tests { use super::{OscNotifyScanner, parse_osc_notification}; - /// Run the scanner over one or more chunks and collect the notifications. fn scan(chunks: &[&[u8]]) -> Vec<(Option<String>, String)> { let mut s = OscNotifyScanner::default(); let mut out = Vec::new(); @@ -3932,12 +2823,10 @@ mod osc_tests { #[test] fn osc9_bel_and_st_terminators() { - // BEL-terminated OSC 9. assert_eq!( scan(&[b"\x1b]9;Build done\x07"]), vec![(None, "Build done".to_string())] ); - // ST-terminated (ESC \) OSC 9. assert_eq!( scan(&[b"\x1b]9;Tests passed\x1b\\"]), vec![(None, "Tests passed".to_string())] @@ -3950,7 +2839,6 @@ mod osc_tests { scan(&[b"\x1b]777;notify;Title;Body text\x07"]), vec![(Some("Title".to_string()), "Body text".to_string())] ); - // Title-only becomes a body-only notification. assert_eq!( scan(&[b"\x1b]777;notify;Just a message\x1b\\"]), vec![(None, "Just a message".to_string())] @@ -3959,13 +2847,10 @@ mod osc_tests { #[test] fn split_across_reads_is_reassembled() { - // The sequence is torn across three chunks, including mid-payload and right - // before the terminator. assert_eq!( scan(&[b"\x1b]9;Hel", b"lo wor", b"ld\x07"]), vec![(None, "Hello world".to_string())] ); - // ESC and its ST backslash split across the chunk boundary. assert_eq!( scan(&[b"\x1b]9;Ping\x1b", b"\\"]), vec![(None, "Ping".to_string())] @@ -3974,13 +2859,10 @@ mod osc_tests { #[test] fn uninteresting_osc_is_ignored_cheaply() { - // OSC 52 (clipboard) and OSC 0 (title) must not produce notifications, and - // real output around them still works. assert_eq!( scan(&[b"\x1b]52;c;bWFueSBieXRlcw==\x07\x1b]0;my title\x07"]), vec![] ); - // A notification after an ignored OSC is still caught (state resets). assert_eq!( scan(&[b"\x1b]0;title\x07\x1b]9;After\x07"]), vec![(None, "After".to_string())] @@ -3989,7 +2871,6 @@ mod osc_tests { #[test] fn conemu_osc9_subcommands_are_not_notifications() { - // ConEmu progress (9;4;…) and set-cwd (9;9;…) are control, not toasts. assert_eq!(scan(&[b"\x1b]9;4;1;50\x07"]), vec![]); assert_eq!(scan(&[b"\x1b]9;9;/home/u\x07"]), vec![]); } @@ -4003,11 +2884,6 @@ mod osc_tests { #[test] fn resyncs_on_new_osc_after_an_unterminated_one() { - // An unterminated OSC aborted by the ESC that *opens the next* OSC must not - // swallow that opening `]`: the following well-formed notification is still - // caught. Covers both the buffering path (a 9/777-prefixed OSC) and the - // ignore path (an OSC we skip, e.g. a title). Real senders occasionally omit - // the terminator and rely on the next ESC to abort the sequence. assert_eq!( scan(&[b"\x1b]9;dropped\x1b]9;kept\x07"]), vec![(None, "kept".to_string())] diff --git a/src/terminal/reverse_search.rs b/src/terminal/reverse_search.rs index 24a59cf5..d62c637b 100644 --- a/src/terminal/reverse_search.rs +++ b/src/terminal/reverse_search.rs @@ -1,57 +1,27 @@ -//! Ctrl+R history search, extracted from the terminal view so the search -//! *logic* (query editing + ranking `history` into a match list) lives apart -//! from the GPUI plumbing (focus, repaint). The view owns an -//! `Option<ReverseSearch>`, forwards keys and typed text to it, and acts on the -//! returned [`Action`] — it never reaches into the query or match list beyond -//! the read-only accessors the menu renderer uses. -//! -//! Matching is fuzzy (see the [`fuzzy`](super::fuzzy) module), blended with the -//! entry's frecency so a command you run constantly — or ran *in this -//! directory* — outranks an equally-good textual match you typed once. An -//! empty query ranks the whole history by frecency alone, so bare Ctrl+R is a -//! browsable "recent & relevant" list rather than a blank prompt. - use super::fuzzy; use std::collections::HashSet; -/// How much an entry's frecency score (roughly `0..7`: recency `0..1` + -/// dampened frequency + current-directory bonus) adds to its fuzzy match -/// score (16+ per matched char). At 2× it decides ties and near-ties between -/// textually similar matches without ever drowning a clearly better match. const FRECENCY_WEIGHT: f64 = 2.0; -/// In-progress search: the typed query and the ranked matches, best first. pub(super) struct ReverseSearch { query: String, matches: Vec<Match>, - /// Cursor into `matches`: the entry Enter accepts, highlighted in the menu. selected: usize, } -/// One ranked match: where it lives in the view's chronological `history`, and -/// which of its chars the query matched (for menu highlighting; empty for the -/// empty-query frecency listing). pub(super) struct Match { pub index: usize, pub positions: Vec<usize>, } -/// What the view should do after handing a key to an active search. pub(super) enum Action { - /// Stay open; just repaint (query, matches or selection changed). Redraw, - /// Close the search and leave the edited line untouched (Esc / Ctrl+G / Ctrl+C). Cancel, - /// Close the search; if `Some`, load that history line into the editor - /// (Enter — the user still presses Enter again to run it). Accept(Option<String>), - /// Close the search and run that history line outright (Cmd+Enter). Run(String), } impl ReverseSearch { - /// Open a search: the empty query immediately lists the history by - /// frecency, so the menu is useful before a single key is typed. pub(super) fn new(history: &[String], frecency: &[f64]) -> Self { let mut rs = Self { query: String::new(), @@ -62,39 +32,29 @@ impl ReverseSearch { rs } - /// The typed query, for the prompt the view renders. pub(super) fn query(&self) -> &str { &self.query } - /// The ranked matches, best first — the menu renders a window of these. pub(super) fn matches(&self) -> &[Match] { &self.matches } - /// Index of the selected match within [`matches`](Self::matches). pub(super) fn selected(&self) -> usize { self.selected } - /// The history line the selection sits on, if any. pub(super) fn selected_line<'a>(&self, history: &'a [String]) -> Option<&'a str> { self.matches .get(self.selected) .map(|m| history[m.index].as_str()) } - /// Recompute the match list. Entries are deduplicated by content (the most - /// recent occurrence wins) and ranked by fuzzy score blended with frecency; - /// an empty query ranks everything by frecency alone. `frecency` is - /// index-aligned with `history`. Resets the selection to the best match. fn update(&mut self, history: &[String], frecency: &[f64]) { self.selected = 0; let list_all = self.query.trim().is_empty(); let mut seen: HashSet<&str> = HashSet::new(); let mut scored: Vec<(f64, Match)> = Vec::new(); - // Newest → oldest, so the stable sort below keeps recent entries first - // among equal scores. for i in (0..history.len()).rev() { let line = history[i].as_str(); if !seen.insert(line) { @@ -123,25 +83,16 @@ impl ReverseSearch { self.matches = scored.into_iter().map(|(_, m)| m).collect(); } - /// Move the selection `delta` steps down the ranked list (positive → worse - /// matches, the classic "older hit" direction of a repeated Ctrl+R), - /// sticking at the ends. fn step(&mut self, delta: isize) { let last = self.matches.len().saturating_sub(1); self.selected = self.selected.saturating_add_signed(delta).min(last); } - /// Append typed text to the query and re-rank. Text arrives either via the - /// IME path (`replace_text_in_range` → the view's `input_text`) or, for a - /// plain ASCII input source, as a direct `key_char` the view forwards from - /// `handle_reverse_search_key`. pub(super) fn push_query(&mut self, text: &str, history: &[String], frecency: &[f64]) { self.query.push_str(text); self.update(history, frecency); } - /// Handle a key while the search is active. Query text itself arrives via - /// [`push_query`](Self::push_query); this covers the control keys only. pub(super) fn handle_key( &mut self, ks: &gpui::Keystroke, @@ -151,23 +102,17 @@ impl ReverseSearch { let m = &ks.modifiers; let key = ks.key.as_str(); if (m.control && key == "r") || key == "down" { - // Next (worse-ranked) match — the classic repeated-Ctrl+R step. self.step(1); Action::Redraw } else if (m.control && key == "s") || key == "up" { - // Back toward the best match (readline's forward-search direction). self.step(-1); Action::Redraw } else if (m.control && (key == "g" || key == "c")) || key == "escape" { Action::Cancel } else if key == "enter" || (m.control && (key == "j" || key == "m")) { - // ⌃J / ⌃M are accept-line's control codes — Enter by another name. let line = self.selected_line(history).map(str::to_string); match (m.platform, line) { - // Cmd+Enter: run the selected line outright. (true, Some(line)) => Action::Run(line), - // Enter: hand back the match (the user still presses Enter to - // run it). A bare Enter with no match just exits the search. (_, line) => Action::Accept(line), } } else if key == "backspace" { @@ -175,7 +120,6 @@ impl ReverseSearch { self.update(history, frecency); Action::Redraw } else { - // Other keys are ignored while searching. Action::Redraw } } @@ -186,14 +130,12 @@ mod tests { use super::*; fn history() -> Vec<String> { - // oldest → newest ["git status", "cargo build", "git commit -m x", "cargo test"] .into_iter() .map(String::from) .collect() } - /// Uniform frecency: ranking falls back to fuzzy score + recency order. fn flat(h: &[String]) -> Vec<f64> { vec![0.0; h.len()] } @@ -216,28 +158,22 @@ mod tests { let h = history(); let mut rs = ReverseSearch::new(&h, &flat(&h)); rs.push_query("git", &h, &flat(&h)); - // Both git commands match equally well; the newer one wins the tie. assert_eq!(rs.selected_line(&h), Some("git commit -m x")); assert_eq!(rs.matches().len(), 2); } #[test] fn fuzzy_matching_spans_words() { - // `gst` is a subsequence of `git status` — the substring search this - // replaces could never find it. let h = history(); let mut rs = ReverseSearch::new(&h, &flat(&h)); rs.push_query("gst", &h, &flat(&h)); assert_eq!(rs.selected_line(&h), Some("git status")); - // The matched positions point at g, s, t for the menu highlight. assert_eq!(rs.matches()[0].positions, vec![0, 4, 5]); } #[test] fn frecency_outranks_recency_between_equal_text_matches() { let h = history(); - // "git status" (oldest) is heavily used; the newer "git commit -m x" - // is a one-off. The blend should float the frequent one on top. let frecency = vec![5.0, 0.0, 0.0, 0.0]; let mut rs = ReverseSearch::new(&h, &frecency); rs.push_query("git", &h, &frecency); @@ -249,7 +185,7 @@ mod tests { let h: Vec<String> = ["ls", "make", "ls"].into_iter().map(String::from).collect(); let rs = ReverseSearch::new(&h, &flat(&h)); let idx: Vec<usize> = rs.matches().iter().map(|m| m.index).collect(); - assert_eq!(idx, [2, 1]); // one "ls", at its newest position + assert_eq!(idx, [2, 1]); } #[test] @@ -263,10 +199,8 @@ mod tests { Action::Redraw )); assert_eq!(rs.selected_line(&h), Some("git status")); - // Already on the last match — a further step sticks. rs.handle_key(&key("ctrl-r"), &h, &flat(&h)); assert_eq!(rs.selected(), 1); - // Ctrl+S / Up steps back toward the best match, sticking at the top. rs.handle_key(&key("ctrl-s"), &h, &flat(&h)); assert_eq!(rs.selected(), 0); rs.handle_key(&key("up"), &h, &flat(&h)); @@ -308,7 +242,6 @@ mod tests { Action::Run(line) => assert_eq!(line, "cargo test"), _ => panic!("expected Run with the selected line"), } - // A bare Enter with no match accepts nothing (just exits). let mut rs = ReverseSearch::new(&h, &flat(&h)); rs.push_query("zzz_nope", &h, &flat(&h)); assert!(rs.matches().is_empty()); @@ -318,8 +251,6 @@ mod tests { } } - /// ⌃J / ⌃M carry accept-line's control codes, so inside the menu they must - /// accept the selection exactly as Enter does (#163). #[test] fn ctrl_j_and_ctrl_m_accept_like_enter() { let h = history(); @@ -337,9 +268,8 @@ mod tests { fn handle_key_backspace_pops_query_and_re_ranks() { let h = history(); let mut rs = ReverseSearch::new(&h, &flat(&h)); - rs.push_query("gitq", &h, &flat(&h)); // no match (no q anywhere) + rs.push_query("gitq", &h, &flat(&h)); assert!(rs.matches().is_empty()); - // Backspace drops the trailing 'q', restoring the git matches. assert!(matches!( rs.handle_key(&key("backspace"), &h, &flat(&h)), Action::Redraw @@ -352,7 +282,6 @@ mod tests { fn handle_key_other_keys_are_ignored_with_redraw() { let h = history(); let mut rs = ReverseSearch::new(&h, &flat(&h)); - // A plain letter is handled via push_query, not handle_key; here it's a no-op redraw. assert!(matches!( rs.handle_key(&key("a"), &h, &flat(&h)), Action::Redraw diff --git a/src/terminal/search.rs b/src/terminal/search.rs index 15787727..7dabd90b 100644 --- a/src/terminal/search.rs +++ b/src/terminal/search.rs @@ -1,8 +1,3 @@ -//! In-terminal incremental search (Cmd+F): the `SearchState` that backs the -//! search bar, the `TerminalView` methods that drive it (open/close, recompute -//! the match list, step between matches) and the search-bar UI. Also hosts -//! `url_at`, the cursor-to-URL probe used for Cmd+click link opening. - use std::path::{Path, PathBuf}; use alacritty_terminal::event::EventListener; @@ -19,16 +14,11 @@ use gpui_component::{ use super::view::TerminalView; -/// Upper bound on matches collected for a single query. Prevents a very broad -/// query (e.g. one character) against a large scrollback from producing an -/// unbounded list and stalling the recompute. const MAX_MATCHES: usize = 10_000; #[derive(Clone, Debug, PartialEq, Eq)] pub(super) enum LinkTarget { Url(String), - /// An existing local file — or directory (`line`/`column` then `None`; - /// dirs never match a `path:line` form). File { path: PathBuf, line: Option<u32>, @@ -43,25 +33,14 @@ pub(super) struct LinkMatch { pub target: LinkTarget, } -/// State backing the Cmd+F search bar. The query text, caret, selection, IME -/// composition and in-field editing keys are all owned by `input` (a -/// gpui-component `InputState`); this struct only adds the match bookkeeping. pub struct SearchState { - /// The text field. Owns focus, caret blink, IME, Cmd+A, arrow keys, etc. pub input: Entity<InputState>, - /// All matches for the query, ordered from the top of the buffer (scrollback) - /// to the bottom. Recomputed only when the query changes. pub matches: Vec<Match>, - /// Index into `matches` of the focused ("current") match, or `None` when - /// there are no matches. Single source of truth — `current()` derives the - /// actual match from it so the two never disagree. pub current_index: Option<usize>, - /// Subscription to the field's `InputEvent`s (query changes, Enter, focus). _subs: Vec<Subscription>, } impl SearchState { - /// The focused match, if any. pub fn current(&self) -> Option<&Match> { self.current_index.and_then(|i| self.matches.get(i)) } @@ -69,13 +48,8 @@ impl SearchState { impl TerminalView { pub fn open_search(&mut self, window: &mut Window, cx: &mut Context<Self>) { - // Build the field on first open (Cmd+F again just refocuses it). The - // InputState owns the query text, caret, selection, Cmd+A and IME. let fresh = self.search.is_none(); if fresh { - // Seed the query: a single-line terminal selection is the strongest - // signal of intent (select-then-⌘F), otherwise fall back to the last - // query so reopening resumes where the user left off. let seed = self .selected_search_seed() .unwrap_or_else(|| self.search_last_query.clone()); @@ -95,16 +69,12 @@ impl TerminalView { if let Some(input) = self.search.as_ref().map(|s| s.input.clone()) { input.update(cx, |state, cx| state.focus(window, cx)); } - // A freshly seeded (or restored) query has matches to compute right away; - // Cmd+F on an already-open bar just refocuses and keeps the current list. if fresh { self.recompute_matches(cx); } cx.notify(); } - /// The current terminal selection as a search seed: a non-empty, single-line - /// selection with no newline. Multi-line selections aren't useful as a query. fn selected_search_seed(&self) -> Option<String> { let text = self.terminal.term.lock().selection_to_string()?; let trimmed = text.trim_matches(['\n', '\r']); @@ -116,8 +86,6 @@ impl TerminalView { } pub fn close_search(&mut self, window: &mut Window, cx: &mut Context<Self>) { - // Remember the query so the next open resumes it (toggles persist on the - // view already). Then tear down the field and any error state. if let Some(s) = self.search.as_ref() { self.search_last_query = s.input.read(cx).value().to_string(); } @@ -125,14 +93,10 @@ impl TerminalView { self.search_focused = false; self.search_regex_error = false; self.terminal.term.lock().selection = None; - // Return focus to the terminal so typing resumes feeding the PTY. window.focus(&self.focus_handle, cx); cx.notify(); } - /// React to the search field's events: a query change recomputes matches and - /// Enter / Shift+Enter steps to the next / previous match. Focus changes are - /// mirrored into `search_focused` for Escape routing in `on_key_down`. fn on_search_event( &mut self, _input: &Entity<InputState>, @@ -143,8 +107,6 @@ impl TerminalView { match event { InputEvent::Change => self.recompute_matches(cx), InputEvent::PressEnter { shift, .. } => { - // Enter: next match (toward the bottom). Shift+Enter: previous - // (toward the top). Matches are ordered top→bottom. let dir = if *shift { Direction::Left } else { @@ -163,12 +125,6 @@ impl TerminalView { } } - /// Recompute the full match list for the current query, ordered from the top - /// of the buffer (scrollback) to the bottom. Called only when the query - /// changes — never per frame. Afterwards `current_index` is set to the match - /// nearest the bottom of the viewport (mirroring the old "search up from the - /// newest content" behavior), falling back to the first match, or `None` - /// when there are no matches / the query is empty. pub(super) fn recompute_matches(&mut self, cx: &mut Context<Self>) { let Some(query) = self .search @@ -185,23 +141,12 @@ impl TerminalView { if !query.is_empty() { let pattern = self.effective_search_pattern(&query); let compiled = RegexSearch::new(&pattern); - // A pattern only fails to compile in regex mode (a literal query is - // escaped, and the `(?-i)` case prefix is always valid), so a failure - // means the user typed a broken regex — flag it instead of silently - // showing zero matches. regex_error = compiled.is_err(); if let Ok(mut regex) = compiled { let term = self.terminal.term.lock(); let grid = term.grid(); let mut origin = Point::new(grid.topmost_line(), Column(0)); - // Walk downward collecting every match. `search_next` wraps - // around the buffer when nothing lies ahead, so we stop as soon - // as a returned match is not strictly past the previous one (it - // wrapped) or once advancing past a match wraps the origin. That - // guarantees forward progress and rules out an infinite loop. - // MAX_MATCHES caps pathological inputs (e.g. a single-character - // query against a huge scrollback) so a recompute stays bounded. while matches.len() < MAX_MATCHES { let Some(m) = term.search_next(&mut regex, origin, Direction::Right, Side::Left, None) @@ -219,8 +164,6 @@ impl TerminalView { } } - // Focus the last match at or above the bottom of the visible - // viewport; fall back to the first match otherwise. if !matches.is_empty() { let display_offset = grid.display_offset() as i32; let bottom = Point::new( @@ -242,9 +185,6 @@ impl TerminalView { } self.search_regex_error = regex_error; - // Clear any stray selection and bring the focused match into view, but - // only when it's off-screen so an in-viewport match doesn't jerk the - // scroll position around as the user refines the query. let current = self.search.as_ref().and_then(|s| s.current().cloned()); let mut term = self.terminal.term.lock(); term.selection = None; @@ -255,9 +195,6 @@ impl TerminalView { cx.notify(); } - /// Move to the next (`Direction::Right`, toward the bottom) or previous - /// (`Direction::Left`, toward the top) match, wrapping around, and scroll the - /// new current match into view. Never recomputes the match list. pub(super) fn step_match(&mut self, direction: Direction, cx: &mut Context<Self>) { let current = { let Some(s) = self.search.as_mut() else { @@ -275,15 +212,10 @@ impl TerminalView { s.current_index = Some(next); s.matches[next].clone() }; - // Explicit navigation always reveals the target: unlike a live query - // change, stepping past a match already on screen should still recenter - // it if it sits off-screen, but leave the viewport alone when it's visible. scroll_match_into_view(&mut self.terminal.term.lock(), ¤t); cx.notify(); } - /// Toggle the "Aa" (force case-sensitive) option and re-search. Does nothing - /// when the bar is closed. fn toggle_search_case(&mut self, cx: &mut Context<Self>) { if self.search.is_none() { return; @@ -292,7 +224,6 @@ impl TerminalView { self.recompute_matches(cx); } - /// Toggle the ".*" (regex vs literal) option and re-search. fn toggle_search_regex(&mut self, cx: &mut Context<Self>) { if self.search.is_none() { return; @@ -301,11 +232,6 @@ impl TerminalView { self.recompute_matches(cx); } - /// Turn the user's query into the pattern fed to alacritty's `RegexSearch`, - /// applying the two toggles. In literal mode the query is regex-escaped so - /// metacharacters (`.`, `*`, `(`, …) match themselves. A `(?-i)` prefix forces - /// case sensitivity when "Aa" is on; when off, alacritty's smart-case default - /// applies (insensitive unless the query already contains an uppercase char). fn effective_search_pattern(&self, query: &str) -> String { let base = if self.search_regex { query.to_string() @@ -325,36 +251,25 @@ impl TerminalView { _window: &Window, cx: &mut Context<Self>, ) -> impl IntoElement + use<> { - // Snapshot theme colors up front so the `cx` borrow is released before we - // build click listeners with `cx.listener` below. let theme = cx.theme(); let muted = theme.muted_foreground; let border = theme.border; let popover = theme.popover; let accent = theme.accent; let danger = theme.red; - // (The `theme` borrow of `cx` ends here, before the `cx.listener` calls below.) let total = state.matches.len(); let has_query = !state.input.read(cx).value().is_empty(); let has_matches = !state.matches.is_empty(); - // Highlight the border while the field is focused so the bar reads as the - // active input. Caret/selection/IME all live inside the field itself. A - // broken regex (only possible in regex mode) turns the border red instead. let focused = self.search_focused; let regex_error = self.search_regex_error; let case_on = self.search_case_sensitive; let regex_on = self.search_regex; - // The query field — a gpui-component InputState. It owns focus, the - // blinking caret, text selection, Cmd+A, arrow keys and IME composition. - // `appearance(false)` drops its own border/background so it sits flush in - // our bar instead of looking like a nested box. let field = Input::new(&state.input) .appearance(false) .with_size(Size::Small); - // Match counter `current/total`, only once something has been typed. let count = has_query.then(|| { let current = if has_matches { state.current_index.map(|i| i + 1).unwrap_or(0) @@ -368,9 +283,6 @@ impl TerminalView { .child(format!("{current}/{total}")) }); - // Option toggles: "Aa" forces case-sensitive matching, ".*" switches the - // query between literal and regex. Both read as pressed (accent fill) when - // active and re-search on click. let case_toggle = Button::new("search-case") .label("Aa") .ghost() @@ -390,12 +302,8 @@ impl TerminalView { this.toggle_search_regex(cx); })); - // Thin rule separating the query zone from the action buttons. let divider = div().flex_none().w(px(1.)).h(px(16.)).bg(border); - // ↑ = previous match (toward the top), ↓ = next (toward the bottom) — - // mirroring the Enter / Shift+Enter bindings. Button stops propagation - // internally, so clicks won't bubble to the terminal surface. let prev = Button::new("search-prev") .icon(IconName::ChevronUp) .ghost() @@ -424,10 +332,6 @@ impl TerminalView { .absolute() .top_2() .right_4() - // Block mouse events over the bar so a click (or drag) on it doesn't - // fall through to the terminal surface and start a selection — the - // terminal's mouse handlers gate on `Hitbox::is_hovered`, which this - // occluding hitbox turns off for the cells beneath the bar. .occlude() .flex() .items_center() @@ -447,8 +351,6 @@ impl TerminalView { }) .bg(popover) .shadow_md() - // The field fills the remaining width; count + toggles + buttons keep - // fixed size. .child(div().flex_1().min_w_0().child(field)) .children(count) .child(case_toggle) @@ -460,11 +362,6 @@ impl TerminalView { } } -/// Scroll `term` so `m`'s start is on screen, but only when it isn't already — -/// an in-viewport match keeps the current scroll position so refining the query -/// or stepping between nearby matches doesn't jerk the view around. The visible -/// line range for the current `display_offset` is `[-offset, screen_lines-1-offset]` -/// (the same arithmetic `recompute_matches` uses to pick the initial match). fn scroll_match_into_view<T: EventListener>(term: &mut Term<T>, m: &Match) { let grid = term.grid(); let display_offset = grid.display_offset() as i32; @@ -476,9 +373,6 @@ fn scroll_match_into_view<T: EventListener>(term: &mut Term<T>, m: &Match) { } } -/// Escape regex metacharacters so a literal-mode query matches itself. Mirrors -/// `regex::escape` (which isn't a direct dependency): backslash-prefix every -/// character the regex parser treats as special. fn regex_escape(query: &str) -> String { let mut out = String::with_capacity(query.len()); for c in query.chars() { @@ -509,16 +403,11 @@ fn regex_escape(query: &str) -> String { out } -/// Test-only convenience over [`url_span_at`]: just the resolved address. #[cfg(test)] pub(super) fn url_at(text: &str, col: usize) -> Option<String> { url_span_at(text, col).map(|(_, _, url)| url) } -/// Detect a link spanning column `col` within a line's text: a bare URL -/// always (see [`url_span_at`]), plus an existing file or directory path when -/// `include_files` — URL detection wins when both would match. `cwd` anchors -/// relative paths and `~` expansion. pub(super) fn link_at( text: &str, col: usize, @@ -537,11 +426,6 @@ pub(super) fn link_at( .flatten() } -/// Detect a bare URL spanning column `col` within a line's text. Splits on -/// whitespace and accepts tokens starting with a known scheme (or `www.`), -/// trimming trailing punctuation that's usually not part of the link. Also -/// reports the inclusive column span `[start, end]` the URL token occupies in -/// `text`, used to underline the exact cells on hover. pub(super) fn url_span_at(text: &str, col: usize) -> Option<(usize, usize, String)> { let chars: Vec<char> = text.chars().collect(); if col >= chars.len() { @@ -550,7 +434,6 @@ pub(super) fn url_span_at(text: &str, col: usize) -> Option<(usize, usize, Strin if chars[col].is_whitespace() { return None; } - // Expand to the surrounding non-whitespace token. let mut start = col; while start > 0 && !chars[start - 1].is_whitespace() { start -= 1; @@ -560,46 +443,21 @@ pub(super) fn url_span_at(text: &str, col: usize) -> Option<(usize, usize, Strin end += 1; } let mut token: String = chars[start..=end].iter().collect(); - // Strip trailing punctuation (see `trim_trailing_punct`) so the underline stops - // where the link does. None of these characters occur inside real URLs. trim_trailing_punct(&mut token); - // A URL is frequently glued to preceding text with no ASCII whitespace: not - // only wrappers like `(`/`[`, but CJK prose and full-width punctuation, e.g. - // `已创建:https://…`. Rather than enumerate every possible prefix, find where a - // known scheme begins inside the token and drop everything before it, advancing - // `start` by the number of (possibly multi-byte) chars removed so the reported - // span still lines up with the cells. const SCHEMES: [&str; 4] = ["https://", "http://", "file://", "ftp://"]; if let Some(off) = SCHEMES.iter().filter_map(|s| token.find(s)).min() { start += token[..off].chars().count(); token.drain(..off); - // A URL can also be glued to *following* prose with no ASCII space, e.g. - // `…/pull/343(fix/… → dev)`, where the full-width `(` opens a parenthetical - // that the whitespace split can't separate. URL characters are all ASCII - // (RFC 3986), so truncate at the first char that can't appear in one — a CJK - // character, full-width bracket, arrow or emoji — which marks where it ends. if let Some(bad) = token.find(|c| !is_url_char(c)) { token.truncate(bad); } - // ASCII `(`/`)` pass the char test (Wikipedia URLs use them), but a closer - // with no matching opener *inside the URL* belongs to the prose around it: - // `(…/pull/43)(Fixes` must end at `43`, not swallow `)(Fixes`. Cut at the - // first unbalanced closer; what survives is balanced, so the trailing trim - // below knows any `)`/`]` still standing is part of the address. truncate_at_unbalanced_close(&mut token); - // Truncating there can re-expose trailing punctuation (`a.com,说明` → `a.com,`). trim_trailing_punct(&mut token); let end = start + token.chars().count() - 1; - // Only resolve when the cursor actually sits on the URL, not on the prefix - // we dropped — for spaceless CJK that prefix can be a whole sentence. return (start..=end).contains(&col).then_some((start, end, token)); } - // No explicit scheme: fall back to a bare `www.` host, trimming the ASCII - // wrappers URLs are commonly parenthesized or quoted with (e.g. `(www.x)`). - // Advance `start` per removed char so the reported span stays aligned; these - // wrappers are ASCII, so `remove(0)` stays on a boundary. while token .chars() .next() @@ -608,9 +466,6 @@ pub(super) fn url_span_at(text: &str, col: usize) -> Option<(usize, usize, Strin token.remove(0); start += 1; } - // Removing the wrappers can orphan their closing halves (`(www.x)` kept its - // `)` through the first trim because the pair looked balanced): trim again - // now that the openers are gone. trim_trailing_punct(&mut token); if token.starts_with("www.") && token.contains('.') { let end = start + token.chars().count() - 1; @@ -636,9 +491,6 @@ fn file_span_at(text: &str, col: usize, cwd: Option<&Path>) -> Option<LinkMatch> location = split_file_location(&token); } - // A `:line` suffix only makes sense for a file — without requiring one, - // `localhost:8080` would link whenever a directory named `localhost` - // happens to exist in the cwd. let path = resolve_existing_path(&location.path, cwd, location.line.is_some())?; (start..=end).contains(&col).then_some(LinkMatch { start, @@ -804,12 +656,6 @@ fn home_from_cwd(_cwd: &Path) -> Option<PathBuf> { None } -/// Trim trailing punctuation a URL gets glued to in prose — `.,;:'"` and `>` plus -/// their full-width / CJK counterparts — so the link stops where the address does. -/// None of these characters occur at the end of a real URL. ASCII `)` and `]` *can* -/// (`…/Rust_(programming_language)`), so those are stripped only while unmatched -/// within the token — a closer with an opener earlier in the token is part of the -/// address (or of a wrapper pair the leading-strip will remove), not glue. fn trim_trailing_punct(token: &mut String) { loop { let strip = match token.chars().next_back() { @@ -832,10 +678,6 @@ fn count_char(s: &str, needle: char) -> usize { s.chars().filter(|&c| c == needle).count() } -/// Cut `token` at the first ASCII `)` or `]` that has no matching opener before it -/// in the token. Balanced pairs — legal and common in URLs — survive; the first -/// orphan closer marks where surrounding prose (`(url)(more…`, `[see url] next`) -/// takes over. Parens and brackets balance independently, each as a plain counter. fn truncate_at_unbalanced_close(token: &mut String) { let mut parens = 0usize; let mut brackets = 0usize; @@ -858,9 +700,6 @@ fn truncate_at_unbalanced_close(token: &mut String) { } } -/// Whether `c` may appear inside a URL per RFC 3986 (unreserved + reserved + `%`). -/// Every such character is ASCII, so any CJK character, full-width bracket, arrow or -/// emoji is rejected — which is what lets a URL be cut off from trailing CJK prose. pub(super) fn is_url_char(c: char) -> bool { c.is_ascii_alphanumeric() || matches!( @@ -896,18 +735,15 @@ mod tests { #[test] fn regex_escape_neutralizes_metacharacters() { - // A literal query for regex metacharacters must match them verbatim. assert_eq!(regex_escape("a.b*c"), r"a\.b\*c"); assert_eq!(regex_escape("foo(bar)"), r"foo\(bar\)"); assert_eq!(regex_escape("1+1=2"), r"1\+1=2"); - // Plain alphanumerics are left untouched. assert_eq!(regex_escape("hello"), "hello"); } #[test] fn url_at_detects_http_and_strips_trailing_punct() { let line = "go https://example.com, now"; - // A column anywhere inside the URL token resolves the whole URL. assert_eq!(url_at(line, 6).as_deref(), Some("https://example.com")); } @@ -918,14 +754,13 @@ mod tests { Some("https://www.rust-lang.org") ); assert_eq!(url_at("just a word", 6), None); - assert_eq!(url_at("word ", 4), None); // whitespace cell - assert_eq!(url_at("word", 99), None); // out of range + assert_eq!(url_at("word ", 4), None); + assert_eq!(url_at("word", 99), None); } #[test] fn url_span_at_reports_inclusive_columns_without_trailing_punct() { let line = "go https://example.com, now"; - // The URL occupies columns 3..=21; the trailing comma is excluded. assert_eq!( url_span_at(line, 10), Some((3, 21, "https://example.com".to_string())) @@ -947,8 +782,6 @@ mod tests { #[test] fn url_span_at_strips_various_trailing_punctuation() { - // Only *trailing* punctuation is trimmed (the token must still start with a - // scheme): closing bracket, angle bracket, quote, colon and semicolon. assert_eq!( url_at("open https://a.com] done", 7).as_deref(), Some("https://a.com") @@ -957,7 +790,6 @@ mod tests { url_at("open https://a.com> done", 7).as_deref(), Some("https://a.com") ); - // A run of mixed trailing punctuation is all trimmed. assert_eq!( url_at("open https://a.com';: done", 7).as_deref(), Some("https://a.com") @@ -966,8 +798,6 @@ mod tests { #[test] fn url_span_at_strips_leading_wrappers() { - // Parenthesized / bracketed / angle-bracketed / quoted URLs are common in - // prose and logs; the leading wrapper must be trimmed so the link resolves. assert_eq!( url_at("see (https://a.com) ok", 8).as_deref(), Some("https://a.com") @@ -984,7 +814,6 @@ mod tests { url_at("say \"https://a.com\" ok", 8).as_deref(), Some("https://a.com") ); - // A bare www. wrapped in parens is still promoted to https. assert_eq!( url_at("(www.rust-lang.org)", 5).as_deref(), Some("https://www.rust-lang.org") @@ -993,57 +822,42 @@ mod tests { #[test] fn url_span_at_reports_trimmed_span_after_stripping_both_ends() { - // The reported inclusive span must cover only the URL cells, excluding both - // the leading `[` and the trailing `]`. let line = "log [https://a.com] end"; let (start, end, url) = url_span_at(line, 8).expect("URL inside the brackets"); assert_eq!(url, "https://a.com"); assert_eq!(&line[start..=end], "https://a.com"); - // The bracket cells sit just outside the reported span. assert_eq!(&line[start - 1..start], "["); assert_eq!(&line[end + 1..end + 2], "]"); } #[test] fn url_at_detects_url_glued_to_cjk_prefix() { - // Regression: a URL glued to CJK prose + a full-width colon, with no ASCII - // whitespace between them (`PR 已创建:https://…`). The scheme is found - // inside the token and the prefix dropped, so the link still resolves. let url = "https://github.com/acme/app/pull/42"; let line = format!("已创建:{url}"); - // Column of the `h` in `https` (after the 3 hanzi + full-width colon). let scheme_col = 4; assert_eq!(url_at(&line, scheme_col).as_deref(), Some(url)); - // Hovering deeper inside the URL resolves it too. assert_eq!(url_at(&line, 12).as_deref(), Some(url)); - // The reported span starts at the scheme, excluding the `已创建:` prefix. let (start, end, got) = url_span_at(&line, scheme_col).expect("URL after prefix"); assert_eq!(start, scheme_col); assert_eq!(got, url); assert_eq!(end, line.chars().count() - 1); - // Same shape but with a half-width ASCII colon, and the URL mid-line - // followed by more text after a space (`… 42 🎉收尾:…`): the token ends at - // the space, so the trailing emoji/prose never leaks into the link. let row = format!("PR 已创建:{url} 🎉收尾:删除临时"); let h = row.chars().position(|c| c == 'h').expect("scheme start"); assert_eq!(url_at(&row, h).as_deref(), Some(url)); - assert_eq!(url_at(&row, 0), None); // on `P` of the `PR ` label + assert_eq!(url_at(&row, 0), None); } #[test] fn url_at_ignores_hover_on_cjk_prefix_before_url() { - // Hovering over the prose that precedes the URL must not underline / open - // the link — only cells on the URL itself count. let line = "已创建:https://a.com"; - assert_eq!(url_at(line, 0), None); // on `已` - assert_eq!(url_at(line, 3), None); // on the full-width colon - assert_eq!(url_at(line, 4).as_deref(), Some("https://a.com")); // on `h` + assert_eq!(url_at(line, 0), None); + assert_eq!(url_at(line, 3), None); + assert_eq!(url_at(line, 4).as_deref(), Some("https://a.com")); } #[test] fn url_at_strips_full_width_trailing_punctuation() { - // A URL closed by a full-width bracket or stop in CJK prose keeps neither. assert_eq!( url_at("见(https://a.com)", 3).as_deref(), Some("https://a.com") @@ -1056,40 +870,26 @@ mod tests { #[test] fn url_at_stops_at_full_width_open_bracket_glued_after_url() { - // Regression: a URL immediately followed by a full-width `(parenthetical)` - // with no ASCII space — `…/pull/343(fix/… → dev)`. The `(` is not - // whitespace, so the token runs past the URL into the bracket; truncating at - // the first non-URL char keeps only the address. let url = "https://github.com/acme/app/pull/343"; let line = format!("PR 已创建:{url}(fix/cache-write-tokens → dev)"); let h = line.chars().position(|c| c == 'h').expect("scheme start"); assert_eq!(url_at(&line, h).as_deref(), Some(url)); - // Hovering deeper inside the URL resolves the same span, sans bracket. let (start, end, got) = url_span_at(&line, h + 10).expect("URL before bracket"); assert_eq!(got, url); assert_eq!(start, h); assert_eq!(line.chars().nth(end + 1), Some('(')); - // Hovering on the parenthetical text after the URL is not a link. let f = line.chars().position(|c| c == 'f').expect("`fix` start"); assert_eq!(url_at(&line, f), None); } #[test] fn url_at_keeps_ascii_parens_inside_a_url() { - // ASCII `(`/`)` are valid URL characters (e.g. Wikipedia), so a pair in the - // middle of the path must survive — the non-URL-char truncation only fires on - // a full-width bracket, never an ASCII one. let url = "https://en.wikipedia.org/wiki/Rust_(programming_language)/history"; assert_eq!(url_at(url, 40).as_deref(), Some(url)); - // A *trailing* balanced pair survives too: the closer has its opener inside - // the URL, so it is part of the address, not prose glue. let url = "https://en.wikipedia.org/wiki/Rust_(programming_language)"; assert_eq!(url_at(url, 40).as_deref(), Some(url)); - // Even when that URL is itself parenthesized: the wrapper pair is stripped, - // the URL's own pair is kept. let line = format!("see ({url}) ok"); assert_eq!(url_at(&line, 8).as_deref(), Some(url)); - // IPv6 literals keep their brackets the same way. let url = "http://[::1]:8080/status"; let line = format!("probe [{url}] done"); assert_eq!(url_at(&line, 10).as_deref(), Some(url)); @@ -1097,23 +897,16 @@ mod tests { #[test] fn url_at_stops_at_unbalanced_close_paren_glued_after_url() { - // Regression: `#43 (https://…/pull/43)(Fixes #42),分支 …` — the token runs - // `(url)(Fixes` with no space, every char is URL-legal, and the link used to - // swallow `)(Fixes`. The first `)` has no opener inside the URL (the `(` - // before the scheme was dropped with the prefix), so the link ends at `43`. let url = "https://github.com/l0ng-ai/tty7/pull/43"; let line = format!("PR 已开:#43 ({url})(Fixes #42),分支 fix-x。"); let h = line.chars().position(|c| c == 'h').expect("scheme start"); assert_eq!(url_at(&line, h).as_deref(), Some(url)); - // The span covers exactly the URL: the wrapping `(` sits before it, the - // `)(Fixes` glue after it, and hovering the glue is not a link. let (start, end, got) = url_span_at(&line, h + 10).expect("URL inside parens"); assert_eq!(got, url); assert_eq!(line.chars().nth(start - 1), Some('(')); assert_eq!(line.chars().nth(end + 1), Some(')')); let f = line.chars().position(|c| c == 'F').expect("`Fixes` start"); assert_eq!(url_at(&line, f), None); - // Same for an orphan `]`: `[see https://a.com/x] next` glued without spaces. assert_eq!( url_at("read https://a.com/x]next now", 8).as_deref(), Some("https://a.com/x") @@ -1122,11 +915,8 @@ mod tests { #[test] fn url_span_at_rejects_www_without_a_dot_and_empty_tokens() { - // "www" alone (no extra dot after stripping) is not promoted. assert_eq!(url_at("www near text", 1), None); - // A token that is entirely trailing punctuation shrinks to empty → None. assert_eq!(url_at("...", 1), None); - // A plain word starting like a scheme but not one. assert_eq!(url_at("httpsomething", 3), None); } @@ -1264,17 +1054,12 @@ mod tests { LinkTarget::Url(url) => panic!("expected directory link, got URL {url}"), } - // `ls -p` style trailing slash resolves too. assert!(link_at("ls dircase/nested/ done", 5, Some(cwd), true).is_some()); - // Off without the modifier, like files. assert!(link_at("artifacts in dircase/nested here", 14, Some(cwd), false).is_none()); } #[test] fn link_at_requires_a_file_when_a_line_suffix_is_present() { - // `localhost:8080` must not become a link just because a directory - // named `localhost` exists in the cwd — `:line` only makes sense for - // files. let file = temp_file("localhost/keep.txt"); let cwd = file.parent().and_then(Path::parent).unwrap(); @@ -1282,7 +1067,6 @@ mod tests { link_at("listening on localhost:8080", 15, Some(cwd), true), None ); - // The bare directory still links. assert!(link_at("listening on localhost", 15, Some(cwd), true).is_some()); } } diff --git a/src/terminal/signature.rs b/src/terminal/signature.rs index 86b77bef..47de0709 100644 --- a/src/terminal/signature.rs +++ b/src/terminal/signature.rs @@ -1,32 +1,9 @@ -//! Per-command completion signatures — tty7's take on rich command -//! signatures (built on Fig's autocomplete specs). -//! -//! The data is generated offline from Fig's MIT-licensed spec corpus by -//! `scripts/fig-convert/convert.mjs`, which executes each compiled spec and -//! snapshots its *static* shape (subcommands, options, args, descriptions, -//! static generator `script`s) into `assets/completions/<cmd>.json`. This module -//! is only the runtime consumer: a serde model plus a per-command **lazy, -//! memoized registry** — a command's JSON is parsed the first time it's typed -//! and cached for the session. -//! -//! Specs are read from an on-disk `completions/` directory rather than embedded -//! in the binary, so the corpus can grow (or a user can drop in their own specs) -//! without a recompile and without bloating the executable. [`spec_source`] -//! resolves that directory across the shapes tty7 runs in — a packaged bundle, -//! an unpackaged binary, `cargo run`, and tests — plus an optional user override -//! under the config dir; see its docs for the search order. The lookup only ever -//! maps a bare command name to `<dir>/<cmd>.json`, so a typed token can't escape -//! the completions dir. - use std::collections::HashMap; use std::path::PathBuf; use std::sync::{Arc, Mutex, OnceLock}; use serde::Deserialize; -/// A command's completion signature (the JSON root). Shares the `options` / -/// `args` / `subcommands` shape with [`Subcommand`] via the [`CmdNode`] trait so -/// the argv walk can treat the root and any nested subcommand uniformly. #[derive(Debug, Deserialize)] pub struct Signature { #[allow(dead_code)] @@ -42,8 +19,6 @@ pub struct Signature { pub subcommands: Vec<Subcommand>, } -/// A subcommand node — the same fields as [`Signature`] but carrying its own -/// aliases (`names`) and a `hidden` flag we keep out of the menu. #[derive(Debug, Deserialize)] pub struct Subcommand { #[serde(default)] @@ -52,8 +27,6 @@ pub struct Subcommand { pub description: Option<String>, #[serde(default)] pub hidden: bool, - /// A per-entry icon from the Fig spec: an emoji, a `fig://icon?type=…` - /// template, or a `fig://template?…`. The menu renderer interprets it. #[serde(default)] pub icon: Option<String>, #[serde(default)] @@ -64,8 +37,6 @@ pub struct Subcommand { pub subcommands: Vec<Subcommand>, } -/// A flag / option. `names` holds every spelling (`["-m", "--message"]`); a -/// non-empty `args` means the option takes a value. #[derive(Debug, Deserialize)] pub struct Opt { #[serde(default)] @@ -82,23 +53,16 @@ pub struct Opt { pub repeatable: bool, #[serde(default)] pub hidden: bool, - /// Per-option icon from the Fig spec (see [`Subcommand::icon`]). #[serde(default)] pub icon: Option<String>, } impl Opt { - /// Whether this option consumes a following value token. pub fn takes_arg(&self) -> bool { !self.args.is_empty() } } -/// A positional / value argument. `template` mirrors Fig's `"filepaths"` / -/// `"folders"` (→ tty7's path completion); `suggestions` is a static candidate -/// list; `generators` holds the *static* shell `script`s whose stdout becomes -/// candidates — the completion engine collects them and the view runs them -/// asynchronously (see [`super::generator`]). #[derive(Debug, Deserialize)] pub struct Arg { #[allow(dead_code)] @@ -119,62 +83,45 @@ pub struct Arg { } impl Arg { - /// Whether this arg wants filesystem completion (Fig `filepaths`/`folders`). pub fn wants_paths(&self) -> bool { self.template .iter() .any(|t| t == "filepaths" || t == "folders") } - /// Whether this arg's filesystem completion is directories only — a Fig - /// `folders` template with no `filepaths` alongside it. pub fn wants_dirs_only(&self) -> bool { self.template.iter().any(|t| t == "folders") && !self.template.iter().any(|t| t == "filepaths") } } -/// A static value suggestion for an argument. #[derive(Debug, Deserialize)] pub struct Suggestion { #[serde(default)] pub names: Vec<String>, #[serde(default)] pub description: Option<String>, - /// Per-suggestion icon from the Fig spec (see [`Subcommand::icon`]). #[serde(default)] pub icon: Option<String>, } -/// A dynamic-value generator, reduced to its static shell `script` (the JS -/// `postProcess` is dropped at conversion time; tty7 defaults to -/// one-suggestion-per-line, overridable per script — see [`super::generator`]). -/// The tokens are joined with single spaces and re-parsed by `/bin/sh -c`, since -/// the converter word-split original string scripts (so `bash -c "…"` entries -/// only survive re-joining). #[derive(Debug, Deserialize)] pub struct Generator { #[serde(default)] pub script: Vec<String>, } -/// Uniform read access to a command node's children, so the argv walk in -/// `completion` can start at the [`Signature`] root and descend into -/// [`Subcommand`]s without special-casing. pub trait CmdNode { fn subcommands(&self) -> &[Subcommand]; fn options(&self) -> &[Opt]; fn args(&self) -> &[Arg]; - /// The subcommand whose name/alias equals `token`, if any. fn find_subcommand(&self, token: &str) -> Option<&Subcommand> { self.subcommands() .iter() .find(|s| s.names.iter().any(|n| n == token)) } - /// The option matching a flag token (`--message`, `-m`); the token is - /// compared after stripping any `=value` suffix. fn find_option(&self, token: &str) -> Option<&Opt> { let flag = token.split('=').next().unwrap_or(token); self.options() @@ -207,17 +154,6 @@ impl CmdNode for Subcommand { } } -/// The directories searched for `<cmd>.json`, most-specific first, resolved once. -/// -/// Order (first hit wins, so earlier entries override later ones): -/// 1. `$TTY7_COMPLETIONS_DIR` — explicit override for dev / testing. -/// 2. `<config-dir>/completions` — user-supplied specs (mirrors how the rest of -/// tty7 lets `~/.config/tty7` override built-ins). -/// 3. bundle/executable-relative — where each packaging script installs the -/// specs: `../Resources/completions` inside a macOS `.app`, or a -/// `completions/` dir beside the executable on Linux/Windows. -/// 4. the in-tree `assets/completions` — the `cargo run` / test fallback, -/// baked in via `CARGO_MANIFEST_DIR` so an unpackaged run still finds specs. fn spec_source() -> &'static [PathBuf] { static DIRS: OnceLock<Vec<PathBuf>> = OnceLock::new(); DIRS.get_or_init(|| { @@ -230,8 +166,8 @@ fn spec_source() -> &'static [PathBuf] { } if let Ok(exe) = std::env::current_exe() { if let Some(dir) = exe.parent() { - dirs.push(dir.join("../Resources/completions")); // macOS .app - dirs.push(dir.join("completions")); // Linux / Windows sibling + dirs.push(dir.join("../Resources/completions")); + dirs.push(dir.join("completions")); } } dirs.push(PathBuf::from(concat!( @@ -242,11 +178,6 @@ fn spec_source() -> &'static [PathBuf] { }) } -/// Read the raw JSON for `cmd` from the first [`spec_source`] dir that has it. -/// -/// `cmd` maps to the bare filename `<cmd>.json`; anything that isn't a plain -/// command token (letters, digits, and `._+-`) is rejected up front so a typed -/// token can never contain a path separator or `..` and read outside the dir. fn raw_spec(cmd: &str) -> Option<String> { if cmd.is_empty() || !cmd @@ -261,8 +192,6 @@ fn raw_spec(cmd: &str) -> Option<String> { .find_map(|dir| std::fs::read_to_string(dir.join(&file)).ok()) } -/// The parse cache: `None` marks a command we've looked up and have no (or -/// unparseable) signature for, so a miss is memoized too. type Registry = Mutex<HashMap<String, Option<Arc<Signature>>>>; fn registry() -> &'static Registry { @@ -270,17 +199,10 @@ fn registry() -> &'static Registry { REGISTRY.get_or_init(|| Mutex::new(HashMap::new())) } -/// The signature for `cmd`, parsed lazily on first use and memoized (hit or -/// miss). Returns `None` for commands outside the embedded corpus or whose JSON -/// fails to parse — callers fall back to generic completion. pub fn signature(cmd: &str) -> Option<Arc<Signature>> { - // Fast path: return the memoized result (hit or miss) without touching disk. if let Some(cached) = registry().lock().unwrap().get(cmd) { return cached.clone(); } - // Read + parse off-lock so filesystem IO never blocks another lookup. A - // concurrent miss may load the same spec twice; that's idempotent, and the - // insert below just re-publishes the same value. let parsed = raw_spec(cmd).and_then(|raw| match serde_json::from_str::<Signature>(&raw) { Ok(sig) => Some(Arc::new(sig)), Err(e) => { @@ -304,7 +226,6 @@ mod tests { let sig = signature("git").expect("git spec on disk"); assert_eq!(sig.name, "git"); assert!(sig.subcommands.len() > 20, "git has many subcommands"); - // Second call returns the same cached Arc. let again = signature("git").unwrap(); assert!(Arc::ptr_eq(&sig, &again)); } @@ -312,7 +233,6 @@ mod tests { #[test] fn docker_loadspec_grafted_compose() { let sig = signature("docker").expect("docker spec on disk"); - // `docker compose` was grafted from the docker-compose spec via loadSpec. let compose = sig .find_subcommand("compose") .expect("compose subcommand present"); @@ -336,9 +256,6 @@ mod tests { assert!(signature("definitely-not-a-real-cmd-xyz").is_none()); } - /// Every spec that ships in-tree must parse into the serde model — a - /// malformed one should fail here (at CI time) rather than silently - /// degrading to generic completion on a user's machine. #[test] fn every_shipped_spec_parses() { let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/completions"); @@ -359,8 +276,6 @@ mod tests { ); } - /// A typed token that isn't a bare command name must never read a file — - /// path separators and `..` are rejected before touching the filesystem. #[test] fn raw_spec_rejects_path_traversal() { assert!(raw_spec("git").is_some()); diff --git a/src/terminal/size.rs b/src/terminal/size.rs index e634f2b4..24beeece 100644 --- a/src/terminal/size.rs +++ b/src/terminal/size.rs @@ -1,13 +1,5 @@ -//! `TermSize`: the fixed grid dimensions handed to the VT emulator and the PTY. -//! -//! This used to live alongside an in-process PTY-backed `Terminal` here, but the -//! PTY now lives in the daemon (`daemon::pane`) and the GUI talks to it through -//! `terminal::remote::RemoteTerminal`. All that survives on the client side is -//! this size type, shared by the remote terminal and the view. - use alacritty_terminal::grid::Dimensions; -/// Fixed dimensions handed to `Term` / `Term::resize`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct TermSize { pub cols: usize, diff --git a/src/terminal/smart_select.rs b/src/terminal/smart_select.rs index 410eb26f..0c39d42f 100644 --- a/src/terminal/smart_select.rs +++ b/src/terminal/smart_select.rs @@ -1,14 +1,3 @@ -//! Double-click smart selection (à la iTerm2): when a double-click's -//! plain word selection sits inside a larger semantic object — a URL, an -//! email address, a file path, a matching bracket pair, or an OSC 8 -//! hyperlink — expand the selection to cover the whole object. -//! -//! The expansion is strictly additive: a candidate is only applied when it -//! *contains* the plain word the double-click would have selected, so the -//! feature can never shrink a selection below what alacritty's semantic -//! (word) selection yields. With no candidate the caller falls back to the -//! stock `SelectionType::Semantic` behavior unchanged. - use std::sync::OnceLock; use alacritty_terminal::event::EventListener; @@ -18,18 +7,10 @@ use alacritty_terminal::term::Term; use alacritty_terminal::term::cell::Flags; use regex::Regex; -/// How many soft-wrapped rows to join on each side of the clicked row when -/// reconstructing the logical line. Caps the text a pathological fully-wrapped -/// scrollback line (minified JS piped to `cat`) can feed the regexes. const MAX_WRAP_ROWS: usize = 32; -/// How many chars around the click offset the regex window covers on each -/// side. Matches never straddle real whitespace anyway, so a bounded window -/// only drops matches on absurdly long unbroken runs. const MATCH_WINDOW: usize = 2000; -/// Bracket pairs a double-click on either half expands across (with -/// nesting): the ASCII pairs plus the full-width/CJK ones. const BRACKET_PAIRS: [(char, char); 15] = [ ('(', ')'), ('[', ']'), @@ -48,41 +29,22 @@ const BRACKET_PAIRS: [(char, char); 15] = [ ('‘', '’'), ]; -/// Symmetric quotes: open and close are the same char, so pairing needs the -/// parity heuristic in [`quote_range`] instead of the bracket scan. const SYMMETRIC_QUOTES: [char; 3] = ['\'', '"', '`']; -/// A resolved smart selection: an inclusive grid-point span, plus whether the -/// span is `exact`. Exact spans have endpoints that may sit mid-word-run (CJK -/// prose, a candidate glued to non-separator text), so the caller must select -/// them with `SelectionType::Simple` — a `Semantic` anchor would re-expand the -/// endpoints across the very boundary the smart range established. Non-exact -/// spans end on run boundaries and can keep `Semantic` for word-wise dragging. pub(super) struct SmartRange { pub start: Point, pub end: Point, pub exact: bool, } -/// Resolve a smart selection range for a double-click at `click` (grid -/// coordinates). `None` means "no candidate beats the plain word" and the -/// caller should keep the stock semantic selection. pub(super) fn grid_smart_range<T: EventListener>( term: &Term<T>, click: Point, ) -> Option<SmartRange> { - // 0) The click carries the geometry of the frame that dispatched it, and - // the grid can shrink out from under it (a split, a window drag, a - // replayed attach size landing on the reader thread). Both walks below - // index `grid[click.line]` straight away, and `Grid`'s `Index<Line>` - // only `debug_assert`s the bound — a release build walks off the - // storage. Same guard, same reason, as `TerminalView::grid_line`. if click.line < term.topmost_line() || click.line > term.bottommost_line() { return None; } - // 1) An explicit OSC 8 hyperlink run wins outright — the program told us - // the exact extent, no guessing needed. if let Some((start, end)) = hyperlink_run(term, click) { return Some(SmartRange { start, @@ -94,10 +56,6 @@ pub(super) fn grid_smart_range<T: EventListener>( let (text, points, click_idx) = logical_line_at(term, click, false)?; let chars: Vec<char> = text.chars().collect(); let separators = term.semantic_escape_chars(); - // A span whose flanks are separator chars ends exactly where alacritty's - // semantic re-expansion would stop anyway; anything else must stay exact. - // Only the separator set counts here — alacritty stops at nothing else, - // so a flank of e.g. U+3000 ideographic space would still re-expand. let resolved = |s: usize, e: usize| SmartRange { start: points[s], end: points[e], @@ -105,66 +63,48 @@ pub(super) fn grid_smart_range<T: EventListener>( || !(e + 1 == chars.len() || separators.contains(chars[e + 1])), }; - // 2) Double-click on a bracket or quote selects through its match. if let Some((s, e)) = pair_range(&chars, click_idx) { return Some(resolved(s, e)); } - // 3) CJK prose has no separators to walk — the whole clause is one run — - // so segment it with a dictionary instead of selecting the entire - // unbroken run. No segmenter available means the run stands as-is. if is_cjk(chars[click_idx]) && let Some((s, e)) = cjk_word_range(&text, click_idx) { return Some(resolved(s, e)); } - // 4) URL / email / path / identifier patterns around the click. let (s, e) = smart_range(&text, &chars, click_idx, separators)?; Some(resolved(s, e)) } -/// Whether a char belongs to a CJK script (Han, Kana, Hangul, or the -/// full-width/CJK punctuation blocks) — text whose words aren't delimited by -/// whitespace or the separator set. pub(super) fn is_cjk(c: char) -> bool { matches!( u32::from(c), - 0x1100..=0x11FF // Hangul Jamo - | 0x2E80..=0x9FFF // CJK radicals, punctuation, Kana, ideographs - | 0xAC00..=0xD7AF // Hangul syllables - | 0xF900..=0xFAFF // CJK compatibility ideographs - | 0xFF00..=0xFFEF // full-width forms - | 0x20000..=0x3134F // ideograph extensions + 0x1100..=0x11FF + | 0x2E80..=0x9FFF + | 0xAC00..=0xD7AF + | 0xF900..=0xFAFF + | 0xFF00..=0xFFEF + | 0x20000..=0x3134F ) } -/// Kana or Hangul — the scripts jieba has no dictionary for. A run holding -/// either is left unsegmented rather than handed to jieba, which shreds it -/// into single characters (`です` → `で` `す`); selecting the whole run is the -/// friendlier failure. #[cfg(not(target_os = "macos"))] fn is_kana_or_hangul(c: char) -> bool { matches!( u32::from(c), - 0x1100..=0x11FF // Hangul Jamo - | 0x3040..=0x30FF // Hiragana + Katakana - | 0x31F0..=0x31FF // Katakana phonetic extensions - | 0xA960..=0xA97F // Hangul Jamo Extended-A - | 0xAC00..=0xD7FF // Hangul syllables + Jamo Extended-B - | 0xFF66..=0xFF9F // half-width Katakana + 0x1100..=0x11FF + | 0x3040..=0x30FF + | 0x31F0..=0x31FF + | 0xA960..=0xA97F + | 0xAC00..=0xD7FF + | 0xFF66..=0xFF9F ) } -/// The jieba segmenter, built once on a background thread. The table costs -/// ~55 MB resident and ~130 ms to build, so it is constructed only if a CJK -/// double-click actually happens — see [`jieba_word_range`]. #[cfg(not(target_os = "macos"))] static JIEBA: OnceLock<jieba_rs::Jieba> = OnceLock::new(); -/// Kick off dictionary construction on a background thread (idempotent). -/// Never called eagerly: the first CJK double-click triggers it and settles -/// for the unsegmented run, so the UI thread never blocks on the build. #[cfg(not(target_os = "macos"))] fn warm() { static ONCE: std::sync::Once = std::sync::Once::new(); @@ -175,13 +115,6 @@ fn warm() { }); } -/// Dictionary-based word bounds for CJK text: the inclusive char range of the -/// word containing char index `click`, or `None` to keep the whole run. -/// -/// The OS tokenizer wins wherever there is one. macOS's CFStringTokenizer -/// carries a Chinese lexicon that matches jieba on most prose, is locale- -/// independent, handles Japanese and Korean properly, and costs nothing — -/// jieba is only worth its ~55 MB on platforms with no such API. pub(super) fn cjk_word_range(text: &str, click: usize) -> Option<(usize, usize)> { #[cfg(target_os = "macos")] { @@ -195,9 +128,6 @@ pub(super) fn cjk_word_range(text: &str, click: usize) -> Option<(usize, usize)> } } -/// Segment the contiguous CJK run around `click` with jieba and return the -/// token containing it. `None` — meaning "select the whole run" — when the -/// dictionary isn't built yet or the run isn't Chinese. #[cfg(not(target_os = "macos"))] fn jieba_word_range(chars: &[char], click: usize) -> Option<(usize, usize)> { let mut rs = click; @@ -208,20 +138,14 @@ fn jieba_word_range(chars: &[char], click: usize) -> Option<(usize, usize)> { while re + 1 < chars.len() && is_cjk(chars[re + 1]) { re += 1; } - // Japanese/Korean: jieba's Chinese dictionary would cut the run into - // single characters, which is worse than not segmenting at all. if chars[rs..=re].iter().copied().any(is_kana_or_hangul) { return None; } - // Building the table takes ~130 ms — far too long to hold the UI thread - // on a click. Start it in the background and let this one click select - // the whole run; every later click finds the table ready. let Some(jieba) = JIEBA.get() else { warm(); return None; }; let run: String = chars[rs..=re].iter().collect(); - // Token start/end are Unicode char offsets into `run`. let rel = click - rs; jieba .cut(&run, true) @@ -230,8 +154,6 @@ fn jieba_word_range(chars: &[char], click: usize) -> Option<(usize, usize)> { .map(|tok| (rs + tok.start, rs + tok.end - 1)) } -/// CFStringTokenizer FFI. The tokenizer functions aren't wrapped by the -/// `core-foundation` crate, so declare them directly against its types. #[cfg(target_os = "macos")] mod tokenizer { use core_foundation::base::{CFIndex, CFRange, TCFType}; @@ -241,9 +163,6 @@ mod tokenizer { type CFStringTokenizerRef = *mut c_void; type CFLocaleRef = *const c_void; - /// `kCFStringTokenizerUnitWordBoundary`: every position belongs to a - /// token (words, punctuation runs, whitespace runs alike), which is the - /// double-click contract. const UNIT_WORD_BOUNDARY: u64 = 4; unsafe extern "C" { @@ -263,9 +182,6 @@ mod tokenizer { fn CFRelease(cf: *const c_void); } - /// Inclusive char range of the token containing char index `click`. - /// CFString ranges are UTF-16 code-unit offsets, so map through a - /// per-char offset table both ways. pub(super) fn word_range(text: &str, click: usize) -> Option<(usize, usize)> { let mut u16_of: Vec<CFIndex> = Vec::new(); let mut total: CFIndex = 0; @@ -302,9 +218,6 @@ mod tokenizer { } } -/// The contiguous run of cells carrying the same OSC 8 hyperlink URI as the -/// clicked cell, following soft wraps in both directions (a long link wraps -/// across rows; stopping at the row edge would truncate the selection). pub(super) fn hyperlink_run<T: EventListener>( term: &Term<T>, click: Point, @@ -362,17 +275,6 @@ pub(super) fn hyperlink_run<T: EventListener>( Some((start, end)) } -/// Reconstruct the logical (soft-wrap-joined) line containing `click`: -/// the text with wide-char spacers dropped, a per-char grid point, and the -/// char index the click landed on. `None` when the click maps to no char -/// (out-of-bounds column). -/// -/// When `bridge_hard_wrap` is set, rows are also joined across a *producer* -/// hard newline (no `WRAPLINE` flag) when the row is filled to the right edge -/// with a link char that continues into the first column of the next row. This -/// lets link resolution recover a URL a printing program split with a literal -/// `\n`, while double-click smart-select (which passes `false`) keeps its -/// word/semantic boundaries and never glues separate output lines together. pub(super) fn logical_line_at<T: EventListener>( term: &Term<T>, click: Point, @@ -387,27 +289,8 @@ pub(super) fn logical_line_at<T: EventListener>( let top = term.topmost_line(); let bottom = term.bottommost_line(); let wraps = |line: Line| grid[line][last_col].flags.contains(Flags::WRAPLINE); - // A hard bridge joins `line` to `line + 1` when the row is full to the - // right edge with a link char and the next row opens with one too, which - // rules out gluing an ordinary short line onto the following paragraph. - // - // It cannot rule out the converse: a hard newline carries no signal about - // whether the producer split a URL, so a *complete* URL that happens to end - // exactly at the right edge is bridged onto whatever the next row starts - // with (`…/a` + `README.md` resolves as `…/aREADME.md`). There is no - // reliable test for that — the head of a genuinely split URL is itself a - // valid URL — so we accept the false positive: the address bar shows the - // mistake and the user is one glance from spotting it. - // - // What we do not accept is the same accident promoting the *second* row to - // the authority. `https://good.com` + `@evil.com/x` parses as userinfo, so - // the real host becomes `evil.com` while the underline still reads - // `good.com` — a phishing hop wearing a trusted label. Never bridge into - // one. let is_link_char = |c: char| super::search::is_url_char(c); let hard = |line: Line| { - // `line < bottom` must stay ahead of the `line + 1` lookup — the last - // grid line has no successor to index. bridge_hard_wrap && line < bottom && is_link_char(grid[line][last_col].c) && { let next = grid[Line(line.0 + 1)][Column(0)].c; is_link_char(next) && next != '@' @@ -436,10 +319,6 @@ pub(super) fn logical_line_at<T: EventListener>( for col in 0..cols { let cell = &grid[line][Column(col)]; let p = Point::new(line, Column(col)); - // Spacer cells pad wide (CJK/emoji) glyphs. A trailing spacer - // follows its wide char; a leading spacer pads the end of a row - // whose wide char wrapped to the next row, so it belongs to the - // *next* pushed char. if cell.flags.contains(Flags::LEADING_WIDE_CHAR_SPACER) { if p == click { click_idx = Some(points.len()); @@ -460,24 +339,14 @@ pub(super) fn logical_line_at<T: EventListener>( } line += 1; } - // A leading spacer at the very end of the collected range can point one - // past the last char; treat that as no hit. let click_idx = click_idx.filter(|&i| i < points.len())?; Some((text, points, click_idx)) } -/// Double-click on a paired delimiter — bracket or quote — selects through -/// its match. `None` when the clicked char is neither, or has no match on -/// the logical line. pub(super) fn pair_range(chars: &[char], click: usize) -> Option<(usize, usize)> { bracket_range(chars, click).or_else(|| quote_range(chars, click)) } -/// Whether the `'` at `i` is a contraction apostrophe rather than a quote. -/// -/// A delimiter has whitespace, punctuation, or a line edge on at least one -/// side; a contraction is welded into a word on both (`it's`, `isn't`, -/// `won't`). Only `'` needs this — `"` and `` ` `` don't appear inside words. fn is_contraction(chars: &[char], i: usize) -> bool { if chars[i] != '\'' { return false; @@ -489,17 +358,6 @@ fn is_contraction(chars: &[char], i: usize) -> bool { flanked(i.checked_sub(1)) && flanked(Some(i + 1)) } -/// Select through a matching symmetric quote (`'`, `"`, `` ` ``). Open and -/// close are the same char, so direction comes from parity: an even count of -/// that quote before the click means it opens (match forward), odd means it -/// closes (match backward). -/// -/// Contraction apostrophes are excluded throughout — clicking one falls -/// through to the stock word, and they count neither toward the parity nor as -/// a candidate match. Without that, `it's a test, isn't it` pairs the two -/// contractions and a double-click on either selects `'s a test, isn'`. This -/// path returns before the `extends` guard that keeps other candidates -/// additive (see [`pair_is_plausible`]), so a bad match here has no safety net. pub(super) fn quote_range(chars: &[char], click: usize) -> Option<(usize, usize)> { let q = *chars.get(click)?; if !SYMMETRIC_QUOTES.contains(&q) || is_contraction(chars, click) { @@ -516,18 +374,6 @@ pub(super) fn quote_range(chars: &[char], click: usize) -> Option<(usize, usize) } } -/// Whether a candidate span is an acceptable match for its bracket pair. -/// -/// Every pair but `<>` is accepted outright — `( a )` is a legitimate subshell, -/// `[ 1 ]` a legitimate index. `<` and `>` are different: they are comparison -/// and redirection operators at least as often as delimiters, and the bracket -/// path returns before the `extends` guard that keeps every other candidate -/// additive, so a bad match here has no safety net. Require the span to hug its -/// contents, which real delimiters do (`Vec<String>`, `<div>`, `<user@host>`, -/// `<info>`) and a comparison doesn't (`a < b > c`, `x <= 0 || y > 9`). -/// -/// Redirections need no special handling: `2>&1` or `cmd > out` have no -/// partner to match, so the scan already fails. fn pair_is_plausible(chars: &[char], open: char, s: usize, e: usize) -> bool { if open != '<' { return true; @@ -535,10 +381,6 @@ fn pair_is_plausible(chars: &[char], open: char, s: usize, e: usize) -> bool { e > s + 1 && !chars[s + 1].is_whitespace() && !chars[e - 1].is_whitespace() } -/// Select through a matching bracket: `click` on an opener scans forward, -/// on a closer scans backward, nesting-aware. Inclusive char range covering -/// both brackets, or `None` when the clicked char isn't a bracket, the match -/// isn't on the logical line, or the span fails [`pair_is_plausible`]. pub(super) fn bracket_range(chars: &[char], click: usize) -> Option<(usize, usize)> { let c = *chars.get(click)?; if let Some((open, close)) = BRACKET_PAIRS.iter().find(|(o, _)| *o == c) { @@ -572,25 +414,13 @@ pub(super) fn bracket_range(chars: &[char], click: usize) -> Option<(usize, usiz None } -/// Patterns tried in specificity order after the URL detector: email, -/// scientific-notation number, file path, dotted/hyphenated identifier. -/// (URLs go through `search::url_span_at` first — it handles scheme -/// detection, wrapper stripping and trailing-punctuation trimming better -/// than a lone regex.) fn regexes() -> &'static [Regex] { static RE: OnceLock<Vec<Regex>> = OnceLock::new(); RE.get_or_init(|| { [ - // Email address. r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}", - // Scientific notation (6.02e+23). r"\b[0-9]+(?:\.[0-9]+)?[eE][+-]?[0-9]+\b", - // File path: at least two segments, or ~/.-anchored. r"[A-Za-z0-9._+@%~-]*(?:/[A-Za-z0-9._+@%~-]+)+/?", - // Identifier chained with `.`/`-` (foo-bar.baz, 10.0.0.1). - // ASCII classes only: the regex crate's `\w` matches Han - // ideographs, which would swallow CJK text glued to a Latin - // word and defeat the script narrowing. r"[0-9A-Za-z_]+(?:[.-][0-9A-Za-z_]+)*", ] .iter() @@ -599,9 +429,6 @@ fn regexes() -> &'static [Regex] { }) } -/// Find a semantic object containing char index `click` in `text` that -/// strictly extends the plain word selection the configured `separators` -/// would produce. Inclusive char range, or `None` to keep the stock word. pub(super) fn smart_range( text: &str, chars: &[char], @@ -612,9 +439,6 @@ pub(super) fn smart_range( return None; } - // The plain word the double-click would select: the run of chars around - // the click that are neither whitespace nor configured separators. - // (Mirrors alacritty's semantic expansion over the same separator set.) let boundary = |c: char| c.is_whitespace() || separators.contains(c); let (mut pws, mut pwe) = (click, click); if !boundary(chars[click]) { @@ -625,13 +449,7 @@ pub(super) fn smart_range( pwe += 1; } } - // CJK chars/punctuation glue onto Latin runs (`分支name,已` is one - // separator-free run), so the word the user *means* is the same-script - // sub-run around the click. Candidates are judged against that; if - // nothing beats it, the narrowed run itself is the answer. let (ws, we) = narrow_to_script(chars, click, pws, pwe); - // Applied only when the candidate strictly contains the (narrowed) word, - // so smart select can grow the meant word but never shrink it. let extends = |s: usize, e: usize| s <= ws && e >= we && (s < ws || e > we); if let Some((s, e, _url)) = super::search::url_span_at(text, click) @@ -640,7 +458,6 @@ pub(super) fn smart_range( return Some((s, e)); } - // Regexes run over a bounded byte window around the click. let byte_of: Vec<usize> = text.char_indices().map(|(b, _)| b).collect(); let w_start = click.saturating_sub(MATCH_WINDOW); let w_end = (click + MATCH_WINDOW).min(chars.len() - 1); @@ -662,15 +479,9 @@ pub(super) fn smart_range( return Some((s, e)); } } - // No pattern beat the meant word — but if script narrowing shrank the - // raw run (Latin word glued to CJK text), that narrowed word *is* the - // correction. ((ws, we) != (pws, pwe)).then_some((ws, we)) } -/// Shrink the inclusive run `[lo, hi]` to the chars sharing `click`'s script -/// class (CJK vs not) — the sub-run a double-click on mixed-script text -/// means. A no-op on single-script runs. pub(super) fn narrow_to_script( chars: &[char], click: usize, @@ -694,12 +505,8 @@ mod tests { use super::*; use alacritty_terminal::event::VoidListener; - /// alacritty's stock separator set, which is also the config default. const SEPS: &str = ",│`|:\"' ()[]{}<>\t"; - /// In production the jieba table builds lazily off-thread and the racing - /// click settles for the whole run; tests want it ready up front. No-op on - /// macOS, where CFStringTokenizer needs no warm-up. fn ensure_segmenter() { #[cfg(not(target_os = "macos"))] let _ = JIEBA.get_or_init(jieba_rs::Jieba::new); @@ -710,16 +517,6 @@ mod tests { smart_range(text, &chars, click, SEPS) } - // ---- Grid-level tests ---- - // - // The functions above operate on a plain `&str`; everything below drives a - // real `Term` through the VT parser instead, because the grid is where the - // index arithmetic actually gets hard: wide CJK glyphs occupy two cells - // (the second a spacer), soft-wrapped rows have to be stitched back into - // one logical line, and OSC 8 runs can straddle both. - - /// A `cols`×`rows` terminal with `input` fed through the VT parser, so the - /// grid holds exactly what a PTY would have produced. fn term_with(cols: usize, rows: usize, input: &str) -> Term<VoidListener> { let config = alacritty_terminal::term::Config { semantic_escape_chars: SEPS.to_string(), @@ -736,23 +533,17 @@ mod tests { term } - /// The text a double-click at `(line, col)` would select, or `None` when - /// no smart candidate applies and the caller keeps the stock word. fn grid_select(term: &Term<VoidListener>, line: i32, col: usize) -> Option<String> { let r = grid_smart_range(term, Point::new(Line(line), Column(col)))?; Some(term.bounds_to_string(r.start, r.end)) } - /// Column of the first occurrence of `needle` on row 0 — keeps the tests - /// from hard-coding offsets that shift when the fixture text changes. fn col_of(row: &str, needle: &str) -> usize { row.find(needle).expect("needle in fixture") } #[test] fn osc8_hyperlink_selects_the_declared_extent_not_the_visible_word() { - // The link text has a space in it: only the OSC 8 run knows where the - // link really ends, which is the whole point of checking it first. let term = term_with( 40, 3, @@ -763,7 +554,6 @@ mod tests { grid_select(&term, 0, col_of(line, "here")).as_deref(), Some("click here"), ); - // A cell outside the run must not pick the link up. assert_ne!( grid_select(&term, 0, col_of(line, "now")).as_deref(), Some("click here"), @@ -772,36 +562,24 @@ mod tests { #[test] fn osc8_hyperlink_follows_a_soft_wrap() { - // 30 chars of link text in 20 columns: the run fills row 0 and spills - // 10 cells onto row 1. Stopping at the row edge would truncate the - // selection to the visible first half. let term = term_with( 20, 4, "\x1b]8;;https://e.com\x1b\\aaaaaaaaaabbbbbbbbbbcccccccccc\x1b]8;;\x1b\\", ); let whole = "aaaaaaaaaabbbbbbbbbbcccccccccc"; - // Click on the wrapped remainder (row 1) — walks backwards over the wrap. assert_eq!(grid_select(&term, 1, 2).as_deref(), Some(whole)); - // ...and from the first row, walking forwards over it. assert_eq!(grid_select(&term, 0, 3).as_deref(), Some(whole)); } #[test] fn soft_wrapped_url_is_stitched_back_into_one_selection() { - // No OSC 8 here — the URL is recovered from the joined logical line, - // so this exercises `logical_line_at`'s wrap walk rather than the - // hyperlink path. let term = term_with(20, 4, "see https://example.com/deep/path here"); let whole = "https://example.com/deep/path"; - // Row 0 holds "see https://example.", row 1 the "com/deep/path here" - // remainder. Clicking the head joins forwards over the wrap... assert_eq!( grid_select(&term, 0, col_of("see https://example", "example")).as_deref(), Some(whole), ); - // ...and clicking the tail joins backwards, which is the direction a - // click on a continuation row depends on entirely. assert_eq!( grid_select(&term, 1, col_of("com/deep/path here", "deep")).as_deref(), Some(whole), @@ -810,13 +588,7 @@ mod tests { #[test] fn hard_wrapped_url_is_bridged_only_for_links() { - // A printing program emitted a literal `\n` mid-URL: the head fills - // row 0 exactly (20 chars, no WRAPLINE flag) and the tail lands on - // row 1. Soft-wrap stitching can't see across this gap; the hard - // bridge in link mode joins them, while smart-select stays put. let term = term_with(20, 4, "https://example.com/\r\ndeep/path/seg rest"); - // The break carries no WRAPLINE flag — this is a producer hard newline, - // not a terminal soft wrap. assert!( !term.grid()[Line(0)][Column(19)] .flags @@ -824,8 +596,6 @@ mod tests { "fixture must be a hard newline, not a soft wrap" ); - // Link mode (bridge_hard_wrap = true) recovers the whole URL spanning - // both rows. let click = Point::new(Line(0), Column(3)); let (text, _points, _idx) = logical_line_at(&term, click, true).expect("logical line under click"); @@ -834,8 +604,6 @@ mod tests { crate::terminal::search::url_span_at(&text, idx + 2).expect("url span in bridged line"); assert_eq!(url, "https://example.com/deep/path/seg"); - // Smart-select mode (bridge_hard_wrap = false) must NOT glue the two - // output lines together. let (text, _points, _idx) = logical_line_at(&term, click, false).expect("logical line under click"); assert!( @@ -846,11 +614,6 @@ mod tests { #[test] fn a_hard_break_before_userinfo_is_never_bridged() { - // Row 0 ends with a bare host that fills the row exactly, row 1 opens - // with `@`. Bridging would resolve `https://good.com@evil.com/x`, whose - // authority per RFC 3986 is `evil.com` — the underline would read - // `good.com` while the click navigated elsewhere. The hard bridge must - // refuse this one even though the row shape otherwise invites it. let term = term_with(20, 4, "go1 https://good.com\r\n@evil.com/x rest"); assert!( !term.grid()[Line(0)][Column(19)] @@ -873,9 +636,6 @@ mod tests { #[test] fn a_soft_wrap_before_userinfo_still_stitches() { - // The `@` guard is about the *ambiguity* of a hard newline. A soft wrap - // is the terminal folding one logical line, so the continuation is - // certain and a userinfo URL must still resolve whole. let term = term_with(20, 4, "see https://user1234@ex.com/z rest"); assert!( term.grid()[Line(0)][Column(19)] @@ -894,12 +654,8 @@ mod tests { #[test] fn wide_glyph_and_its_spacer_resolve_to_the_same_word() { - // Each Han char occupies two cells; the second carries WIDE_CHAR_SPACER - // and has no `c` of its own. Clicking either half must select the same - // segmented word — an off-by-one in the spacer branch shows up here. ensure_segmenter(); let term = term_with(40, 3, "run 北京欢迎你 done"); - // "run " is 4 cells, then 北 at col 4 (spacer at 5), 京 at 6 (spacer 7). let expected = grid_select(&term, 0, 4); assert_eq!(expected.as_deref(), Some("北京"), "click on 北"); assert_eq!( @@ -921,12 +677,8 @@ mod tests { #[test] fn wide_glyph_wrapping_to_the_next_row_keeps_its_word_intact() { - // An odd column count leaves one cell at the end of the row: the wide - // char can't fit, so alacritty pads with LEADING_WIDE_CHAR_SPACER and - // moves the glyph to the next row. The logical line must still join. ensure_segmenter(); let term = term_with(9, 4, "abcdefgh北京欢迎你"); - // 北 is pushed to row 1 col 0 by the leading spacer at row 0 col 8. assert_eq!(grid_select(&term, 1, 0).as_deref(), Some("北京")); } @@ -937,17 +689,11 @@ mod tests { assert!(grid_smart_range(&term, Point::new(Line(0), Column(99))).is_none()); } - /// A double-click dispatched with the previous frame's geometry can name a - /// row the grid has since dropped. Indexing it walks off the storage, and - /// the click arrives in a gpui `extern "C"` callback where that panic - /// aborts instead of unwinding — so the row has to be refused first. #[test] fn click_outside_the_grid_rows_yields_no_range() { let term = term_with(10, 2, "hello"); - // Below the last row of a shrunken grid... assert!(grid_smart_range(&term, Point::new(Line(2), Column(0))).is_none()); assert!(grid_smart_range(&term, Point::new(Line(9_000), Column(0))).is_none()); - // ...and above the top of a scrollback this short. assert!(grid_smart_range(&term, Point::new(Line(-1), Column(0))).is_none()); } @@ -959,8 +705,6 @@ mod tests { #[test] fn url_expands_past_scheme_colon() { let text = "fetch https://example.com/a/b?q=1 done"; - // Click inside "example" — the plain word starts after the `:` - // separator; smart select recovers the whole URL. let click = text.find("example").unwrap(); assert_eq!( selected(text, click).as_deref(), @@ -977,14 +721,9 @@ mod tests { #[test] fn email_only_fires_when_it_extends_the_word() { - // With the default separators the plain word already covers the whole - // address (`@` and `.` are word chars) — the candidate equals the word - // and must be rejected, keeping the stock selection. let text = "author:dev@example.com pushed"; let click = text.find("example").unwrap(); assert_eq!(range(text, click), None); - // With `@` configured as a separator, the email regex reassembles the - // full address across it. let chars: Vec<char> = text.chars().collect(); let got = smart_range(text, &chars, click, ",@:() "); let (s, e) = got.expect("email should match"); @@ -1001,8 +740,6 @@ mod tests { #[test] fn path_across_quote_boundary_stays_plain() { - // The whole path is one plain word already (no separators inside); - // candidates equal to the word are rejected → stock selection. let text = "cat /usr/local/bin/tool"; let click = text.find("local").unwrap(); assert_eq!(range(text, click), None); @@ -1010,10 +747,6 @@ mod tests { #[test] fn path_glued_to_colon_expands() { - // `error:/tmp/x/y` — the word starts after `:`; the path regex - // must not leak left past the colon but the URL/identifier ones - // must not shrink it either. Path candidate is `/tmp/x/y`, equal - // to the plain word → None. Click on `error` side: word `error`. let text = "error:/tmp/x/y"; let click = text.find("tmp").unwrap(); assert_eq!(range(text, click), None); @@ -1026,8 +759,6 @@ mod tests { #[test] fn scientific_notation_with_custom_separators() { - // With `.` and `+` configured as separators (finer-grained - // boundaries), the sci-notation regex reassembles the number. let text = "n = 6.02e+23 mol"; let chars: Vec<char> = text.chars().collect(); let click = text.find("02").unwrap(); @@ -1039,8 +770,6 @@ mod tests { #[test] fn identifier_with_custom_separators() { - // Fine-grained separators split `foo-bar.baz`; the identifier - // regex restores the full dotted chain. let text = "run foo-bar.baz now"; let chars: Vec<char> = text.chars().collect(); let click = text.find("bar").unwrap(); @@ -1052,8 +781,6 @@ mod tests { #[test] fn latin_word_glued_directly_to_han_narrows_without_punctuation() { - // No separator or punctuation between the scripts at all — the - // identifier regex must not reassemble the mixed run (`\w` would). let text = "已合并到main分支"; let chars: Vec<char> = text.chars().collect(); let click = chars.iter().position(|&c| c == 'm').unwrap(); @@ -1064,8 +791,6 @@ mod tests { #[test] fn latin_word_glued_to_cjk_narrows_to_the_latin_run() { - // `,` and `已` are not separators, so the raw run is - // `worktree-feat-smart-select,已`; the meant word is the Latin part. let text = "分支 worktree-feat-smart-select,已 rebase"; let chars: Vec<char> = text.chars().collect(); let click = chars.iter().position(|&c| c == 'w').unwrap() + 10; @@ -1077,35 +802,26 @@ mod tests { #[test] fn symmetric_quotes_pair_by_parity() { let chars: Vec<char> = r#"echo 'a,b' "c d" x"#.chars().collect(); - // First ' opens (0 quotes before), second closes. assert_eq!(quote_range(&chars, 5), Some((5, 9))); assert_eq!(quote_range(&chars, 9), Some((5, 9))); - // Double quotes pair independently of the single ones. assert_eq!(quote_range(&chars, 11), Some((11, 15))); assert_eq!(quote_range(&chars, 15), Some((11, 15))); - // An unmatched opener finds nothing. let chars: Vec<char> = "say 'oops".chars().collect(); assert_eq!(quote_range(&chars, 4), None); - // Non-quote chars never match. assert_eq!(quote_range(&chars, 1), None); } #[test] fn contraction_apostrophes_do_not_pair() { let chars: Vec<char> = "it's a test, isn't it".chars().collect(); - // Clicking either contraction falls through to the stock word. assert_eq!(quote_range(&chars, 2), None); assert_eq!(quote_range(&chars, 16), None); - // And the whole line yields no smart candidate at all, so the - // double-click keeps alacritty's `it's`. let text = "it's a test, isn't it"; assert_eq!(range(text, 2), None); } #[test] fn contractions_do_not_skew_a_real_quote() { - // The apostrophes inside the quoted span must not flip the parity or - // steal the match from the genuine delimiters. let chars: Vec<char> = "echo 'it isn't so' done".chars().collect(); let open = 5; let close = chars.iter().rposition(|&c| c == '\'').unwrap(); @@ -1115,8 +831,6 @@ mod tests { #[test] fn trailing_apostrophe_still_closes() { - // `dogs'` — the apostrophe has a word char only on its left, so it is - // a delimiter, not a contraction. let chars: Vec<char> = "the 'dogs' bark".chars().collect(); assert_eq!(quote_range(&chars, 4), Some((4, 9))); assert_eq!(quote_range(&chars, 9), Some((4, 9))); @@ -1151,7 +865,6 @@ mod tests { #[test] fn angle_brackets_pair_only_when_they_hug_their_contents() { - // Real delimiters: generics, tags, placeholders, bracketed addresses. for (text, want) in [ ("let v: Vec<String> = x", "<String>"), ("<div class=\"row\">hi", "<div class=\"row\">"), @@ -1165,8 +878,6 @@ mod tests { let got: String = chars[s..=e].iter().collect(); assert_eq!(got, want, "{text}"); } - // Comparison operators must not pair across half a line — a space just - // inside either end is the tell. for text in [ "if a < b then c > d", "awk '{ if ($1 > 100 && $2 < 5) print }'", @@ -1180,7 +891,6 @@ mod tests { } } } - // Redirections never had a partner to match in the first place. for text in ["cargo build 2>&1 | tee out", "grep -rn foo src/ > /tmp/o"] { let chars: Vec<char> = text.chars().collect(); for (i, &c) in chars.iter().enumerate() { @@ -1210,9 +920,6 @@ mod tests { #[test] fn cjk_segmentation_survives_surrogate_pairs_before_the_click() { - // The emoji is two UTF-16 units: a tokenizer offset table that counted - // chars instead would shift every index after it. Both backends agree - // on `世界`, so a skewed mapping shows up as a different token. ensure_segmenter(); for text in ["你好世界", "🙂 你好世界", "🙂🙂🙂 你好世界"] { let chars: Vec<char> = text.chars().collect(); @@ -1234,17 +941,12 @@ mod tests { assert_eq!(sel, ","); } - /// Japanese must not be run through jieba's Chinese dictionary — it cuts - /// kana into single characters, which is worse than leaving the run whole. - /// macOS hands it to CFStringTokenizer, which segments it properly. #[test] fn japanese_is_not_shredded_into_single_kana() { ensure_segmenter(); let text = "日本語の文章です"; let chars: Vec<char> = text.chars().collect(); let click = chars.iter().position(|&c| c == 'で').unwrap(); - // macOS yields a real token, never a lone kana; elsewhere the run - // comes back unsegmented and the caller selects all of it. if let Some((s, e)) = cjk_word_range(text, click) { let sel: String = chars[s..=e].iter().collect(); assert_eq!(sel, "です"); diff --git a/src/terminal/typeahead.rs b/src/terminal/typeahead.rs index 9d860c14..2cba7213 100644 --- a/src/terminal/typeahead.rs +++ b/src/terminal/typeahead.rs @@ -1,55 +1,13 @@ -//! Tracks what the user types into a pane while the local line editor is -//! *disengaged*, so the editor can adopt it on engage instead of stranding it. -//! -//! Two windows behave identically: a freshly spawned shell sourcing rc files -//! (often a second or more before the first OSC 133), and the gap every -//! submitted command opens between `133;C` and the next prompt. In both, -//! `at_prompt` is false and keystrokes go raw to the PTY. The shell isn't -//! reading them — the bytes queue in the kernel TTY buffer — and when zle -//! (re)starts at the next prompt it consumes them as type-ahead: they appear -//! on the *shell's* command line. At that same moment the editor engages with -//! an empty buffer and swallows every key, so the strays can be neither -//! edited nor deleted — and the editor overlay (transparent, anchored at the -//! cursor) double-draws its own line over their echo. -//! -//! The fix: record a best-effort reconstruction of the gap typing here; when -//! the editor engages, send one `^U` (kill-whole-line) to the PTY and seed -//! the editor with the reconstruction. Ordering makes the `^U` safe with no -//! timing assumptions: it is written *after* every stray byte, and the TTY -//! queue is FIFO, so zle always consumes the strays first and then the `^U` -//! that wipes them — wherever prompt boundaries fall. In the common case -//! nothing was typed in the gap, `drain` returns `None`, and no byte is sent. -//! -//! A command that *reads* its stdin (a REPL, a password prompt) consumes gap -//! bytes itself; they never reach zle. The `^U` still only lands in zle (the -//! editor engages at a prompt, after the command exited), where killing an -//! empty line is a no-op, and Enter-terminated input seeds nothing thanks to -//! the submit-boundary rule — so the wipe stays safe there too. Full-screen -//! TUI input (alt screen) is not reconstructable typing at all: it taints the -//! record instead of recording. - -/// Cap on the recorded reconstruction. Typing that overflows it (nobody types -/// 4 KiB into a prompt gap — this is a paste or a stuck key) taints the -/// record instead of silently truncating to a wrong line. const RECORD_CAP: usize = 4096; -/// Best-effort reconstruction of user input sent raw to the PTY while the -/// line editor was disengaged. `tainted` means bytes we can't reconstruct -/// (arrows, tab, control chords, multi-line pastes) went through: the wipe -/// still happens, but nothing is seeded — a wrong guess in the editor is -/// worse than an empty line. #[derive(Default)] pub struct Typeahead { text: String, tainted: bool, } -/// One raw PTY-bound user-input event, as far as reconstruction cares. pub enum RawInput<'a> { - /// Committed printable text (the IME commit and paste paths). Text(&'a str), - /// A non-text keystroke that produced PTY bytes. `key` is the GPUI key - /// name; `plain` means no control/alt/platform modifier was held. Key { key: &'a str, plain: bool }, } @@ -58,11 +16,6 @@ impl Typeahead { Self::default() } - /// Fold one raw PTY-bound input event into the reconstruction. Call this - /// wherever user input is written to the PTY while the line editor is - /// disengaged — the record mirrors exactly what the shell will later - /// consume as type-ahead. `alt_screen` input belongs to a full-screen TUI, - /// not the shell's next line: it taints the record instead of recording. pub fn observe(&mut self, input: RawInput, alt_screen: bool) { if alt_screen { self.taint(); @@ -82,17 +35,10 @@ impl Typeahead { } } - /// Take the reconstruction accumulated since the last drain, resetting the - /// record so the next gap starts clean. `None` → nothing was typed, send - /// nothing. `Some(seed)` → send `^U` to wipe the shell's line, then put - /// `seed` (possibly empty, if tainted or everything was already submitted) - /// into the editor. pub fn drain(&mut self) -> Option<String> { std::mem::take(self).flush() } - /// Record committed printable text (the IME path). Control characters mean - /// this wasn't plain typing (e.g. a multi-line paste) — taint instead. fn record_text(&mut self, s: &str) { if s.chars().any(char::is_control) { self.tainted = true; @@ -105,9 +51,6 @@ impl Typeahead { self.text.push_str(s); } - /// Record Enter. `\r` marks a submit boundary: everything before it will - /// have been accepted (and run) by zle, so only the tail after the *last* - /// `\r` is still sitting on the line when we flush. fn record_enter(&mut self) { if self.text.len() + 1 > RECORD_CAP { self.tainted = true; @@ -116,24 +59,16 @@ impl Typeahead { self.text.push('\r'); } - /// Record Backspace. Pops the last recorded char — except across a submit - /// boundary (or on an empty record), where zle itself would have had - /// nothing to erase, so the record must not shrink either. fn record_backspace(&mut self) { if !self.text.ends_with('\r') { self.text.pop(); } } - /// Record a byte sequence we can't reconstruct (arrows, tab, control - /// chords…). The eventual wipe neutralizes whatever zle makes of it; we - /// just stop pretending to know the line's content. fn taint(&mut self) { self.tainted = true; } - /// Consume the record into the seed decision — the by-value core of - /// [`Typeahead::drain`], see there for the contract. fn flush(self) -> Option<String> { if self.text.is_empty() && !self.tainted { return None; @@ -141,7 +76,6 @@ impl Typeahead { if self.tainted { return Some(String::new()); } - // Only the tail after the last submit boundary is still on zle's line. let seed = self.text.rsplit('\r').next().unwrap_or(""); Some(seed.to_string()) } @@ -153,24 +87,16 @@ mod tests { #[test] fn drained_record_reconstructs_each_gap_independently() { - // Mid-session, every submitted command opens a prompt→prompt gap where - // typing goes raw to the PTY. The record must reset on `drain` so each - // gap seeds only its own typing. let mut t = Typeahead::new(); t.observe(RawInput::Text("cd getty"), false); assert_eq!(t.drain(), Some("cd getty".to_string())); - // The idle prompt drains once per render — nothing typed since, so - // nothing is wiped or seeded. assert_eq!(t.drain(), None); - // The next gap starts clean, unpolluted by the drained one. t.observe(RawInput::Text("ls"), false); assert_eq!(t.drain(), Some("ls".to_string())); } #[test] fn raw_keys_map_to_boundary_erase_or_taint() { - // Plain Enter is a submit boundary (zle ran what precedes it); plain - // Backspace erases the last recorded char, exactly like zle will. let mut t = Typeahead::new(); t.observe(RawInput::Text("ls"), false); t.observe( @@ -190,8 +116,6 @@ mod tests { ); assert_eq!(t.drain(), Some("git s".to_string())); - // Any other key that produced PTY bytes (arrows, tab, chords) makes - // the line unknowable — wipe, seed nothing. let mut t = Typeahead::new(); t.observe(RawInput::Text("ls"), false); t.observe( @@ -203,7 +127,6 @@ mod tests { ); assert_eq!(t.drain(), Some(String::new())); - // A chorded Enter isn't accept-line; it must not fake a boundary. let mut t = Typeahead::new(); t.observe(RawInput::Text("a"), false); t.observe( @@ -218,10 +141,6 @@ mod tests { #[test] fn alt_screen_input_taints_instead_of_seeding() { - // Keys typed into a full-screen TUI (vim, less…) are that program's - // input, not command typing — resurrecting them as an editor seed - // would turn a habitual `q` into a pending command. They make the - // line unknowable: wipe at the next prompt, seed nothing. let mut t = Typeahead::new(); t.observe(RawInput::Text("q"), true); assert_eq!(t.drain(), Some(String::new())); @@ -229,8 +148,6 @@ mod tests { #[test] fn untouched_record_flushes_to_none() { - // The overwhelmingly common case — nothing typed during startup — must - // send nothing: no ^U, no seed, zero behavior change. assert_eq!(Typeahead::new().drain(), None); } @@ -251,8 +168,6 @@ mod tests { #[test] fn backspace_on_empty_record_is_noop_but_still_flushes_nothing() { - // zle would have nothing to erase either; the record stays empty and - // the flush stays silent. let mut p = Typeahead::new(); p.record_backspace(); assert_eq!(p.drain(), None); @@ -260,9 +175,6 @@ mod tests { #[test] fn enter_marks_a_submit_boundary() { - // "ls\r" was accepted and executed by zle at the first prompt; nothing - // of it remains on the line. Seeding "ls" again would duplicate the - // command — the seed must be only the tail after the last \r. let mut p = Typeahead::new(); p.record_text("ls"); p.record_enter(); @@ -275,16 +187,11 @@ mod tests { let mut p = Typeahead::new(); p.record_text("ls"); p.record_enter(); - // ^U still goes out (an empty next line is wiped harmlessly; a partial - // leak is cleaned), but the executed command is not resurrected. assert_eq!(p.drain(), Some(String::new())); } #[test] fn backspace_does_not_cross_a_submit_boundary() { - // After "ls\r", zle's next line is empty: a Backspace typed then erases - // nothing in the shell, so it must not eat our \r marker either — - // otherwise the seed would become "ls" and duplicate the executed command. let mut p = Typeahead::new(); p.record_text("ls"); p.record_enter(); @@ -294,8 +201,6 @@ mod tests { #[test] fn unreconstructable_input_taints_wipe_without_seed() { - // An arrow key (history recall!) makes the line's real content - // unknowable. Wipe it, seed nothing. let mut p = Typeahead::new(); p.record_text("ls"); p.taint(); @@ -304,8 +209,6 @@ mod tests { #[test] fn control_chars_in_committed_text_taint() { - // A multi-line paste reaches the raw path as one commit; its embedded - // newlines already ran as commands zle-side. Don't guess. let mut p = Typeahead::new(); p.record_text("echo a\necho b"); assert_eq!(p.drain(), Some(String::new())); @@ -318,14 +221,11 @@ mod tests { for _ in 0..5 { p.record_text(&chunk); } - // 5000 > RECORD_CAP: a truncated seed would be a wrong line; taint. assert_eq!(p.drain(), Some(String::new())); } #[test] fn exactly_at_the_cap_still_reconstructs() { - // Filling the record to exactly RECORD_CAP is not an overflow; the full - // reconstruction survives. One more char would tip it into taint. let mut p = Typeahead::new(); let full = "x".repeat(RECORD_CAP); p.record_text(&full); @@ -333,14 +233,12 @@ mod tests { let mut p = Typeahead::new(); p.record_text(&full); - p.record_text("y"); // cap + 1 → taint (wipe, no seed) + p.record_text("y"); assert_eq!(p.drain(), Some(String::new())); } #[test] fn taint_survives_later_clean_typing() { - // Once the record is unknowable it stays unknowable — later reconstructable - // keys must not "wash" the taint into a half-right seed. let mut p = Typeahead::new(); p.taint(); p.record_text("ls"); diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 3c6ea050..3b38fa19 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -1,6 +1,3 @@ -//! The GPUI view that hosts a terminal: owns the backend, pumps PTY events into -//! redraws, translates keystrokes to bytes, and renders the terminal chrome. - use alacritty_terminal::event::Event as AlacEvent; use alacritty_terminal::grid::{Dimensions, Scroll}; use alacritty_terminal::index::{Column, Direction, Line, Point, Side}; @@ -32,29 +29,9 @@ use crate::core::actions::{ use crate::core::config::{BellMode, Config, NotifyMode}; use crate::daemon::protocol::{RemoteContext, ShellSpec}; -/// Inset (px) between the terminal-surface edge and the cell grid. The prompt -/// editor and the floating completion / history menus are absolutely positioned -/// over the grid, so they must offset their grid-aligned origin by the same -/// amount the surface padding insets the grid. Keep these in sync with the -/// `.px()/.py()` on the surface container in `TerminalView::render` — they are -/// the single source of truth for that inset. const GRID_PAD_X: f32 = 8.; const GRID_PAD_Y: f32 = 4.; -// Terminal-scoped actions dispatched by the right-click context menu and the -// menu bar's Edit menu. They route to this view via `.on_action` handlers on the -// terminal surface; tab/split actions in the same menu bubble up to `Tty7App` -// from the focused terminal. -// -// Every one of these is the *single* path for its gesture: the ⌘-chord, the -// context-menu row, and the Edit-menu item all dispatch the same action, so the -// three can't drift (they did — the context menu's Paste used to skip the -// image-paste branch that ⌘V had). -// -// `InsertNewline` is the exception to the "menu" part: it has no menu row (you -// do not reach for a menu mid-word), and exists so the prompt editor's soft -// newline is a *bindable* action rather than a hardcoded chord — see -// `insert_newline_action`. actions!( terminal, [ @@ -72,482 +49,127 @@ actions!( ] ); -/// Emitted when the pane's child process has genuinely exited (`exit`, -/// Ctrl-D, a crashed shell) — as opposed to the daemon connection dropping, -/// which keeps the dead pane visible. `Tty7App` subscribes (see -/// `new_terminal`) and closes the pane in response: collapsing its split, or -/// closing the tab when it was the only pane. pub struct ChildExited; impl gpui::EventEmitter<ChildExited> for TerminalView {} -/// A native-SSH pane raised an interactive auth/host-key prompt (or a status -/// change) that the app should surface in an in-pane sheet. `Tty7App` subscribes -/// (see `new_terminal`) and drains the pane's pending prompts into -/// `ui::ssh_prompt`. Zero-payload — the app reads the prompt off the pane's -/// `RemoteTerminal` (`take_auth_prompt` / `ssh_phase`). pub struct AuthPromptReady; impl gpui::EventEmitter<AuthPromptReady> for TerminalView {} -/// The pane's coding agent reported a different native session id than the one -/// the saved layout knows about — it started a conversation, or replaced the -/// one it had. `Tty7App` subscribes (see `new_terminal`) and re-saves. -/// -/// # Why an event and not just "it's read at save time" -/// -/// The id arrives asynchronously, on the agent's own hooks, long after -/// everything that *structurally* changes a window. Nothing else was making the -/// window save in between, so whether the id reached the persisted layout came -/// down to whether the user happened to open a tab, split a pane or move focus -/// afterwards. That is what made resume-after-End-Sessions work sometimes and -/// not others: the layout on file simply had no agent in it. pub struct AgentSessionChanged; impl gpui::EventEmitter<AgentSessionChanged> for TerminalView {} -/// An established native-SSH daemon pane, ready to be wrapped in a view: the -/// output of the fallible [`TerminalView::spawn_native_ssh_terminal`], consumed -/// by the infallible [`TerminalView::from_native_ssh_parts`]. pub struct NativeSshParts { terminal: RemoteTerminal, pane_id: u64, - /// Secret-free spec copy retained for session restore / in-pane reconnect. persist: Box<crate::daemon::protocol::NativeSshSpec>, } -/// An established shell daemon pane (fresh spawn or re-attach), ready to be -/// wrapped in a view: the output of the fallible -/// [`TerminalView::spawn_shell_terminal_in`], consumed by the infallible -/// [`TerminalView::from_shell_parts`]. pub struct ShellParts { terminal: RemoteTerminal, - /// The daemon's id for this pane. Readable so a pane that arrived after its - /// slot was closed can be killed rather than leaked (see - /// `ui::app::Tty7App::land_pane`). pub(crate) pane_id: u64, - /// The explicit shell pick this pane was spawned with, if any; `None` for a - /// re-attached pane (the pick isn't persisted). shell_spec: Option<ShellSpec>, - /// The remote workspace this pane was opened *in*, carried through so - /// [`TerminalView::from_shell_parts`] can bind the view to the same machine - /// the connection went to. `None` for a local pane. - /// - /// It rides here rather than being set on the view afterwards because the - /// two must not be able to disagree: the route is chosen before the pane - /// exists, and a view that thought it was somewhere else would send its - /// `Kill` and its restore `List` to the wrong daemon. - /// - /// Readable for the same reason as `pane_id` above: killing an orphaned - /// pane has to dial the machine it actually landed on. pub(crate) workspace: Option<crate::terminal::PaneWorkspace>, - /// Whether this is the pane the caller asked to re-attach to, or a fresh - /// one spawned because that id was gone. A restored pane is still running - /// whatever it was running; a respawned one is a bare shell in the same - /// directory, which is the case a saved coding-agent session has to be - /// resumed into. pub(crate) restored: bool, - /// The workspace whose window created this pane — see - /// [`TerminalView::owner_workspace`]. Rides here for the same - /// cannot-disagree reason as `workspace` above. pub(crate) owner: Option<crate::core::session::WorkspaceId>, } -/// See `TerminalView::drag_scroll`. #[derive(Clone, Copy)] struct DragScroll { - /// How far past the pane edge the pointer sits, in lines. Positive = - /// above the top edge (scroll up into history), negative = below. overshoot: f32, - /// Column to keep extending the selection with at the edge row. col: usize, - /// Cell half to anchor the selection end on. side: Side, } -/// Whether a pane's reported paths can be asked about on the host it is paired -/// with — the gate behind [`TerminalView::host_cwd`], lifted out so it can be -/// tested without standing up a pane and a daemon. -/// -/// This is the one check keeping a remote path away from a `git` that cannot -/// see it. The pairing has to *agree*: a shell running on another machine is -/// only answerable by a host that is that machine, and a local shell only by -/// the local host. A pane that runs elsewhere but still holds the local host — -/// a native-SSH or WSL pane, which has no `Host` behind it at all — answers -/// `false`, which is exactly what the old `remote_context().is_none()` gate -/// did. fn cwd_is_on_host(pane_runs_remotely: bool, host_is_local: bool) -> bool { match pane_runs_remotely { - // A local shell: its paths are this machine's, so only the local host - // can answer for them. false => host_is_local, - // A shell on another machine: only a host that *is* that machine can. true => !host_is_local, } } pub struct TerminalView { pub terminal: RemoteTerminal, - /// The machine whose filesystem and `git` this pane's paths belong to — - /// [`HostId::LOCAL`] for a local or SSH pane, its workspace's machine for a - /// remote-workspace pane (set by [`set_workspace`](Self::set_workspace)). - /// - /// **The id, not the host object.** A reconnect mints a fresh `RemoteHost` - /// for the same machine and replaces the registry's entry; a pane holding - /// the old `Arc` would keep probing a dead connection forever, and since a - /// failed probe deliberately leaves the last good branch line on screen, - /// that failure would look exactly like "nothing changed". Resolving - /// through [`host`](Self::host) at use time means a reconnect is picked up - /// by the next probe with nothing to notify. host_id: crate::ui::host_ops::HostId, - /// The remote workspace this pane belongs to, when it is one. - /// - /// `None` — the case today, until the M5 window/workspace binding calls - /// [`set_workspace`](Self::set_workspace) — means a local pane or an SSH - /// pane, and every path that reads this falls back to the pane-addressed - /// behaviour it has always had. - /// - /// It sits beside `host` rather than inside it because the two answer - /// different questions: `host` is *whose filesystem is this path on*, this - /// is *whose SSH connection do side effects run on, and what owns them*. - /// Several workspaces share one `host`; they must not share forwards. workspace: Option<crate::terminal::PaneWorkspace>, - /// Daemon-assigned id of the pane this view mirrors. Persisted in the session - /// 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>, - /// The workspace whose window created this view (spawn or re-attach). - /// `None` only for views built through paths that predate the field (tests, - /// native SSH). `Tty7App::save_session` compares it against the workspace - /// it is about to record under and shouts on a mismatch — a window whose - /// tabs and identity have come apart is exactly the corruption that once - /// copied one workspace's layout into another's record, and it must be - /// caught at the write, not discovered at the next restart. owner_workspace: Option<crate::core::session::WorkspaceId>, - /// Whether this view re-attached to the pane its caller asked for, rather - /// than getting a fresh shell because that pane was gone — [`ShellParts`]'s - /// `restored`, kept because restore has to act on it *after* the view is - /// built. - /// - /// `false` for every view that was never restoring anything (a new tab, a - /// split, a test), which is the same answer those callers already got from - /// "there was no pane id to come back to". restored: bool, - /// The native-SSH spec this pane was spawned with, **secrets stripped** - /// ([`NativeSshSpec::without_secrets`]). `None` for local shells (and a - /// foreground `ssh` typed in one). Persisted into the session so a *dead* - /// native-SSH pane can be respawned/reconnected on restore (PRD FR-E4 / C2), - /// and read live to drive the in-pane reconnect (`RestartSshSession`). ssh_spec: Option<Box<crate::daemon::protocol::NativeSshSpec>>, pub focus_handle: FocusHandle, pub font: Font, - /// Optional distinct base face for bold cells (from `font_family_bold`), with - /// the same fallback chain as `font`. `None` → synthesize bold from `font`. pub font_bold: Option<Font>, - /// Optional distinct base face for italic cells (from `font_family_italic`). - /// `None` → synthesize italic from `font`. pub font_italic: Option<Font>, - /// User-configured OpenType features for terminal fonts. `None` preserves - /// tty7's terminal-safe default (ligatures disabled); `Some` is opt-in. font_features: Option<gpui::FontFeatures>, pub font_size: Pixels, - /// Line height as a multiple of `font_size`; the element turns it into the - /// concrete row height each frame. Sourced from `Config::line_height`. pub line_height_mul: f32, pub cell_width: Pixels, line_height: Pixels, selecting: bool, - /// Auto-scroll state for a selection drag that has crossed the pane's top - /// or bottom edge; `None` while the pointer is inside. A repeating task - /// (armed by `select_autoscroll`) keeps scrolling the scrollback and - /// re-extending the selection while this is `Some`, so the scroll goes on - /// even when the pointer holds still past the edge — mouse-move events - /// alone stop the moment the hand does. drag_scroll: Option<DragScroll>, - /// Generation counter for the auto-scroll task. Bumped every time a new - /// task is armed so a stale task from a just-cancelled edge visit kills - /// itself instead of doubling the scroll speed when the pointer leaves, - /// re-enters, and leaves the pane again within one tick. drag_scroll_epoch: u64, pub title: String, - /// IME pre-edit (composing) text, e.g. the pinyin shown before a Chinese - /// candidate is committed. Empty when not composing. pub marked_text: String, - /// Last cell reported to the PTY in mouse-tracking mode, used to suppress - /// duplicate motion reports while dragging within a single cell. last_mouse_cell: Option<(usize, usize)>, - /// Last cell the pointer hovered over locally. Kept separate from - /// `last_mouse_cell`, which belongs to terminal mouse-reporting protocol - /// state and must not be disturbed by local link affordances. last_hover_cell: Option<(usize, usize)>, - /// Whether the platform modifier (⌘ on macOS) is currently held, as reported - /// by the window-level modifier listener. Mouse events can lag or omit this - /// state while a mouse-tracking TUI is foreground, so link hover must not - /// depend solely on each move event's modifier snapshot. link_modifier_down: bool, - /// Fractional line debt carried between wheel events on the quantized - /// paths (mouse-tracking reports, alternate-scroll arrow keys), where the - /// app consumes whole lines. Trackpads report pixel deltas well under a - /// line per event; rounding each one separately discards them all and slow - /// scrolling never moves. Accumulate instead and spend whole lines as they - /// build up. scroll_debt: f32, - /// Sub-line part of the scrollback position, in lines (`0.0..1.0`). The - /// emulator's `display_offset` holds the whole lines; together they form a - /// continuous, pixel-smooth scroll position. The element shifts the whole - /// grid down by `scroll_frac * line_height` at paint and fills the strip - /// above with the next older row, so trackpad scrolling moves every frame - /// instead of snapping line by line. Reset to 0 whenever something jumps - /// the view (typing, submit, clear). pub(super) scroll_frac: f32, - /// In-progress incremental search (Cmd+F), if the search bar is open. pub search: Option<SearchState>, - /// Whether the block cursor is in its "on" (drawn) phase. Toggled by the - /// blink task while focused, and forced back to `true` on input / focus so - /// the cursor never lingers in the hidden phase right after the user acts. pub cursor_visible: bool, - /// Whether this terminal currently holds keyboard focus. Kept in sync via - /// focus listeners so the blink task pauses while unfocused (where the - /// cursor is drawn as a hollow box instead of blinking). pub focused: bool, - /// Whether the search field currently holds focus. Kept in sync from the - /// field's `Focus`/`Blur` events; lets Escape close the bar while focused and - /// keeps Escape feeding the PTY when the terminal is focused. - /// `pub(super)` so the search code in `terminal::search` can mirror focus. pub(super) search_focused: bool, - /// Force case-sensitive matching (the "Aa" toggle). When `false` the query - /// keeps alacritty's smart-case default (insensitive unless it contains an - /// uppercase char); when `true` a `(?-i)` prefix forces sensitivity. Persists - /// across close/reopen of the bar. pub(super) search_case_sensitive: bool, - /// Treat the query as a regex (the ".*" toggle). When `false` (default) the - /// query is matched literally (metacharacters escaped); when `true` it is a - /// regex pattern. Persists across close/reopen. pub(super) search_regex: bool, - /// Set when the current query is regex mode and fails to compile — drives the - /// error styling on the search field so an invalid pattern isn't a silent - /// zero-match. Only ever true while `search_regex` is on. pub(super) search_regex_error: bool, - /// The last query text, remembered when the bar closes so reopening restores - /// it (unless a selection prefills instead). pub(super) search_last_query: String, - /// True for a brief window after a bell event; drives a momentary visual - /// flash painted in place of an audible beep. pub bell_flash: bool, - /// Whether mouse events are reported to full-screen apps that request it - /// (`Config::mouse_reporting`). Cached from the global at construction and - /// refreshed on config hot-reload (`Tty7App::reload_from_config`) so the - /// mouse-report gates — which run in `&self`/`&mut self` methods without a - /// `cx` — can consult it. When `false`, every mouse-tracking mode reads as - /// clear, keeping the mouse local (selection + scrollback). pub report_mouse: bool, - /// Last observed "shell is idle at its prompt" state, tracked so a change can - /// trigger a redraw (showing/hiding the line editor) even when the shell - /// produced no output to repaint on its own. last_at_prompt: bool, - /// When a foreground command is running, the instant it started and the tab - /// title captured then — used to fire a "command finished" notification for - /// long-running commands completed while the window is in the background. running_since: Option<std::time::Instant>, running_title: String, - /// The coding agent (if any) detected during the current foreground-command - /// episode, captured so its completion notification can be branded ("Claude - /// Code finished" rather than a generic "command finished"). Set the moment - /// the daemon reports an agent while a command runs; cleared when it ends. running_agent: Option<crate::core::cli_agent::CLIAgent>, - /// The rich agent status last seen by the poll, so transitions (working → - /// waiting, working → done) fire exactly one notification each and repaint - /// the status dot. last_agent_status: Option<crate::core::cli_agent::AgentStatus>, - /// The agent identity that is worth *persisting* — the native session id and - /// the argv it was launched with — as last seen. Compared on every poll so a - /// change raises [`AgentSessionChanged`] and the layout on file catches up. - /// - /// Deliberately not the agent chip itself: that can blip for a moment when - /// the agent shells out, and a blip here would mean a save (and, on a remote - /// workspace, a push) for nothing. The session id does not blip. last_agent_session: (Option<String>, Option<Vec<String>>), - /// When the current rich turn entered `Working`, for the "finished after - /// Ns" copy on its `Done` notification. agent_turn_started: Option<std::time::Instant>, - /// Whether this pane's agent ever reported over the rich sentinel channel. - /// While true, the coarse process-exit "agent finished" notification is - /// suppressed — the turn-level `stop` events already said it better. agent_was_rich: bool, - /// Whether the agent's last finished turn (the green `Done` dot) is *unread* - /// — a turn ended that the user hasn't looked at since. Set when a new turn - /// finishes while this pane is unfocused; cleared the moment the pane gains - /// focus (you're looking at it). The tab avatar only paints the Done dot - /// while this is true, so a result you've already seen stops nagging. Blue - /// (working) / amber (waiting) are unaffected — they track live state. agent_result_unread: bool, - /// One-shot guard for a manual "Mark as Unread" on the pane the dismissed - /// context menu is about to refocus: closing the menu returns window focus - /// to that pane, and the resulting focus-in would instantly clear the mark - /// the user just made. Armed by [`mark_agent_result_unread`] - /// (Self::mark_agent_result_unread); the next focus-in consumes it instead - /// of clearing, so the mark survives until the user genuinely comes back. keep_unread_on_focus: bool, - /// The cwd this pane's git line reads from (and last scheduled a probe - /// for), so the poll loop only reprobes when the working directory - /// actually changes. The snapshot itself lives in the process-wide - /// [`GitStatusCache`](crate::terminal::git_status::GitStatusCache), keyed - /// by work-tree root — panes in one repo share one entry instead of each - /// computing (and staling) its own. git_status_cwd: Option<std::path::PathBuf>, - /// The agent session's tool-completion count as of the last poll, so a - /// change means "the agent ran a tool since we looked" — the cue to refresh - /// the git line mid-turn rather than at the end of one. Reset to 0 when no - /// session is present, so a new session's first tool call reads as activity. last_agent_activity: u64, - /// The inline command line editor. Live only while the shell sits idle - /// at its prompt (`input_active`): there the terminal keeps keyboard focus and - /// we run our own line editor (so we own Tab / ↑ / ↓ for completion and - /// history, which a focused `InputState` would otherwise claim). On Enter the - /// whole edited line is shipped to the PTY at once. While a command runs (or on - /// the alternate screen) it's hidden and keys feed the PTY directly. cmd: CmdEditor, - /// Reconstruction of input typed while the line editor is disengaged — - /// shell startup (rc sourcing) and the gap while every command runs. Those - /// keys bypass the editor, queue in the TTY, and zle consumes them at the - /// next prompt as un-editable strays that the editor overlay then - /// double-draws over. Drained (^U + editor seed) once the editor is live - /// *and* zle is reading (`zle_reading`). See `typeahead` module docs. typeahead: Typeahead, - /// Short client-side hold for reconstructable gap input: a fast command's - /// typeahead goes straight to the editor without ever touching the PTY - /// (no kernel echo, no wipe); a lapsed window (`HOLD_WINDOW`) releases the - /// bytes for whatever reads stdin. See `hold` module docs. hold: GapHold, - /// Commands submitted this session, oldest first — the source for ↑/↓ recall - /// and Ctrl+R search (both of which want strict chronological order). history: Vec<String>, - /// How many times each history line has been run (across the shell histories, - /// tty7's own file, and this session). The frequency half of the frecency - /// ranking; kept in step with `history` on submit. history_counts: std::collections::HashMap<String, u32>, - /// For each history line, the set of directories it was run in — the - /// current-directory half of the frecency ranking, so commands used *here* - /// float up. Kept in step with `history` on submit. history_cwds: std::collections::HashMap<String, std::collections::HashSet<String>>, - /// Last-run metadata (timestamp + exit code) per history line, feeding the - /// Ctrl+R menu's "ran 3h ago" and failure badges. Kept in step with - /// `history` on submit; the exit code lands when the shell reports back. history_meta: std::collections::HashMap<String, super::history::EntryMeta>, - /// `history` re-ordered by frecency (frequency × recency + a current-directory - /// bonus), most relevant first. Drives the ghost-text autosuggestion — the - /// sole whole-line recall surface besides Ctrl+R (the Tab menu stays - /// history-free). Recomputed when a command is run or the working directory - /// changes. history_ranked: Vec<String>, - /// The frecency score of each `history` entry, index-aligned with it — the - /// relevance half of the Ctrl+R search's fuzzy+frecency blend. Recomputed - /// alongside `history_ranked`. history_frecency: Vec<f64>, - /// The directory `history_ranked` was last computed for, so the polling loop - /// only re-ranks when the working directory actually changes. ranked_cwd: Option<std::path::PathBuf>, - /// Current position while navigating history with ↑/↓: `Some(i)` indexes - /// `history`; `None` means we're editing a fresh line (past the newest entry). history_nav: Option<usize>, - /// The in-progress line saved when history navigation starts, so pressing ↓ - /// past the newest entry restores what the user was typing. history_stash: String, - /// Position of a run of ⌥. presses (readline's `yank-last-arg`): which - /// `history` entry the last press took its word from, and the char span it - /// left in the line — the next press replaces that span with the word from - /// the entry before it. Any other key clears this, so the following ⌥. - /// starts a fresh walk at the newest entry. last_word_nav: Option<LastWordWalk>, - /// A submitted command whose history-file record is deferred until the - /// shell reports back at its prompt, so the record can carry the command's - /// exit code (see [`PendingHistory`]). pending_history: Option<PendingHistory>, - /// Open Tab-completion menu, if any — a picker over the candidates gathered - /// when it opened. Typing/Backspace re-filter it in place; it closes on - /// accept, on Escape, or once the edited word no longer matches anything. completion: Option<CompletionSession>, - /// Whether a remote directory listing for completion is on the wire (see - /// [`Self::spawn_remote_path_completion`]). Holding Tab down would - /// otherwise dial the daemon once per repeat while the first answer is - /// still travelling. remote_completion_inflight: bool, - /// Monotonic tag bumped every time a completion session opens or closes. - /// Dynamic generators run on background threads and land their results here - /// via `cx.spawn`; each task captures the generation it was spawned under and - /// its result is dropped unless it still matches — so output from a session - /// the user has since closed (or replaced) can never leak into a later menu. completion_generation: u64, - /// While equal to the terminal's current `prompt_cycle`, the local line - /// editor has handed this prompt's line over to the shell (Tab fell - /// through to shell-native completion — see - /// [`Self::handoff_tab_to_shell`]): the shell's own editor now holds the - /// text, so keys go raw to the PTY exactly as on a shell-vi-mode prompt. - /// Keyed to the entered-prompt *cycle*, not the raw report seq — a - /// same-prompt redraw (completion list, `reset-prompt`) re-emits the - /// PS1-embedded `133;B` and would bump the seq while zle still holds the - /// handed-off text; re-engaging there would fork the two line buffers. - /// Only a command actually running starts a new cycle and re-engages the - /// editor. editor_handoff: Option<u64>, - /// Active Ctrl+R history search, if any. While set, the editor shows a - /// `(reverse-i-search)` prompt instead of the line and a menu of the ranked - /// matches floats beside it: typing edits the query (fuzzy, blended with - /// frecency), Ctrl+R/↓ and Ctrl+S/↑ move the selection, Enter accepts the - /// selection into the line, Cmd+Enter runs it outright, and Escape/Ctrl+G - /// cancels. reverse_search: Option<ReverseSearch>, - /// One-shot "shell integration didn't engage" notice (#46). Set when Ctrl+R - /// is pressed in a pane whose shell never reported OSC 133 — the history - /// menu the user is reaching for can't appear, and without this the feature - /// just looks broken. A figterm-style PTY shim (kiro-cli-term, qterm) that - /// swallowed the reports is the usual culprit, so the message names the - /// wrapper when the daemon's foreground query recognizes one. Cleared on - /// the next keystroke, after a timeout, or if integration engages late. integration_notice: Option<String>, - /// Latch so the notice shows at most once per pane — a diagnostic, not a nag. integration_notice_shown: bool, - /// When this view was created. Ctrl+R inside the startup grace window stays - /// silent: slow rc files mean integration legitimately hasn't reported yet. created_at: std::time::Instant, - /// True while a left-drag that began on the command-editor line is in progress, - /// so mouse-move extends the editor selection rather than the terminal's. editor_selecting: bool, - /// True from a left press on the command-editor line until its release — - /// unlike [`Self::editor_selecting`] it also covers the double/triple-click - /// word/line selections, which don't arm drag-extend. Tells the mouse-up - /// that the ended gesture selected in the editor, so copy-on-select copies - /// the editor's selection rather than the terminal's. editor_select_gesture: bool, - /// When a drag-extend is armed by a double-click, the word range the - /// double-click selected, so the drag can grow the selection by whole words - /// (keeping the anchor word intact). `None` for a plain char-granular drag. editor_drag_word: Option<(usize, usize)>, - /// Sticky target column for vertical caret motion (↑/↓ across a multi-line - /// buffer). Set on the first vertical step from the caret's current visual - /// column and preserved across a run of ↑/↓ so passing through a short line - /// doesn't lose the column; any other motion or edit clears it (`None`). editor_goal_col: Option<usize>, - /// The URL currently under the mouse (an OSC 8 hyperlink or a bare URL found - /// in the row text), if any. Drives the hover underline and the pointing-hand - /// cursor that mark a link as clickable. Stored in scroll-stable grid - /// coordinates so it survives a scroll without a fresh mouse-move; see - /// [`HoveredLink`]. pub(super) hovered_link: Option<HoveredLink>, - /// Focus listeners kept alive for the lifetime of the view. _focus_subs: Vec<gpui::Subscription>, } -/// A link under the mouse, remembered so the grid can underline its cells. The -/// endpoints are alacritty grid points (line = display row minus the scroll -/// offset), which stay fixed as the viewport scrolls. A link the terminal -/// wrapped — or a producer split across rows with a hard newline — spans -/// several rows, so `start` and `end` can sit on different lines. #[derive(Clone, Debug, PartialEq)] pub(super) struct HoveredLink { pub start: Point, @@ -560,43 +182,20 @@ enum LoopbackOpen { NotLoopback, } -/// How a ⌘/Ctrl-clicked URL should be opened, decided before anything is done -/// about it. -/// -/// Split out as a pure decision so the branch a pane takes is testable without a -/// daemon, a connection, or a browser — the three things this feature otherwise -/// needs all at once. #[derive(Clone, Debug, PartialEq)] pub(super) enum LoopbackPlan { - /// Not a loopback URL, forwarding is off, or the pane's shell runs on this - /// machine: hand the URL to the OS unchanged. Direct, - /// A remote whose `localhost` *is* the client's — WSL shares the network - /// namespace with its Windows host (the exception). No forward is - /// built; the original URL already resolves. NoForwardNeeded, - /// A native-SSH pane ("连一下"): forward on the pane's own connection, owned - /// by the pane, torn down with it. ForwardOnPane(u64), - /// A remote-workspace pane ("在上面开发"): forward on the workspace's - /// connection, owned by the workspace so it outlives the pane. ForwardOnWorkspace(Box<crate::terminal::PaneWorkspace>), } -/// The ⌘-click routing rule, as a pure function of what the pane is. -/// -/// The workspace is checked *before* the pane's own remote context, and that -/// order is the whole point: a remote workspace's panes are ordinary local -/// shells as far as the remote daemon is concerned, so they carry no -/// `RemoteContext` at all and the SSH-pane test below would decline them. pub(super) fn loopback_plan( enabled: bool, workspace: Option<&crate::terminal::PaneWorkspace>, remote_kind: Option<crate::daemon::protocol::RemoteKind>, pane_id: u64, ) -> LoopbackPlan { - // The user's off switch wins everywhere, including hover detection — a - // `localhost:3000` that will not be forwarded must not underline either. if !enabled { return LoopbackPlan::Direct; } @@ -604,16 +203,12 @@ pub(super) fn loopback_plan( if ws.shares_localhost() { return LoopbackPlan::NoForwardNeeded; } - // A non-WSL workspace with nothing to forward over cannot be reached; - // opening the client's own `localhost` would be a wrong answer dressed - // up as a right one, so decline and let the URL through untouched. if ws.spec.is_none() { log::warn!("remote workspace has no connection spec; not forwarding localhost links"); return LoopbackPlan::Direct; } return LoopbackPlan::ForwardOnWorkspace(Box::new(ws.clone())); } - // A native-SSH pane forwards over its own russh connection (FR-F4). match remote_kind { Some(crate::daemon::protocol::RemoteKind::NativeSsh) => { LoopbackPlan::ForwardOnPane(pane_id) @@ -622,11 +217,6 @@ pub(super) fn loopback_plan( } } -/// A submitted command whose history-file record is deferred so it can carry -/// the command's exit code (like zsh's `INC_APPEND_HISTORY_TIME`). `seq` is -/// [`RemoteTerminal::prompt_seq`] at submit time: a later report that puts the -/// shell back at its prompt means the run completed and `last_exit_code()` is -/// this command's. Flushed without an exit code if the view goes away first. struct PendingHistory { line: String, cwd: Option<std::path::PathBuf>, @@ -634,24 +224,12 @@ struct PendingHistory { seq: u64, } -/// Where a run of ⌥. presses has walked to (see -/// [`TerminalView::last_word_nav`]). struct LastWordWalk { - /// Index into `history` the last press took its word from. entry: usize, - /// Char offset of the word it inserted — the next press swaps that span - /// for the word from an older entry. at: usize, - /// The word itself: both the span's length and a fingerprint. Edits that - /// bypass `handle_editor_key` (IME-committed text, a paste, a completion - /// pick, ⌘Z) can't clear `last_word_nav`, so before resuming, the walk - /// checks the line still holds this word at `at` with the caret at its - /// end — anything else means an edit intervened and the walk starts over - /// rather than eating it. word: String, } -/// Seconds since the unix epoch — the timestamp history records carry. fn unix_now() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -659,76 +237,32 @@ fn unix_now() -> u64 { .unwrap_or(0) } -/// Outcome of a ⌘ shortcut at the terminal surface — the three control-flow -/// paths the key dispatcher needs. Splitting the ⌘ block into its own method -/// keeps `on_key_down` readable; the caller maps each variant back to the -/// stop-propagation / return / fall-through it originally inlined. enum CmdKey { - /// Handled here — stop propagation and return. Consumed, - /// Not ours — return without stopping, so the app shell (new tab, split, …) - /// gets it. Bubble, - /// Recognized but not applicable (e.g. ⌘C with no selection) — fall through - /// to the editor / PTY paths below. FallThrough, } -// The minimum foreground-command duration worth a "finished" notification is -// configurable (`Config::notify_threshold_secs`, default 10s); read live where -// the notification is posted rather than pinned to a const here. - -/// How long gap input may be held client-side before it must be released to -/// the PTY (see the `hold` module). Long enough for a fast command's full -/// round trip (`133;D` report back to this client — tens of ms), short enough -/// that typing into a program that reads stdin right after launch feels -/// instant once the window lapses. const HOLD_WINDOW: std::time::Duration = std::time::Duration::from_millis(150); -/// How long after pane creation Ctrl+R stays silent about missing shell -/// integration: slow rc files can take several seconds to reach the first -/// prompt report, and calling integration broken while the shell is still -/// starting up would be a false alarm. const INTEGRATION_GRACE: std::time::Duration = std::time::Duration::from_secs(8); -/// How long the integration notice stays up when no keystroke dismisses it. const INTEGRATION_NOTICE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15); -/// Floor on how often an *opportunistic* git probe may run for one cwd (see -/// [`GitRefresh::Opportunistic`]). Short enough that the sidebar's counts feel -/// live while an agent works, long enough that a burst of tool calls — or an -/// alt-tab into a window holding a dozen panes — collapses into one `git` -/// shell-out per repo instead of a dozen. const OPPORTUNISTIC_GIT_GAP: std::time::Duration = std::time::Duration::from_millis(1500); -/// Why a git-status probe is being asked for — the two classes get opposite -/// treatment when one is already in flight. #[derive(Clone, Copy, PartialEq, Eq, Debug)] enum GitRefresh { - /// A rare state change that must not be missed: the pane changed - /// directory, a command finished, an agent turn ended. Queues behind an - /// in-flight probe (which then reruns) rather than being dropped. Edge, - /// A cheap signal that repeats on its own: the window regained focus, the - /// agent finished a tool call. Dropped outright when a probe is in flight - /// or one ran within [`OPPORTUNISTIC_GIT_GAP`] — the next one will come. Opportunistic, } -/// Fig-descended PTY shims known to exec over the shell we spawned and re-host -/// it on a nested PTY without forwarding OSC 133 — which starves shell -/// integration and silently kills the whole command-editor overlay (#46). -/// Matched against the foreground process name the daemon reports; `contains` -/// because the shim may present as e.g. `zsh (kiro-cli-term)`. fn known_pty_shim(fg: &str) -> Option<&'static str> { ["kiro-cli-term", "figterm", "qterm", "cwterm"] .into_iter() .find(|shim| fg.contains(shim)) } -/// The integration-notice text. Naming the shim matters: "install shell -/// integration" advice would mislead — the hooks *are* installed, something -/// between the shell and tty7 is eating their reports. fn integration_notice_message(wrapper: Option<&str>) -> String { match wrapper { Some(w) => format!( @@ -743,10 +277,6 @@ fn integration_notice_message(wrapper: Option<&str>) -> String { } } -/// Post a desktop notification that a command finished. Best-effort and -/// non-blocking: routed through [`super::remote::notify_desktop`] (the single -/// `notify-rust` entry point shared with the escape-sequence path), so there's no -/// `osascript` subprocess and every notification goes through one code path. fn notify_command_finished(label: &str, elapsed: std::time::Duration) { let secs = elapsed.as_secs(); let label = label.trim(); @@ -758,24 +288,15 @@ fn notify_command_finished(label: &str, elapsed: std::time::Duration) { super::remote::notify_desktop(Some("tty7"), &body); } -/// Post a branded "the agent finished" notification — the coding-agent form of -/// [`notify_command_finished`], titled with the agent so it's obvious *which* -/// session came back. fn notify_agent_finished(agent: crate::core::cli_agent::CLIAgent, elapsed: std::time::Duration) { let secs = elapsed.as_secs(); let body = format!("Finished after {secs}s"); super::remote::notify_desktop(Some(agent.display_name()), &body); } -/// Ring the OS system bell for the `Audible` bell mode. Returns `true` if a -/// sound was actually requested, `false` on platforms without a portable beep -/// (the caller then falls back to the visual flash so the bell is never silent). fn ring_system_bell() -> bool { #[cfg(target_os = "macos")] { - // A parameter-less AppKit call that just asks the system to play the - // user's alert sound; invoked on the main (gpui app) thread, where every - // `AlacEvent` is handled. objc2_app_kit::NSBeep(); true } @@ -785,22 +306,6 @@ fn ring_system_bell() -> bool { } } -/// Build the byte sequence written to the PTY for a paste. Under bracketed paste -/// the content is wrapped in the `ESC[200~` / `ESC[201~` markers, and every ESC -/// (`0x1b`) byte is stripped from the content first. Without that strip, clipboard -/// text carrying its own `ESC[201~` end-marker could terminate the paste early and -/// have whatever follows (e.g. a newline + command) run as ordinary typed input — -/// a "bracketed-paste escape" that defeats the very protection the markers give -/// the shell. Removing ESC makes an embedded `ESC[201~` unrepresentable, matching -/// alacritty's own paste filtering. `0x1b` is ASCII, so it never appears inside a -/// multi-byte UTF-8 char — filtering the byte stream can't split a codepoint. -/// Legitimate pasted text does not contain raw ESC, so this is a no-op for it. -/// -/// Without bracketed paste, line breaks are normalized to `\r` — the byte the -/// Enter key sends — matching xterm/alacritty. A raw-mode app (the only -/// consumer of this path, since the prompt routes pastes into the editor) -/// reads keys, not lines, and many bind accept/submit to CR only; leaving `\n` -/// in would feed them a byte no keyboard produces. fn paste_bytes(text: &str, bracketed: bool) -> Vec<u8> { if bracketed { let mut bytes = b"\x1b[200~".to_vec(); @@ -812,45 +317,7 @@ fn paste_bytes(text: &str, bracketed: bool) -> Vec<u8> { } } -/// Build the byte sequence that submits the local editor's buffer to the shell. -/// -/// The line count is what matters here. Replaying every embedded newline as its -/// own CR makes the shell's line editor run a *full* prompt cycle per line — -/// preexec, the user's precmd chain (git-status prompts, conda…), a -/// syntax-highlight pass over the whole buffer, plus our own OSC 133 `D` -/// follow-up work. A 30-line paste costs 30 of them and visibly crawls down the -/// screen, as if the command were being retyped. Under bracketed paste the -/// whole buffer goes in as a single paste and one CR accepts it: one prompt -/// cycle whatever the line count. -/// -/// Continuation still works — better, in fact. zle keeps the embedded newlines -/// in its buffer, so a backslash / open-quote / heredoc command parses as one -/// unit; the PS2 assembly the per-line replay existed for now happens inside -/// the buffer instead of on the wire. The visible difference is that the block -/// executes as one unit and lands in the shell's history as one entry — which -/// is what pasting multi-line text into any other terminal already does, and -/// what our own history has always recorded. -/// -/// ESC is stripped first (in both branches, unlike the paste path): clipboard -/// text carrying its own `ESC[201~` could otherwise close the paste early and -/// have the rest run as typed input, and a raw ESC reaching zle unbracketed is -/// an editor command, not text. CR is normalized away in the same pass, so a -/// CRLF clipboard is one line break either way rather than a stray blank Enter. -/// -/// An empty buffer skips the markers: zsh's `bracketed-paste-magic` (which -/// oh-my-zsh turns on) errors on a paste with nothing between them. -/// -/// The agent-prompt path already delivers multi-line text this way — see -/// [`crate::core::agent_prompt::submit_bytes`], which is this shape minus the -/// unbracketed fallback (an agent TUI always enables the mode). fn submit_bytes(line: &str, bracketed: bool) -> Vec<u8> { - // A CRLF clipboard pastes into the editor verbatim, so the `\r` has to go - // before either branch sees it. Unbracketed it would be a second Enter - // (`\r\n` → `\r\r`, a blank line submitted mid-command); bracketed it would - // ride inside the markers and land on whatever the far side happens to do - // with a CR in a paste — zsh turns it into a newline, so the block gains a - // blank line, and a shell that doesn't leaves a literal `^M` in the command. - // One `\n` per line is the shape both branches are written for. let clean: String = line .replace("\r\n", "\n") .chars() @@ -862,33 +329,18 @@ fn submit_bytes(line: &str, bracketed: bool) -> Vec<u8> { bytes } -/// Strip trailing spaces/tabs from every line, preserving the line structure -/// (and any final newline). Used by copy when `clipboard_trim_trailing_spaces` -/// is on so selections don't carry cell-padding whitespace. fn trim_trailing_spaces(text: &str) -> String { - // `split('\n')` keeps empty segments, so a trailing newline round-trips (the - // final empty segment re-joins into it) and a string without one gains none. text.split('\n') .map(|line| line.trim_end_matches([' ', '\t'])) .collect::<Vec<_>>() .join("\n") } -/// Backslash-escape the shell-significant characters in a filesystem path so a -/// pasted filename with spaces (or `$`, `'`, `(`, `&`…) reaches the shell as a -/// single argument instead of splitting. Mirrors how macOS Terminal.app turns -/// a dropped/pasted file into command-line text. An empty path -/// becomes `''`. -/// -/// A newline/CR can't be backslash-escaped into a literal (`\<newline>` is a -/// shell line-continuation), so a pathological filename containing one is -/// single-quoted whole instead. fn shell_escape_path(path: &str) -> String { if path.is_empty() { return "''".to_string(); } if path.contains(['\n', '\r']) { - // Close/re-open the single quote around each embedded `'`. return format!("'{}'", path.replace('\'', "'\\''")); } let mut out = String::with_capacity(path.len() + 8); @@ -926,16 +378,6 @@ fn shell_escape_path(path: &str) -> String { out } -/// Decide what text a paste should insert for a clipboard item. -/// -/// When the clipboard holds file references — a Finder "Copy" carries -/// `ExternalPaths` and (usually) no string rep — we shell-escape each path and -/// join them with a single space, so pasting a file drops a ready-to-use, -/// space-safe path (multiple files → space-separated args), matching macOS -/// Terminal.app. gpui's own `ClipboardItem::text()` would instead -/// concatenate the paths with *no* separator and never escape them. -/// -/// Otherwise (plain text, or an image with no text) we defer to `text()`. fn clipboard_paste_text(item: &ClipboardItem) -> Option<String> { let escaped: Vec<String> = item .entries() @@ -953,15 +395,6 @@ fn clipboard_paste_text(item: &ClipboardItem) -> Option<String> { item.text() } -/// Stage a clipboard image as a temp file so [`paste_clipboard_image`] can paste -/// its path. Web-friendly formats a coding agent's vision accepts (PNG/JPEG/GIF/ -/// WebP) are written through untouched; anything else — notably the BMP that -/// Windows screenshots (`CF_DIB`) arrive as — is transcoded to PNG, since agent -/// vision rejects those. Returns the path, or `None` if decoding/writing failed. -/// -/// The filename is keyed on gpui's content hash of the bytes, so re-pasting the -/// same screenshot reuses one file instead of accumulating temp copies (this -/// crate has no `Date`/random to mint a unique name with anyway). #[cfg(not(target_os = "macos"))] fn write_clipboard_image(img: &gpui::Image) -> Option<std::path::PathBuf> { use gpui::ImageFormat; @@ -980,8 +413,6 @@ fn write_clipboard_image(img: &gpui::Image) -> Option<std::path::PathBuf> { Some(path) } -/// Decode `bytes` (in `format`) and re-encode as PNG. SVG can't be rasterized by -/// the `image` crate, so it — and any decode/encode failure — yields `None`. #[cfg(not(target_os = "macos"))] fn transcode_to_png(bytes: &[u8], format: gpui::ImageFormat) -> Option<Vec<u8>> { use gpui::ImageFormat as G; @@ -1004,23 +435,6 @@ fn transcode_to_png(bytes: &[u8], format: gpui::ImageFormat) -> Option<Vec<u8>> Some(out) } -/// The font fallback chain: the user's configured list, then this platform's -/// stock faces, then the bundled "Hack" pinned to the end. -/// -/// Hack ships inside the binary (`register_bundled_fonts`) and covers the -/// symbols prompt themes lean on — `❯`, `➜`, box drawing, the sharp powerline -/// wedges — with ink that fits a monospace advance. Without this anchor, a -/// custom `font_family` that lacks one of those codepoints falls through the -/// whole configured list into the OS cascade, which happily serves a -/// proportional glyph wider than the cell that `paint_glyphs`' per-cell clip -/// then truncates (issue #17's severed `➜`). -/// -/// Hack carries no CJK at all (1548 codepoints mapped, zero ideographs), so on -/// a chain that names only macOS faces every Chinese character falls through to -/// the OS cascade too. [`platform_last_resort_fallbacks`] is appended for the -/// same reason the Hack anchor exists — to keep the last word ours rather than -/// the cascade's — and it repairs already-persisted configs, which a change to -/// `Config::default` alone would never reach. fn fallback_chain(family: &str, configured: &[String]) -> Vec<String> { let mut chain = configured.to_vec(); let mut pin = |name: &str| { @@ -1036,40 +450,6 @@ fn fallback_chain(family: &str, configured: &[String]) -> Vec<String> { } impl TerminalView { - /// The fallible half of a shell-backed view: establish the daemon pane - /// *before* the view is built, so a refused spawn (daemon down, spawn - /// error) comes back as an `Err` the caller can report. Splitting it out - /// matters beyond tidiness: the view is constructed inside `cx.new`, deep - /// under gpui's `extern "C"` input callbacks, where a panic can't unwind - /// and aborts the process instead. Mirrors - /// [`Self::spawn_native_ssh_terminal`]. - /// - /// 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 (with the caller's shell - /// pick, if any). - /// - /// **A `restore_pane` that is gone falls back to a fresh pane** rather than - /// failing. Callers do check first, but neither check is a guarantee: a - /// local one asks the daemon and can be raced by the pane exiting, and a - /// remote one cannot afford to ask at all (`alive_panes_on` is a blocking - /// round trip and the UI thread is where it would run) — trying the attach - /// *is* the question there. Either way an id that no longer exists is the - /// ordinary state of a workspace whose sessions were ended, and the answer - /// to it is the same as for a session written before the daemon existed: a - /// fresh shell in the saved cwd. `restored` says which happened, because - /// what the caller does next differs — see [`ShellParts`]. - /// - /// **`workspace: None` is the local path, unchanged down to the byte** — - /// [`PaneRoute::for_workspace`] answers `Local`, and `Local` is a bare - /// `transport::connect()` with no header in front of it. There is no - /// "remote-aware" branch a local pane passes through. - /// - /// `Some(_)` is the whole of what makes a remote pane remote: the daemon is - /// told which machine the connection is for before any `ClientMsg` goes out, - /// and everything after — `Spawn`, `Attach`, `Input`, `Output` — lands on - /// that machine's `tty7-server` instead of this one's daemon. pub fn spawn_shell_terminal_in( workspace: Option<crate::terminal::PaneWorkspace>, working_directory: Option<std::path::PathBuf>, @@ -1080,8 +460,6 @@ impl TerminalView { let route = crate::terminal::PaneRoute::for_workspace(workspace.as_ref()); let attached = match restore_pane { Some(id) => match RemoteTerminal::attach_on(&route, TermSize::new(80, 24), 8, 17, id) { - // An attached pane keeps whatever shell it already runs; the - // pick that spawned it (if any) isn't persisted. Ok(terminal) => Some((terminal, id, None)), Err(e) => { log::info!("pane {id} is gone on its machine ({e:#}); spawning fresh"); @@ -1116,8 +494,6 @@ impl TerminalView { }) } - /// Wrap an established shell pane (from [`Self::spawn_shell_terminal_in`]) in - /// a view. Infallible by construction — see that function. pub fn from_shell_parts( parts: ShellParts, window: &mut Window, @@ -1131,31 +507,14 @@ impl TerminalView { view } - /// Whether this pane came back as the one it was asked to re-attach to. - /// `false` means a fresh shell — see the field, and - /// [`ShellParts::restored`]. pub(crate) fn restored(&self) -> bool { self.restored } - /// The workspace whose window created this pane, or `None` when the - /// creating path predates the field. See the field for what reads it. pub fn owner_workspace(&self) -> Option<crate::core::session::WorkspaceId> { self.owner_workspace } - /// Spawn a native (russh) SSH pane for `spec` and build the view around it - /// (PRD FR-C1/E-series). The caller (`ui::ssh_connect`) has already resolved - /// keychain secrets into `spec`; this view retains only the **secret-free** - /// copy ([`NativeSshSpec::without_secrets`]) for session-restore respawn and - /// the in-pane reconnect. Auth/host-key prompts and the connection phase ride - /// this pane's own stream and surface through the usual `AuthPromptReady` - /// path. - /// The fallible half of a native-SSH view: establish the daemon pane first, - /// so a refused spawn (daemon down, stale pre-SSH daemon, protocol error) - /// surfaces as an `Err` the caller can report — building the view itself - /// (inside `cx.new`, via [`Self::from_native_ssh_parts`]) has no failure - /// path of its own. pub fn spawn_native_ssh_terminal( spec: Box<crate::daemon::protocol::NativeSshSpec>, working_directory: Option<std::path::PathBuf>, @@ -1175,8 +534,6 @@ impl TerminalView { }) } - /// Wrap an established native-SSH pane (from - /// [`Self::spawn_native_ssh_terminal`]) in a view. pub fn from_native_ssh_parts( parts: NativeSshParts, window: &mut Window, @@ -1187,20 +544,12 @@ impl TerminalView { view } - /// Build the view around an already-connected terminal. Split from [`new`] - /// so tests can hand in a `RemoteTerminal` backed by a plain socketpair - /// and exercise the event plumbing without a live daemon — see - /// [`quiet_test_pane`], which the UI-level tests build their tabs from. fn with_terminal( terminal: RemoteTerminal, pane_id: u64, window: &mut Window, cx: &mut Context<Self>, ) -> Self { - // Font comes from user config: a primary face plus fallbacks so glyphs it - // lacks still render (e.g. a Nerd Font supplies powerline / box separators - // and an emoji face covers pictographs). Defaults are Menlo + Hasklug Nerd - // Font Mono + Apple Color Emoji at 13px. let config = cx.global::<Config>(); let font_family = config.font_family.clone(); let fallbacks = fallback_chain(&font_family, &config.font_fallbacks); @@ -1216,8 +565,6 @@ impl TerminalView { if let Some(features) = &font_features { font.features = features.clone(); } - // Optional distinct bold/italic faces, each carrying the same fallback - // chain so glyph coverage matches the primary face. let alt_font = |family: &Option<String>| { family.as_ref().map(|f| { let mut af = gpui::font(f.clone()); @@ -1233,12 +580,6 @@ impl TerminalView { let focus_handle = cx.focus_handle(); - // Pump backend events → redraws. The reader thread sends one Wakeup per - // output chunk, and a TUI redrawing at full tilt (Claude Code streaming) - // produces long bursts of them; drain whatever queued up behind the - // first event and collapse the Wakeups to one, so a burst costs one - // update+notify instead of scheduling dozens of no-op round-trips - // between two frames. let events = terminal.events.clone(); cx.spawn(async move |this, cx| { let mut batch = Vec::new(); @@ -1250,8 +591,6 @@ impl TerminalView { let res = this.update(cx, |view, cx| { let mut woke = false; for ev in batch.drain(..) { - // A Wakeup only marks the view dirty, so one per batch - // is enough; order relative to other events is moot. if matches!(ev, AlacEvent::Wakeup) && std::mem::replace(&mut woke, true) { continue; } @@ -1263,15 +602,6 @@ impl TerminalView { Ok(woke) => woke, Err(_) => break, }; - // `notify()` above only dirties windows whose tracked-entity set - // still contains this view; if one frame drops the view from - // that set, every later notify is filtered, the window never - // goes dirty, never redraws, and so never re-tracks the view — - // grid updates then sit unseen until some input event forces a - // refresh. Dirty the view's current window directly so PTY - // output always reaches the screen; painting stays vsync-paced, - // so a batch costs the same one frame either way. Failure here - // only means no window right now — never tear down the pump. if woke { let _ = this.update_in(cx, |_, window, _| window.refresh()); } @@ -1279,19 +609,10 @@ impl TerminalView { }) .detach(); - // Track focus so the cursor blinks only while focused, resetting the - // blink phase on focus changes so it's solid the instant focus returns. - // Focus changes are also reported to the app when it asked for them - // (mode 1004): vim's autoread, tmux's focus hooks and prompt - // frameworks' cursor dimming all key off `CSI I`/`CSI O`. let focus_subs = vec![ cx.on_focus_in(&focus_handle, window, |view, _window, cx| { view.focused = true; view.cursor_visible = true; - // Looking at the pane marks its finished turn read, so the tab - // avatar's green Done dot clears — unless this focus-in is - // just the context menu handing focus back after a manual - // "Mark as Unread" (the one-shot guard eats it). if view.keep_unread_on_focus { view.keep_unread_on_focus = false; } else { @@ -1307,9 +628,6 @@ impl TerminalView { }), ]; - // Blink the block cursor. Toggling and the redraw happen only while - // focused; unfocused we draw a static hollow box and skip the work. - // The task stops naturally once the view is dropped (update → Err). cx.spawn(async move |this, cx| { loop { cx.background_executor() @@ -1317,12 +635,7 @@ impl TerminalView { .await; if this .update(cx, |view, cx| { - // The search field blinks its own caret; here we only drive - // the terminal's block cursor. if view.focused { - // Honor `cursor_blink`: when off, keep the cursor - // solid (force it visible if a prior toggle left it - // hidden) instead of flipping it. if cx.global::<Config>().cursor_blink { view.cursor_visible = !view.cursor_visible; cx.notify(); @@ -1340,10 +653,6 @@ impl TerminalView { }) .detach(); - // Poll the PTY's foreground process group once a second to notice when a - // long-running command finishes while the window is in the background, - // and post a desktop notification. `update_in` gives us the Window so we - // can check whether it's currently active. cx.spawn(async move |this, cx| { loop { cx.background_executor() @@ -1361,8 +670,6 @@ impl TerminalView { window.focus(&focus_handle, cx); - // Rank without a directory bias for now; the first `poll_foreground` learns - // the cwd and re-ranks (favouring commands run in this directory). let history = super::history::load(); let history_ranked = super::history::rank_by_frecency( &history.entries, @@ -1416,11 +723,6 @@ impl TerminalView { running_title: String::new(), running_agent: None, last_agent_status: None, - // Empty rather than seeded from the saved layout: a pane that comes - // back attached to a running agent then reports the id it already - // had, which reads as a change and saves once. Harmless, and the - // alternative — trusting the record — would skip the save that - // fixes a record which is *wrong*. last_agent_session: (None, None), agent_turn_started: None, agent_was_rich: false, @@ -1459,7 +761,6 @@ impl TerminalView { } } - /// Called from the element each frame with the measured grid geometry. pub fn set_grid_size( &mut self, cols: usize, @@ -1467,14 +768,6 @@ impl TerminalView { cell_width: Pixels, line_height: Pixels, ) { - // A remembered hover cell describes the *old* geometry: after a resize - // its row may not exist any more, and the pointer sits over a different - // cell regardless. Forget it — the next mouse move records a fresh one. - // (`grid_line` also refuses a stale row, so this is about not underlining - // the wrong cell, not about safety.) The link that cell resolved to goes - // with it: it is held in grid coordinates the reflow just moved text - // under, so keeping it would underline whatever now sits there (and hold - // the pointing-hand cursor over it) until the pointer moves again. if (cols, rows) != (self.terminal.size().cols, self.terminal.size().rows) { self.last_hover_cell = None; self.hovered_link = None; @@ -1488,8 +781,6 @@ impl TerminalView { ); } - /// Current working directory of this terminal's foreground process, used so - /// new tabs / splits can open in the same place. `None` if it can't be read. pub fn cwd(&self) -> Option<std::path::PathBuf> { self.terminal.foreground_cwd() } @@ -1498,111 +789,30 @@ impl TerminalView { self.terminal.remote_context() } - /// The pane's cwd *only when it names a directory on this machine* — the - /// accessor every local filesystem or `Command` use must go through. - /// - /// A remote pane's OSC 7 reports a path in the remote's namespace - /// (`/home/me/proj` from an SSH host). Feeding that to a local `git` or - /// `read_dir` is meaningless, and on Windows it is worse than meaningless: - /// `/home/me/proj` is not an absolute path there but a *drive-relative* - /// one, so it silently resolves to `C:\home\me\proj`. That usually just - /// fails an `exists()` check — but if such a directory happens to exist, - /// the pane reports an unrelated local repo's branch and diff as its own. - /// Correctness must not rest on that collision never happening. - /// - /// Note this gates on the pane being remote, not on the shape of the path: - /// a local shell may legitimately sit in a directory whose name looks - /// remote, and Git Bash reports genuinely local paths (via `pwd -W`) that - /// merely originate from a POSIX-looking shell. pub fn local_cwd(&self) -> Option<std::path::PathBuf> { self.paths_are_local().then(|| self.cwd())? } - /// Whether this pane's paths name files on *this* machine. - /// - /// Two independent ways they may not, and a caller that checks one and not - /// the other is silently wrong: - /// - **`remote_context`** — the pane's *own* process is elsewhere (a pane - /// tty7 dialled over SSH, a `wsl.exe` pane, a foreground `ssh`). The - /// daemon reports it, having watched the process. - /// - **`host_id`** — the pane belongs to a **remote workspace**. - /// Nothing about the pane itself is remote *from its own daemon's point - /// of view*: `tty7-server` on the far machine spawned an ordinary local - /// shell and reports `remote_context: None`, exactly as a local daemon - /// would. It is the daemon that is on another machine, which no - /// pane-level signal can express — only this side's binding knows. - /// - /// Which is why the second test cannot be folded into the first, and why - /// every use goes through here rather than re-deriving it: a routed pane - /// answering "yes, local" hands its remote cwd to `read_dir`, to `git`, and - /// to the file opener, all of which then answer about the wrong machine. fn paths_are_local(&self) -> bool { self.remote_context().is_none() && self.host_id.is_local() } - /// The pane's cwd *as the machine a sibling pane will spawn on reads it* — - /// what "+", a split, and the persisted session hand the new shell. - /// - /// Deliberately **not** [`local_cwd`](Self::local_cwd), and the difference - /// is the whole point. A window shows one machine, so a sibling lands - /// on the machine this pane's shell already runs on: for a remote-workspace - /// pane that is the far box, where `/home/me/proj` is exactly right and - /// withholding it would open every new tab at `$HOME` instead. - /// - /// What still has to decline is a pane whose shell is on a machine the - /// sibling will *not* be on — a native-SSH or WSL pane, whose window is - /// otherwise local. `remote_context` is precisely that condition, and it is - /// why these callers cannot share the strict accessor: they ask "will the - /// new shell be able to chdir here", not "is this file on my disk". pub fn spawnable_cwd(&self) -> Option<std::path::PathBuf> { self.remote_context().is_none().then(|| self.cwd())? } - /// The machine this pane's paths live on — what every filesystem or `git` - /// question about this pane must be asked of. - /// - /// `None` means that machine is not around: a remote workspace whose - /// connection closed, or one this process has not connected to yet. It is - /// never a local pane — the local host is always resolvable. Callers stop - /// there rather than falling back to this machine; asking the local git - /// about a remote path is how a pane ends up showing *another* repository's - /// branch, which is the bug this whole indirection exists to prevent. pub fn host(&self, cx: &gpui::App) -> Option<crate::ui::host_ops::SharedHost> { crate::ui::host_registry::HostRegistry::lookup(cx, self.host_id) } - /// The id alone — for the cache lookups and comparisons that never need the - /// host object, and so keep working while a machine is disconnected. pub fn host_id(&self) -> crate::ui::host_ops::HostId { self.host_id } - /// The remote workspace this pane belongs to, if any — what its port - /// forwards are owned by and whose SSH connection its SFTP rides. pub fn workspace(&self) -> Option<&crate::terminal::PaneWorkspace> { self.workspace.as_ref() } - /// Bind this pane to a remote workspace. - /// - /// A setter rather than a constructor argument so the one - /// `TerminalView::new` keeps the shape every existing call site already - /// passes, and a local pane needs no change at all. - /// - /// Called by [`from_shell_parts`](Self::from_shell_parts) with the workspace - /// the pane's *connection* was routed to, so the two cannot drift apart. - /// Calling it with anything else re-labels a pane without moving it, which - /// is why nothing else does. - /// - /// **This is also what points the pane's path questions at the right - /// machine.** The host id comes off the workspace's own `RemoteTarget`, - /// through the same `connection_key` the connection was opened under — so - /// the id resolves to the very host object - /// [`HostLinks::insert`](crate::ui::remote_connect::HostLinks::insert) - /// registered, with no second source of truth to drift from it. Setting the - /// route and setting the host is one operation because a pane that ran its - /// shell on one machine and its `git` on another would be worse than - /// either. pub fn set_workspace(&mut self, workspace: Option<crate::terminal::PaneWorkspace>) { self.host_id = workspace .as_ref() @@ -1610,31 +820,10 @@ impl TerminalView { self.workspace = workspace; } - /// Where this pane's daemon connections go. - /// - /// Anything addressed by `pane_id` — `Kill`, the restore-time `List` — has to - /// use this rather than the plain local call, because pane ids are per-daemon - /// and a remote pane's id names a *different* daemon's pane. Returns - /// [`PaneRoute::Local`] for a local pane, which is the call every one of - /// those sites makes today. pub fn pane_route(&self) -> crate::terminal::PaneRoute { crate::terminal::PaneRoute::for_workspace(self.workspace.as_ref()) } - /// The read-only degrade, as the keyboard sees it. - /// - /// **A local pane always answers `true`** — it has no connection to lose, - /// and `workspace()` is `None` for it, so this is a field test and not a - /// lookup. A remote pane defers to its workspace's connection state, which - /// is the workspace's business and not a pane's: five entry points ask, one - /// rule answers. - /// - /// Deliberately **not** consulted by - /// [`handle_event`](Self::handle_event)'s `PtyWrite` arm. Those bytes are - /// the emulator answering a question the *remote program* asked — a DA - /// report, an OSC colour reply, a cursor-position report — and gating them - /// would leave that program waiting for an answer that never comes, which - /// is a hang, not a degrade. The rule is about the *user's* keystrokes. fn accepts_input(&self, cx: &gpui::App) -> bool { let Some(ws) = self.workspace().map(|w| w.workspace) else { return true; @@ -1642,13 +831,6 @@ impl TerminalView { crate::ui::remote_workspace::workspace_accepts_input(cx, ws) } - /// Everything a reconnect needs to know about this pane, read on the UI - /// thread before the blocking half runs off it: which pane, and at what - /// geometry to bring it back ("以新客户端的尺寸 Resize"). - /// - /// The geometry is *this* client's current one, not the one the pane was - /// recorded at — a laptop that reconnects to a workspace it left on a - /// 4K monitor must not come back at 300 columns. pub fn relink_plan(&self) -> (u64, TermSize, u16, u16) { ( self.pane_id, @@ -1658,13 +840,6 @@ impl TerminalView { ) } - /// Adopt a stream that has already re-`Attach`ed to this pane. - /// - /// The title goes back to the neutral one: the pane wore - /// "tty7 — process exited" only because the *link* died, and leaving that on - /// a tab whose shell is demonstrably still running would be the UI lying - /// about the very thing this reconnect just disproved. A real title arrives - /// with the replay if the shell sets one. pub fn adopt_relink( &mut self, stream: crate::daemon::transport::Stream, @@ -1681,124 +856,46 @@ impl TerminalView { Ok(()) } - /// Let go of this pane's link without ending the pane. - /// - /// The takeover: another client attached, so this one stops being - /// the workspace's session. The pane stays on screen, read-only, exactly as - /// a dropped link leaves it — what must *not* happen is this client going on - /// holding a stream to a workspace somebody else is now typing in. pub fn detach_link(&mut self, cx: &mut Context<Self>) { self.terminal.detach_link(); cx.notify(); } - /// The pane's cwd *when it names a directory on this pane's own - /// [`host`](Self::host)* — the accessor the git probe, the diff and the - /// worktree actions go through. - /// - /// This is the generalisation of [`local_cwd`](Self::local_cwd), and the - /// two answer differently in exactly one case. `local_cwd` asks "is this - /// path on *this machine*", because its callers do something local with it: - /// open a file, spawn a local shell, persist a cwd a local shell will be - /// restored into. This one asks "is this path on the machine I would ask - /// about it", which is the weaker and more useful question — a pane whose - /// shell runs over SSH has a perfectly good cwd, it just isn't here. - /// - /// The gate is *not* "is the pane remote". It is "does the pane's host - /// agree with where the pane's shell runs": a remote pane still paired with - /// the local host has a cwd nobody in this process can answer for, and - /// handing `/home/me/proj` to a local `git` is the collision that gate - /// exists to prevent (worse than useless on Windows, where that path is - /// drive-relative and resolves to `C:\home\me\proj`). A remote-workspace - /// pane does have its own host, so for it this returns the remote path and - /// every caller below answers about the remote repository. pub fn host_cwd(&self) -> Option<std::path::PathBuf> { self.cwd_is_on_host().then(|| self.cwd())? } - /// Whether paths this pane reports are in [`host`](Self::host)'s namespace. - /// See [`host_cwd`](Self::host_cwd) for why this is not `remote_context() - /// .is_none()`. - /// - /// "Runs elsewhere" is [`paths_are_local`](Self::paths_are_local) negated, - /// **not** `remote_context().is_some()`. A remote-workspace pane's shell is - /// perfectly local *to the machine it runs on*, so the `tty7-server` there - /// reports no remote context for it — the fact that it is elsewhere is - /// carried by the workspace, which is the other half of that accessor. fn cwd_is_on_host(&self) -> bool { cwd_is_on_host(!self.paths_are_local(), self.host_id.is_local()) } - /// The coding agent running in this pane's foreground, or `None` when none - /// is. Identity comes from the daemon's foreground-`argv` detection (plus - /// the sentinel event channel, which can brand wrappers argv can't see - /// through). The tab avatar brands the pane with it. See - /// [`crate::core::cli_agent`]. pub fn agent(&self) -> Option<crate::core::cli_agent::CLIAgent> { self.terminal.foreground_agent() } - /// The agent's rich session status (idle / working / waiting / done + - /// native session id), when the pane's agent reports events over the - /// sentinel OSC channel (or the opaque notification fallback). Drives the - /// avatar's status dot, "needs your input" notifications, and resume. An - /// output-idle *guess* is deliberately still absent — agents are quietest - /// while thinking, so only agent-reported state is trusted. pub fn agent_session(&self) -> Option<crate::core::cli_agent::AgentSessionState> { self.terminal.agent_session() } - /// Whether this pane's finished turn (the green `Done` dot) is unread — a - /// turn ended that the user hasn't looked at since (see - /// [`agent_result_unread`](Self::agent_result_unread) field). Feeds the - /// tab's unread count (the avatar dot's count badge); the dot itself shows - /// for any `Done`. pub fn agent_result_unread(&self) -> bool { self.agent_result_unread } - /// Re-flag this pane's finished turn as unread — the tab context menu's - /// "Mark as Unread". `refocus_incoming` is true for the pane the dismissed - /// menu is about to hand window focus back to (the active tab's focused - /// leaf): that focus-in is the menu closing, not the user reading the - /// result, so it must not clear the mark it just made. pub fn mark_agent_result_unread(&mut self, refocus_incoming: bool) { self.agent_result_unread = true; self.keep_unread_on_focus = refocus_incoming; } - /// The git snapshot for this pane's cwd (branch + working-tree diff), for - /// the sidebar row's branch line — read from the shared per-repo - /// [`GitStatusCache`](crate::terminal::git_status::GitStatusCache), so - /// every pane in one work tree reports the same numbers. `None` outside a - /// git work tree or before the repo's first background probe lands. pub fn git_status(&self, cx: &App) -> Option<crate::terminal::git_status::GitStatus> { let cwd = self.git_status_cwd.as_ref()?; cx.try_global::<crate::terminal::git_status::GitStatusCache>()? .status_for(self.host_id, cwd) } - /// The cwd the pane's git line reads from — the same path [`git_status`] - /// resolves through, so the diff overlay opened from that line probes the - /// identical repo (not a fresh foreground-cwd syscall that could disagree - /// mid-command). `None` outside a repo-probe-worthy state. - /// - /// [`git_status`]: Self::git_status pub fn git_status_cwd(&self) -> Option<&std::path::Path> { self.git_status_cwd.as_deref() } - /// Re-probe this pane's git status opportunistically — for callers holding - /// a reason to suspect the tree moved without the pane seeing it. The one - /// that matters is the window regaining focus: edits made in an editor, or - /// by a `git` command run in another app entirely, produce no event here at - /// all, so without this the counts would sit stale until the user happened - /// to run something in the pane. - /// - /// Throttled and in-flight-deduped (see [`GitRefresh::Opportunistic`]), so - /// calling it for every pane on every activation is cheap. A pane with no - /// resolved cwd yet is skipped rather than being pinned to `None` — its - /// first real probe is the poll loop's job. pub fn refresh_git_status_now(&mut self, cx: &mut Context<Self>) { let cwd = self.git_status_cwd.clone(); if cwd.is_some() { @@ -1806,8 +903,6 @@ impl TerminalView { } } - /// The current grid selection as text, if any non-blank one exists — the - /// source for "Agent: Send Selection". pub fn selection_text(&self) -> Option<String> { self.terminal .term @@ -1816,55 +911,33 @@ impl TerminalView { .filter(|t| !t.trim().is_empty()) } - /// Deliver a built prompt into this pane's PTY as a bracketed paste + CR — - /// the submit path for the agent context-feed commands. See - /// [`crate::core::agent_prompt::submit_bytes`]. pub fn send_agent_prompt(&self, prompt: &str) { self.terminal .write(crate::core::agent_prompt::submit_bytes(prompt)); } - /// Type one command line + Enter into the pane's PTY, as if the user had. - /// Used by session restore to hand a fresh shell an agent resume command; - /// the bytes queue in the PTY until the (possibly still-starting) shell - /// reads them. pub fn run_command_line(&self, cmd: &str) { self.terminal.write(format!("{cmd}\r").into_bytes()); } - /// 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() } - /// The secret-free native-SSH spec this pane ran, if it is a native-SSH pane. - /// Persisted for session restore and re-used by the in-pane reconnect - /// (`RestartSshSession`). pub fn ssh_spec(&self) -> Option<Box<crate::daemon::protocol::NativeSshSpec>> { self.ssh_spec.clone() } - /// The native-SSH connection phase for the status strip (PRD FR-E1); `None` - /// for a non-native pane. pub fn ssh_phase(&self) -> Option<crate::daemon::protocol::SshPhase> { self.terminal.ssh_phase() } - /// Whether this native-SSH pane's connection is dead (shell exited or the - /// connect failed) and so eligible for an in-pane reconnect. False for live - /// panes and non-native panes. pub fn ssh_disconnected(&self) -> bool { self.ssh_spec.is_some() && self.terminal.exited } 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`). self.terminal.poll_exited(); - // A native-SSH pane may have queued an auth/host-key prompt behind this - // wakeup; let the app drain it into the in-pane sheet. Cheap check — - // only true during the brief pre-Output auth window. if self.terminal.has_pending_auth() { cx.emit(AuthPromptReady); } @@ -1881,22 +954,11 @@ impl TerminalView { AlacEvent::PtyWrite(text) => self.terminal.write(text.into_bytes()), AlacEvent::ChildExit(_) | AlacEvent::Exit => { self.terminal.exited = true; - // Say which of the two things happened. For a local pane both - // read the same and the wording is unchanged; for a remote - // workspace they are opposite facts, and "process exited" on a - // pane whose shell is still running on the far machine is the - // one claim the degrade must not make — the whole - // promise is that the work is still there when the link returns. self.title = if self.workspace().is_some() && !self.terminal.child_exited() { "tty7 — disconnected".to_string() } else { "tty7 — process exited".to_string() }; - // A genuine child exit closes the pane (the app subscribes and - // collapses the split / closes the tab). A daemon disconnect - // reaches this same arm but must NOT auto-close: the session - // may still be alive daemon-side, and closing would both hide - // the failure and kill the pane. if self.terminal.child_exited() { cx.emit(ChildExited); } @@ -1911,13 +973,6 @@ impl TerminalView { } } AlacEvent::ColorRequest(idx, fmt) => { - // OSC 10/11/12 query the default foreground/background/cursor as - // the special indices 256/257/258, which live *outside* the - // 256-color palette. The old `idx.min(255)` clamped them all to - // palette[255] (near-white), so apps probing the background to - // pick a light/dark UI (e.g. Claude Code) saw a "light" terminal - // and switched to a washed-out light theme. Reply with the real - // theme colors instead. let theme = cx.theme(); let rgb = match idx { 256 => super::palette::hsla_to_rgb(theme.foreground), @@ -1928,13 +983,8 @@ impl TerminalView { self.terminal.write(fmt(rgb).into_bytes()); } AlacEvent::Bell => match cx.global::<Config>().bell { - // Silenced: neither flash nor sound. BellMode::None => {} - // Visual bell: a brief flash instead of an audible beep. BellMode::Visual => self.flash_bell(cx), - // Audible bell: ring the system bell. Where none exists (non-mac - // today), fall back to the flash so an opted-in bell is never - // silent. BellMode::Audible => { if !ring_system_bell() { self.flash_bell(cx); @@ -1942,10 +992,6 @@ impl TerminalView { } }, AlacEvent::TextAreaSizeRequest(fmt) => { - // CSI 14 t: the text area size in pixels. Image-preview TUIs - // (yazi, ranger's chafa/sixel backends) size their graphics - // from this reply; ignoring the request leaves them guessing - // or stalling on a report that never comes. let size = self.terminal.size(); let reply = fmt(alacritty_terminal::event::WindowSize { num_lines: size.rows as u16, @@ -1959,8 +1005,6 @@ impl TerminalView { } } - /// Report a focus change to the application (`CSI I` / `CSI O`) when it - /// opted into focus events (mode 1004). No-op otherwise. fn report_focus_change(&self, focused: bool) { let mode = *self.terminal.term.lock().mode(); if let Some(bytes) = focus_report_bytes(mode, focused) { @@ -1969,34 +1013,14 @@ impl TerminalView { } fn on_key_down(&mut self, ev: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) { - // `terminal.exited` is set by two very different things: the shell - // genuinely ending, and the *link* dropping (the reader's teardown sets - // the same flag). For a local pane those are the same story and this - // early return is unchanged — a local pane's link only dies when its - // daemon does. - // - // For a remote-workspace pane they are not. The read-only - // degrade is precisely the case where the link is gone and the shell is - // not: that window must keep scrolling, selecting, copying and - // searching, and every one of those runs below this line. What must not - // happen — a keystroke reaching the machine — is stopped further down at - // [`accepts_input`](Self::accepts_input), and again by the `exited` - // checks on `send_to_pty` / `commit_text` themselves. let link_dropped_on_a_remote_pane = self.workspace().is_some() && !self.terminal.child_exited(); if self.terminal.exited && !link_dropped_on_a_remote_pane { return; } - // Any keystroke dismisses a visible integration notice — it has been - // read. The Ctrl+R that raises it runs later in this same dispatch, so - // the raising chord never clears its own notice. if self.integration_notice.take().is_some() { cx.notify(); } - // macOS Option-key policy (see `input::reshape_option_keystroke`): - // reshape the chord once, up front, so every consumer below — the ⌘ - // dispatcher, the prompt editor, the raw PTY encoder — sees the same - // story. Other platforms have no composed-character split to resolve. let reshaped = if cfg!(target_os = "macos") { super::input::reshape_option_keystroke( &ev.keystroke, @@ -2008,11 +1032,6 @@ impl TerminalView { let ks = reshaped.as_ref().unwrap_or(&ev.keystroke); let m = &ks.modifiers; - // While the search field is focused it owns the keyboard — typing, caret - // movement, selection, Cmd+A and IME are all handled inside the field, and - // Enter is delivered via its `PressEnter` event. We only intercept Escape - // to close the bar; any other key that bubbled up here was unhandled, so - // swallow it rather than leak it to the PTY. if self.search.is_some() && self.search_focused { if ks.key == "escape" { self.close_search(window, cx); @@ -2021,10 +1040,6 @@ impl TerminalView { return; } - // Cmd shortcuts (copy / paste / find / select-all + macOS line editing). - // Delegated to keep this dispatcher scannable; the outcome decides whether - // we consume the key, let it bubble to the app shell (new tab / split / - // switch), or fall through to the editor / PTY paths below. if m.platform && !m.control && !m.alt { match self.handle_cmd_shortcut(ks, window, cx) { CmdKey::Consumed => { @@ -2036,12 +1051,6 @@ impl TerminalView { } } - // Off macOS there is no reachable Cmd key, so the clipboard trio lives on - // Ctrl (the Windows/Linux convention). Route only Ctrl+C / Ctrl+V / Ctrl+X - // to the shared clipboard handler; every other Ctrl chord keeps its shell / - // readline meaning (Ctrl+Z suspend, Ctrl+R reverse-search, Ctrl+F forward, - // …). Ctrl+C copies an active selection and otherwise falls through to ^C - // (SIGINT); Ctrl+X cuts a prompt selection; Ctrl+V pastes. if cfg!(not(target_os = "macos")) && m.control && !m.platform @@ -2057,11 +1066,6 @@ impl TerminalView { } } - // Off macOS the "secondary" modifier is Ctrl, so Ctrl+1..9 switches tabs at - // the app shell (mirroring macOS's Cmd+1..9, which bubbles via the platform - // branch above). Those digit chords have no terminal meaning, so return - // without consuming the event — letting it bubble to the root `on_key_down` - // handler — instead of being swallowed by the editor / PTY paths below. if cfg!(not(target_os = "macos")) && m.control && !m.platform @@ -2074,63 +1078,21 @@ impl TerminalView { return; } - // The read-only degrade, placed **here and not at the top of - // this function**. - // - // Everything above is the window's own keyboard, not the machine's: - // ⌘F opens the search bar, ⌘A selects, ⌘C copies, ⌘1-9 switches tabs. - // Every one of those keeps working while the link is - // down — "能滚历史、能选能复制、能 ⌘F 搜索" — and a gate at the top of - // `on_key_down` would silently take them all away, turning a read-only - // window into an inert one. (⌘V is not an exception that needs handling - // here: it reaches [`paste`](Self::paste), which has its own gate.) - // - // Everything *below* ends up at the PTY: the local line editor whose - // Enter ships the line, and the raw key encoder. That is the half that - // must not reach a machine we are not attached to. if !self.accepts_input(cx) { return; } - // On macOS all ordinary text goes out through the IME, never through - // `key_char` — see `input::defer_to_ime` for why (gpui reconstructs - // `key_char` from the virtual keycode, which is a lie for synthesized - // events). Decline the key without consuming it and gpui hands the - // native event to the input context, which delivers the real text via - // `commit_text`. - // - // Kitty's REPORT_ALL_KEYS_AS_ESC is the exception — `defer_to_ime` - // declines under it so the key reaches the encoder below. - // - // A pending multi-key chord is already handled before this point: a key - // that completes a sequence is dispatched as an action and never - // reaches `on_key_down`. The check below is belt-and-braces (gpui takes - // `pending_input` earlier in `dispatch_key_event`, so it never fires - // here) and mirrors `prefers_ime_for_printable_keys`, which *is* live. #[cfg(target_os = "macos")] if !window.has_pending_keystrokes() && super::input::defer_to_ime(ks, self.kitty_flags()) { return; } - // While idle at the prompt, our local command editor owns the keyboard: - // editing keys act on the in-memory line and Enter ships it to the PTY. - // Printable text is delivered through the IME path (`commit_text`), so we - // only handle the non-text keys here and consume everything else (so it - // never leaks to the PTY as a raw byte). if self.input_active() { self.handle_editor_key(ks, cx); cx.stop_propagation(); return; } - // Ctrl+R reaching this raw path means the tty7 history menu the user is - // probably reaching for cannot appear here. When that's because shell - // integration never engaged — not because a foreground command owns the - // PTY — say so once instead of failing silently (#46). The chord still - // goes to the PTY below, so the shell's own reverse-i-search keeps - // working as the fallback. - // Nothing to explain when the user switched the menu off — Ctrl+R - // reaching the PTY is then exactly what they asked for. if m.control && !m.platform && !m.alt @@ -2144,11 +1106,6 @@ impl TerminalView { if let Some(bytes) = super::input::keystroke_to_bytes(ks, kitty) { let plain = !m.control && !m.alt && !m.platform; let shell_owns_prompt = self.shell_owns_prompt(); - // A plain Backspace is reconstructable gap input: offer it to the - // hold, so a fast command's typeahead never touches the PTY (see - // `hold`). Anything else releases the hold first — FIFO order on - // the wire — and goes raw, kept in step with the typeahead record - // for the deferred wipe. let held = plain && ks.key == "backspace" && !shell_owns_prompt @@ -2175,21 +1132,13 @@ impl TerminalView { ); } } - // Keep the cursor solid while typing (resets the blink phase). self.cursor_visible = true; - // Typing clears the selection and jumps to the prompt. self.jump_to_prompt(); cx.notify(); - // Consume so the key isn't also re-sent through the IME path. cx.stop_propagation(); } } - /// Handle a ⌘ shortcut at the terminal surface and report what the dispatcher - /// should do with the key (see [`CmdKey`]). Covers copy / cut / paste / find / - /// select-all plus the macOS editor line-editing chords (⌘Z, ⌘←/→, ⌘⌫), all of - /// which only act at the prompt. Behavior is identical to the inline block it - /// replaced; only the stop-propagation / return plumbing moved to the caller. fn handle_cmd_shortcut( &mut self, ks: &gpui::Keystroke, @@ -2198,26 +1147,14 @@ impl TerminalView { ) -> CmdKey { let m = &ks.modifiers; match ks.key.as_str() { - // Copy / cut / paste route through the same methods the `CopyText` / - // `CutText` / `PasteText` actions call, so the chord, the right-click - // row and the Edit menu can't drift apart. "c" => { - // `clear_on_copy`: Ctrl+C is dual-purpose — copy with a - // selection, ^C (SIGINT) without — so the copy must consume the - // selection or the next press copies again instead of - // interrupting (#111). ⌘C never doubles as SIGINT, so there the - // selection stays highlighted (the macOS convention). if self.copy_contextual(m.control, cx) { CmdKey::Consumed } else { - // Nothing was selected anywhere: don't swallow the key, so - // Ctrl+C still reaches the PTY as ^C. CmdKey::FallThrough } } "x" => { - // Cut is editor-only; outside the prompt there is nothing to - // remove, so the key falls through rather than dying silently. if self.cut_contextual(cx) { CmdKey::Consumed } else { @@ -2228,19 +1165,10 @@ impl TerminalView { self.paste_from_clipboard(cx); CmdKey::Consumed } - // Find (open bar) and ⌘G / ⌘⇧G (next / previous match) are registered - // keybindings — `FindInTerminal` / `FindNext` / `FindPrevious` — so they - // are visible and rebindable in Settings and get a working default on - // every platform (⌘F on macOS, Ctrl+Shift+F elsewhere). They dispatch - // through `on_action`, not this inline path. "a" => { - // At the prompt, ⌘A selects the whole edited line; otherwise it - // selects the whole terminal buffer (scrollback included). self.select_all_contextual(cx); CmdKey::Consumed } - // The following are editor-only (macOS line editing); they're swallowed - // elsewhere since they have no terminal meaning. "z" => { self.undo_edit(m.shift, cx); CmdKey::Consumed @@ -2271,7 +1199,6 @@ impl TerminalView { CmdKey::Consumed } "delete" => { - // ⌘⌫ deletes to line start; ⌘⌦ is its mirror — delete to line end. if self.input_active() { if !self.cmd.delete_selection() { self.cmd.delete_to_end(); @@ -2286,27 +1213,12 @@ impl TerminalView { } } - /// Handle one keystroke while the local command editor is live at the prompt. - /// Editing keys and readline-style control combos act on `self.cmd`; Enter - /// submits; ↑/↓ recall history. Printable text is *not* handled here — it - /// arrives via the IME path (`commit_text`). Tab is claimed by the `SendTab` - /// action (reserved for completion), so it never reaches this method. fn handle_editor_key(&mut self, ks: &gpui::Keystroke, cx: &mut Context<Self>) { let m = &ks.modifiers; let key = ks.key.as_str(); self.cursor_visible = true; - // The raw key path does this per keystroke; the editor owns the keyboard - // at the prompt and every arm below returns early, so it has to happen - // once here instead. Without it a key pressed while scrolled up edits a - // line the viewport isn't showing (#208). self.jump_to_prompt(); - // ⌃P / ⌃N are readline's spelling of ↑ / ↓ (0x10 / 0x0e on the wire, and - // what the shell's own keymap answers when the editor isn't holding the - // line). Rewrite them into the arrow keys here rather than giving them - // arms of their own, so the two spellings can't drift apart — history - // recall, multi-line steps, the completion picker and the reverse-search - // menu all treat them identically from this point down. let aliased; let ks = if m.control && !m.platform && !m.alt && matches!(key, "p" | "n") { aliased = gpui::Keystroke { @@ -2321,41 +1233,23 @@ impl TerminalView { let m = &ks.modifiers; let key = ks.key.as_str(); - // Any key other than a vertical step drops the sticky goal column, so the - // next ↑/↓ takes its column from wherever the caret ends up. if key != "up" && key != "down" { self.editor_goal_col = None; } - // Likewise, only a repeat of ⌥. continues an insert-last-word walk — - // anything else and the next press starts fresh at the newest entry - // rather than swallowing whatever now sits left of the caret. if !(m.alt && key == ".") { self.last_word_nav = None; } - // A reverse search, when active, owns the keyboard. if self.reverse_search.is_some() { self.handle_reverse_search_key(ks, cx); return; } - // ⌃J / ⌃M are readline's accept-line — the terminal's own encoding of - // Enter (LF / CR), and what most shells' default keymaps bind. Route - // them through the same path Enter takes, completion picker included. - // Left alone they reach `apply_readline_ctrl`'s no-op arm and the Ctrl - // branch below swallows them, so the key does nothing at all (#163). if m.control && !m.platform && !m.alt && matches!(key, "j" | "m") { self.accept_line(cx); return; } - // While a completion menu is open it behaves as a picker: - // ↑/↓ move the highlight, Enter writes the highlighted candidate into - // the line (a second Enter submits; Cmd+Enter does both in one stroke), - // Escape just closes — the line keeps any filled prefix. Tab/Shift-Tab - // (via the SendTab action) fill the common prefix / move the highlight. - // Typing and Backspace re-filter the menu live; any other key falls - // through and closes it just below. if self.completion.is_some() && !m.control && !m.alt { match (m.platform, key) { (false, "up") => { @@ -2371,7 +1265,6 @@ impl TerminalView { return; } (true, "enter") => { - // Cmd+Enter: accept the highlighted candidate and run it. self.completion_accept(cx); self.submit_command(cx); return; @@ -2392,19 +1285,9 @@ impl TerminalView { } } - // Any other editing key closes an open completion menu. self.close_completion(); - // Readline-style control combinations, delegated so this dispatcher stays - // scannable. A chord the editor answers is consumed here; one it doesn't - // goes on to the shell rather than dying at the prompt. Either way this - // branch returns. if m.control && !m.platform && !m.alt { - // Off macOS, word navigation and deletion live on Ctrl (the Windows / - // Linux convention): Ctrl+←/→ move by word (Shift extends the - // selection), Ctrl+⌫/⌦ delete a word. macOS keeps these on Alt (handled - // below) — its Ctrl+arrows are OS-level Space switches, and Ctrl+letters - // stay readline — so claim the arrow / delete keys only off macOS. if cfg!(not(target_os = "macos")) { match key { "left" => { @@ -2435,10 +1318,6 @@ impl TerminalView { _ => {} } } - // Off macOS, Ctrl is the primary modifier, so Ctrl+A is expected to - // select the whole edited line (text-editor / Windows convention) — - // there is no reachable Cmd key to carry the macOS `Cmd+A`. macOS keeps - // the readline `Ctrl+A` = move-to-line-start (its select-all is Cmd+A). if cfg!(not(target_os = "macos")) && key == "a" { self.cmd.select_all(); self.close_completion(); @@ -2446,9 +1325,6 @@ impl TerminalView { cx.notify(); return; } - // With the history menu switched off ⌃R belongs to the shell: hand - // the line over and let whatever is bound there answer — zle / - // readline's own reverse-i-search, or an fzf / percol widget (#163). if key == "r" && !cx.global::<Config>().history_search { self.handoff_line_to_shell(&[0x12], cx); return; @@ -2456,10 +1332,6 @@ impl TerminalView { if self.apply_readline_ctrl(key) { cx.notify(); } else if let Some(bytes) = super::input::keystroke_to_bytes(ks, self.kitty_flags()) { - // No local widget answers this chord. Swallowing it is the one - // thing we mustn't do — the key worked before shell integration - // engaged, and zle's keymap (⌃T transpose, a `bindkey` widget, - // an fzf binding…) still knows what to do with it. self.handoff_line_to_shell(&bytes, cx); } else { cx.notify(); @@ -2467,13 +1339,6 @@ impl TerminalView { return; } - // Readline-style Meta chords on the edited line: M-b / M-f motions, - // M-d delete-word (mirroring the Alt+←/→/Delete handling below) and - // M-. insert-last-word. On macOS these are reachable only with - // `macos_option_as_alt` on — with it off the chord composes a character - // upstream and arrives here altless, through the printable-text arm. - // Meta chords with no arm here reach the shell instead of dying (see - // the fallthrough at the bottom of the dispatcher). if m.alt && !m.platform && !m.control { match key { "." => { @@ -2504,22 +1369,10 @@ impl TerminalView { match key { "enter" => { - // Any Enter that reaches here submits. The soft newline that - // Shift+Enter / Opt+Enter authors is not handled inline: it is - // the `InsertNewline` action, dispatched by the keymap before - // the key ever reaches this dispatcher, so the chord can be - // rebound like every other action (#182). self.submit_command(cx); return; } "backspace" => { - // Empty editor: nothing local to delete, but the shell's own - // line may hold type-ahead the editor never saw (bytes that - // reached the PTY outside it — e.g. typed into a finishing - // command). Pass the key through so such strays are always - // erasable by hand; on a truly empty line it's a shell no-op. - // An undrained record must mirror the erase (editor active ⇒ - // primary screen, so no alt-screen taint applies). if self.cmd.is_empty() { self.terminal.write(vec![0x7f]); self.typeahead.observe( @@ -2531,8 +1384,6 @@ impl TerminalView { ); return; } - // backspace() deletes the selection if there is one; only fall - // back to word-delete when nothing is selected. if m.alt && self.cmd.selection().is_none() { self.cmd.delete_word_left(); } else { @@ -2549,7 +1400,6 @@ impl TerminalView { } "left" => self.editor_move_h(false, m.shift, m.alt), "right" => { - // At end-of-line with a suggestion and no selection, → accepts it. if !m.shift && self.cmd.selection().is_none() { if let Some(full) = self.ghost_suggestion() { self.cmd.set(&full); @@ -2562,8 +1412,6 @@ impl TerminalView { "home" => self.editor_move_edge(false, m.shift), "end" => self.editor_move_edge(true, m.shift), "up" => { - // Within a multi-line buffer ↑ moves up a visual row; from the - // top row it recalls the previous history entry. if self.editor_move_v(false, m.shift) { cx.notify(); } else { @@ -2572,8 +1420,6 @@ impl TerminalView { return; } "down" => { - // The mirror of ↑: down a visual row, or newer history from the - // bottom row. if self.editor_move_v(true, m.shift) { cx.notify(); } else { @@ -2582,26 +1428,11 @@ impl TerminalView { return; } "escape" => { - // Esc carries no local-editor meaning, so pass it straight to the - // shell — its own zle/readline bindings act on it (vi command - // mode from bindkey/readline vi mode, `\e`-prefixed widgets, - // menu-select cancel). Shell vi-mode itself disables the local - // editor from prompt start, so this is only the emacs-mode - // fallback path. let bytes = super::input::keystroke_to_bytes(ks, self.kitty_flags()) .unwrap_or_else(|| vec![0x1b]); self.terminal.write(bytes); return; } - // Printable text delivered directly, without an IME round-trip. On - // macOS printable keys are routed to the IME and arrive via - // `commit_text`, so they never reach this method. On Linux (where - // `prefers_ime_for_printable_keys` is false because gpui's IBus path - // doesn't commit plain ASCII back) they arrive here as ordinary key - // events carrying `key_char`; feed them through the same commit path - // the IME would use so the local editor sees the text. Skip control / - // Cmd chords and any non-printable char (function keys have no - // `key_char`). _ => { if !m.control && !m.platform && !m.alt { if let Some(ch) = ks.key_char.as_deref() { @@ -2611,14 +1442,6 @@ impl TerminalView { } } } - // A Meta chord with nothing local behind it (M-t transpose-word, - // M-u/M-l/M-c case widgets, whatever the user bound) goes to the - // shell rather than dying here — same reasoning as the Ctrl side - // above. The shared encoder goes first (it knows the shifted - // character and the Kitty form when `key_char` is there to - // consult), but the platforms that deliver Alt chords at all - // don't reliably carry one — then fall back to ESC + the key - // name, uppercased under Shift, as a raw terminal would send. if m.alt && !m.control && !m.platform && key.chars().count() == 1 { let bytes = super::input::keystroke_to_bytes(ks, self.kitty_flags()) .unwrap_or_else(|| { @@ -2639,19 +1462,6 @@ impl TerminalView { cx.notify(); } - /// Apply a readline-style Ctrl chord to the command editor: Ctrl-A/E/B/F - /// motions (Ctrl-F also accepts the autosuggestion), Ctrl-W/U/K/H deletions - /// (each removing the selection first if there is one), Ctrl-Y yanking the - /// last kill back, Ctrl-L clear-screen, Ctrl-R reverse search, Ctrl-C - /// interrupt, and Ctrl-D EOF/forward-delete. - /// - /// Returns whether the chord was recognized: the caller hands the ones that - /// weren't to the shell, so a widget tty7 has no answer for still reaches - /// the keymap that does. - /// - /// The caller resolves Ctrl-J / Ctrl-M (accept-line), Ctrl-P / Ctrl-N (the - /// arrow keys by another name) and, when the history menu is switched off, - /// Ctrl-R before this point — none of them reach here. fn apply_readline_ctrl(&mut self, key: &str) -> bool { match key { "r" => self.start_reverse_search(), @@ -2668,7 +1478,6 @@ impl TerminalView { self.cmd.move_left(); } "f" => { - // Accept the autosuggestion if one is showing; else move right. if let Some(full) = self.ghost_suggestion() { self.cmd.set(&full); } else { @@ -2676,7 +1485,6 @@ impl TerminalView { self.cmd.move_right(); } } - // Deletion combos remove the selection first if there is one. "w" => { if !self.cmd.delete_selection() { self.cmd.delete_word_left(); @@ -2693,22 +1501,11 @@ impl TerminalView { } } "h" => self.cmd.backspace(), - // Yank: the other half of ⌃W / ⌃U / ⌃K. Answered locally rather - // than handed to the shell — zle keeps its own kill ring, and - // yanking from it would paste text this editor never cut. "y" => self.cmd.yank(), "l" => { - // Clear screen belongs to the shell/readline layer: send the - // same form-feed byte the raw terminal path emits for Ctrl+L. self.terminal.write(vec![0x0c]); } "c" => { - // Interrupt: drop the edited line and let the shell draw a - // fresh prompt (send ^C, as a real terminal would). zle's own - // ^C aborts its line, unadopted gap strays included — the - // typeahead record is moot and must not resurrect them at the - // next prompt; likewise any still-held gap input is discarded - // (^C means "throw the line away"). self.cmd.clear(); self.history_nav = None; let _ = self.typeahead.drain(); @@ -2716,10 +1513,6 @@ impl TerminalView { self.terminal.write(vec![0x03]); } "d" => { - // ^D on an empty line is EOF (exits the shell); otherwise it's - // a forward-delete. EOF only reads as EOF on an *empty* zle - // line — unadopted gap strays would turn it into a completion - // listing, so wipe them first. if self.cmd.is_empty() { self.wipe_pending_typeahead(); self.terminal.write(vec![0x04]); @@ -2732,9 +1525,6 @@ impl TerminalView { true } - /// Horizontal caret motion in the editor with selection semantics: Shift - /// extends, a plain move with an active selection collapses to its edge, - /// otherwise the caret moves (by word when `word`). fn editor_move_h(&mut self, right: bool, shift: bool, word: bool) { if shift { self.cmd.begin_selection(); @@ -2751,7 +1541,6 @@ impl TerminalView { } } - /// Home/End motion with selection semantics (Shift extends, else collapses). fn editor_move_edge(&mut self, end: bool, shift: bool) { if shift { self.cmd.begin_selection(); @@ -2765,12 +1554,6 @@ impl TerminalView { } } - /// Vertical caret motion across a multi-line / wrapped input buffer (↑/↓), - /// with a sticky goal column so passing through a short line keeps the - /// target column. Returns `true` if the caret moved within the buffer; - /// `false` means it was already on the top row (↑) or bottom row (↓), so the - /// caller falls through to history recall — matching how fish/zsh edit a - /// multi-line line. Shift extends the selection. fn editor_move_v(&mut self, down: bool, shift: bool) -> bool { let Some((_, scol)) = self.cursor_cell() else { return false; @@ -2779,9 +1562,6 @@ impl TerminalView { let chars: Vec<char> = self.cmd.text().chars().collect(); let len = chars.len(); let (positions, _r, _c) = input_char_positions(&chars, scol, cols); - // The caret renders on the cell of the char it sits before, or on a - // trailing slot at the buffer end (a fresh row when the buffer ends in a - // newline). let end_caret = if len == 0 { (0usize, scol) } else { @@ -2802,16 +1582,13 @@ impl TerminalView { if chars.last() == Some(&'\n') { max_row += 1; } - // On the boundary row in the travel direction, defer to history recall. if (down && cur_row >= max_row) || (!down && cur_row == 0) { self.editor_goal_col = None; return false; } let target = if down { cur_row + 1 } else { cur_row - 1 }; let goal = *self.editor_goal_col.get_or_insert(cur_col); - // Land on the caret slot of the target row nearest the goal column. Char - // `i`'s slot is the caret *before* it; the buffer-end slot is `len`. - let mut best: Option<(usize, usize)> = None; // (index, |col - goal|) + let mut best: Option<(usize, usize)> = None; for (i, &(r, c, _)) in positions.iter().enumerate() { if r == target { let dist = c.abs_diff(goal); @@ -2842,36 +1619,18 @@ impl TerminalView { self.terminal.term.lock().selection.is_some() } - /// Is there anything [`copy_contextual`](Self::copy_contextual) would copy — - /// in the grid *or* in the prompt editor? What the Copy / Cut menu rows gate - /// on: `has_selection` alone is grid-only, so a prompt selection used to - /// leave "Copy" greyed out even though ⌘C would have copied it. fn any_selection(&self) -> bool { self.has_selection() || (self.input_active() && self.cmd.selected_text().is_some()) } - /// Snapshot the Kitty keyboard-protocol flags the app has enabled, read off the - /// local `Term`'s mode bits (the reader thread keeps them current by advancing - /// the emulator over all child output). Consulted by the key encoder so TUIs - /// that opt into the protocol get `CSI u` reports. pub(super) fn kitty_flags(&self) -> super::input::KittyFlags { super::input::KittyFlags::from_mode(self.terminal.term.lock().mode()) } - /// Bytes for a Tab / Shift-Tab press sent to the PTY. Honors the Kitty keyboard - /// protocol when a full-screen app enabled it (so `Tab` arrives as `CSI 9 u`, - /// distinct from `Ctrl+I`); otherwise the legacy HT / back-tab sequences. These - /// keys reach the PTY through the `SendTab`/`SendBackTab` actions rather than - /// `on_key_down`, so the Kitty encoding is applied here as well. fn tab_bytes(&self, shift: bool) -> Vec<u8> { super::input::tab_bytes(shift, self.kitty_flags()) } - /// The housekeeping every input path shares: drop the selection the key - /// invalidated and bring the viewport back to the live prompt, whole lines - /// (`display_offset`) and sub-line remainder (`scroll_frac`) alike. Acting - /// on a line the user can't see is the thing to avoid — so this runs for - /// keys handled locally too, not only for bytes that reach the PTY. fn jump_to_prompt(&mut self) { let mut term = self.terminal.term.lock(); term.selection = None; @@ -2880,9 +1639,6 @@ impl TerminalView { self.scroll_frac = 0.; } - /// Write a fixed byte sequence to the PTY (for keystrokes delivered as - /// actions rather than through `on_key_down`, e.g. Tab / Shift-Tab), applying - /// the same cursor / selection / scroll housekeeping as normal typing. fn send_to_pty(&mut self, bytes: &[u8], cx: &mut Context<Self>) { if self.terminal.exited || !self.accepts_input(cx) { return; @@ -2893,8 +1649,6 @@ impl TerminalView { cx.notify(); } - /// Select the entire buffer — from the top of scrollback to the last cell — - /// so Cmd+A then Cmd+C copies everything. pub fn select_all(&mut self, cx: &mut Context<Self>) { let mut term = self.terminal.term.lock(); let grid = term.grid(); @@ -2907,10 +1661,6 @@ impl TerminalView { cx.notify(); } - /// "Select All" as the user means it in context: at the prompt, select the - /// edited command line; otherwise select the whole terminal buffer. Shared by - /// the ⌘A shortcut and the right-click "Select All" item so the two never - /// drift apart. pub fn select_all_contextual(&mut self, cx: &mut Context<Self>) { if self.input_active() { self.cmd.select_all(); @@ -2920,11 +1670,6 @@ impl TerminalView { } } - /// Paste clipboard text. While idle at the prompt it goes into the local - /// command editor (a single trailing newline is dropped so a copied line - /// doesn't auto-submit). Otherwise it's written to the PTY, wrapped in - /// bracketed-paste markers when the app enabled that mode (so shells/editors - /// treat it as one paste rather than typed-and-executed input). pub fn paste(&mut self, text: String, cx: &mut Context<Self>) { if !self.accepts_input(cx) { return; @@ -2939,34 +1684,17 @@ impl TerminalView { cx.notify(); return; } - // A gap paste rides the same hold as typed text (a clean single-line - // paste ahead of a fast command lands in the editor, PTY untouched); - // `write_gap_text` taints the record on embedded newlines — those - // lines execute as commands zle-side and must not become a seed. let bracketed = self .terminal .term .lock() .mode() .contains(TermMode::BRACKETED_PASTE); - // `paste_bytes` wraps in bracketed markers when the app enabled that - // mode (the receiver's own guard against a pasted command - // auto-executing) and strips any ESC so clipboard text can't smuggle - // its own `ESC[201~` end-marker to break out. self.write_gap_text(&text, paste_bytes(&text, bracketed), cx); - // Pasting to the PTY is input like typing: it consumes the selection, - // so a following Ctrl+C means ^C again (#111). The editor branch above - // leaves the selection alone, matching `commit_text`. self.terminal.term.lock().selection = None; cx.notify(); } - // ---- Mouse tracking (so vim / tmux / zellij get clicks & drags) ---- - - /// True when the application has enabled any mouse-reporting mode. - /// Drive the momentary visual bell flash: turn it on now, then schedule a - /// one-shot task to clear it ~150ms later. Shared by the `Visual` bell mode - /// and the `Audible` fallback on platforms without a system bell. fn flash_bell(&mut self, cx: &mut Context<Self>) { self.bell_flash = true; cx.notify(); @@ -2992,9 +1720,6 @@ impl TerminalView { .intersects(TermMode::MOUSE_MODE) } - /// Encode and send a single mouse event to the PTY. `base` is the raw button - /// code (0/1/2 buttons, 64/65 wheel, 32/33/34 drag-motion); `row`/`col` are - /// 0-based viewport coordinates. fn write_mouse(&self, base: u8, mods: &Modifiers, col: usize, row: usize, pressed: bool) { let sgr = self .terminal @@ -3029,8 +1754,6 @@ impl TerminalView { } pub fn mouse_drag(&mut self, button: MouseButton, col: usize, row: usize, mods: &Modifiers) { - // Only report when the cell changed, and only if the app asked for drag - // or motion tracking. if self.last_mouse_cell == Some((col, row)) { return; } @@ -3054,11 +1777,6 @@ impl TerminalView { self.write_mouse(base, mods, col, row, true); } - /// Report button-less mouse motion when the app asked for *all* motion - /// (mode 1003, any-event tracking) — hover-driven TUIs never see the mouse - /// otherwise. Drags (a button held) go through [`mouse_drag`] instead. - /// Deduped per cell like drags, so pixel moves within one cell don't spam - /// the PTY. Base 35 = the motion flag (32) plus "no button" (3). pub fn mouse_motion(&mut self, col: usize, row: usize, mods: &Modifiers) { if self.last_mouse_cell == Some((col, row)) { return; @@ -3077,29 +1795,21 @@ impl TerminalView { self.write_mouse(35, mods, col, row, true); } - /// Scroll handling that also honors mouse-wheel reporting and alternate - /// scroll, falling back to local scrollback otherwise. pub fn scroll(&mut self, lines: i32, mods: &Modifiers, cx: &mut Context<Self>) { if lines == 0 { return; } let mut mode = *self.terminal.term.lock().mode(); - // "Mouse reporting off" also silences the wheel: drop the report mode so - // the tick falls through to alternate-scroll / local scrollback, exactly - // as if the app had never asked for wheel reporting. if !self.report_mouse { mode.remove(TermMode::MOUSE_MODE); } match wheel_route(mode, mods.shift, lines > 0) { - // Mouse-wheel reporting: one report per line, at the last mouse cell. WheelRoute::Report { base } => { let (col, row) = self.last_mouse_cell.unwrap_or((0, 0)); for _ in 0..lines.unsigned_abs() { self.write_mouse(base, mods, col, row, true); } } - // Alternate scroll: translate the wheel into arrow keys for - // full-screen apps (less, man) that don't do mouse reporting. WheelRoute::Arrows { seq } => { let mut out = Vec::with_capacity(seq.len() * lines.unsigned_abs() as usize); for _ in 0..lines.unsigned_abs() { @@ -3107,10 +1817,6 @@ impl TerminalView { } self.terminal.write(out); } - // Local scrollback, in whole lines (wheel scrolling goes through - // `smooth_scroll` instead and keeps a sub-line fraction; a - // line-quantized jump here must not leave a stale fraction shifting - // the paint). WheelRoute::Scrollback => { self.scroll_frac = 0.; self.terminal @@ -3122,13 +1828,9 @@ impl TerminalView { } } - // ---- Cmd+F search ---- - pub fn copy_selection(&mut self, cx: &mut Context<Self>) { let text = self.terminal.term.lock().selection_to_string(); if let Some(mut text) = text { - // Optionally strip trailing whitespace from each line — a block/rect - // selection or wrapped rows otherwise carry padding spaces. if cx.global::<Config>().clipboard_trim_trailing_spaces { text = trim_trailing_spaces(&text); } @@ -3138,23 +1840,7 @@ impl TerminalView { } } - /// Copy whatever is selected, preferring the prompt editor's selection over - /// the terminal grid's. Returns whether anything was actually copied — the - /// ⌃C path needs to know, because with nothing selected the key has to fall - /// through to ^C (SIGINT). - /// - /// `clear_on_copy` drops the selection after copying. Ctrl+C is dual-purpose - /// (copy with a selection, SIGINT without), so it must consume the selection - /// or the next press copies forever instead of interrupting (#111); ⌘C and - /// the menu items leave the highlight up, the macOS convention. - /// - /// The single copy path: ⌘C / ⌃C, the right-click "Copy" row, and the Edit - /// menu all land here. pub fn copy_contextual(&mut self, clear_on_copy: bool, cx: &mut Context<Self>) -> bool { - // At the prompt the editor's selection wins — but only when it has one. - // With no editor selection we fall on through: the user may have - // mouse-selected terminal output/scrollback, which lives in - // `term.selection`, not in the editor. if self.input_active() { if let Some(text) = self.cmd.selected_text() { cx.write_to_clipboard(ClipboardItem::new_string(text)); @@ -3176,10 +1862,6 @@ impl TerminalView { false } - /// Step to the next (`forward`) or previous search match. A no-op while the - /// find bar is closed — there is nothing to step through. Exposed for the - /// palette's "Find Next" / "Find Previous", which run from outside the - /// terminal module and so can't reach `step_match` directly. pub fn find_step(&mut self, forward: bool, cx: &mut Context<Self>) { let direction = if forward { Direction::Right @@ -3189,10 +1871,6 @@ impl TerminalView { self.step_match(direction, cx); } - /// Undo (or, with `redo`, redo) the last prompt edit. Editor-only: the - /// terminal grid has no edit history, so outside the prompt this is a no-op - /// that still swallows the gesture rather than sending ⌘Z to the PTY. - /// Shared by the ⌘Z chord and the Edit menu's Undo / Redo. pub fn undo_edit(&mut self, redo: bool, cx: &mut Context<Self>) { if !self.input_active() { return; @@ -3206,11 +1884,6 @@ impl TerminalView { cx.notify(); } - /// Cut the prompt editor's selection: copy it out, then delete it. Only - /// meaningful at the prompt — the terminal grid is not editable — so this - /// reports whether the gesture was *handled* (i.e. the prompt was active), - /// not whether text was actually removed; a cut with nothing selected is - /// still a no-op the prompt owns rather than a key the PTY should see. pub fn cut_contextual(&mut self, cx: &mut Context<Self>) -> bool { if !self.input_active() { return false; @@ -3225,14 +1898,6 @@ impl TerminalView { true } - /// Read the system clipboard and paste it into the PTY (bracketed-paste - /// aware). The single paste path: ⌘V / ⌃V, the right-click "Paste" row, and - /// the Edit menu. - /// - /// Text wins when the clipboard carries any. Failing that — an image-only - /// clipboard (a screenshot) dropped on a pane whose foreground app is a TUI - /// coding agent — the image is written to a temp file and its path typed in, - /// which is how those agents take attachments. pub fn paste_from_clipboard(&mut self, cx: &mut Context<Self>) { let Some(item) = cx.read_from_clipboard() else { return; @@ -3252,11 +1917,6 @@ impl TerminalView { } } - /// Files dragged in from Finder (etc.) and dropped on the terminal: - /// shell-escape each path, join with spaces, and insert them like a paste — - /// with a trailing space so a dropped path is ready to be an argument and - /// back-to-back drops don't run together. Matches macOS Terminal.app - /// (which reuses its paste escaping for drops). fn drop_files(&mut self, paths: &ExternalPaths, cx: &mut Context<Self>) { let text = paths .paths() @@ -3270,16 +1930,6 @@ impl TerminalView { self.paste(format!("{text} "), cx); } - /// Paste a clipboard image (e.g. a screenshot) into a foreground coding-agent - /// TUI. Agents like Claude Code attach an image typed as a *file path* at the - /// prompt — the same route drag-and-drop uses — so off macOS we stage the image - /// to a temp file and paste its shell-escaped path, mirroring [`drop_files`]. - /// - /// On macOS the agent can instead read the image straight from the pasteboard - /// when it sees Ctrl+V, so we forward SYN (`0x16`) and let it do that - /// higher-fidelity read. That same read is unreliable off macOS — Claude Code on - /// Windows silently drops raw screenshots (anthropics/claude-code#26679) — which - /// is why we materialize a file there. If staging fails, we fall back to SYN. fn paste_clipboard_image(&mut self, img: &gpui::Image, cx: &mut Context<Self>) { #[cfg(not(target_os = "macos"))] if let Some(path) = write_clipboard_image(img) { @@ -3289,30 +1939,18 @@ impl TerminalView { } let _ = img; self.terminal.write(vec![0x16]); - // PTY input consumes the selection, like `paste` (#111). self.terminal.term.lock().selection = None; cx.notify(); } - /// Clear the terminal (right-click "Clear"), like Cmd+K / the `clear` - /// command: purge the scrollback history *and* wipe the visible screen. - /// We drop the history directly, then send Ctrl+L so the shell/TUI repaints - /// its prompt at the top with the cursor in sync (no desync from poking the - /// grid behind the program's back). pub fn clear_scrollback(&mut self, cx: &mut Context<Self>) { self.terminal.term.lock().grid_mut().clear_history(); self.scroll_frac = 0.; - // Every mark's row indexed into the history that just went away, so the - // Outline's positions are now meaningless. Drop them rather than leave - // rows that scroll somewhere arbitrary. self.terminal.marks().clear(); - self.terminal.write(vec![0x0c_u8]); // Ctrl+L + self.terminal.write(vec![0x0c_u8]); cx.notify(); } - /// Swap the primary font face (keeping the configured fallbacks). Lets the - /// settings panel change the font family live; the element re-measures cell - /// geometry on the next prepaint, so the grid reflows automatically. pub fn set_font_family(&mut self, family: String, cx: &mut Context<Self>) { let fallbacks = self.font.fallbacks.clone(); let mut font = gpui::font(family); @@ -3324,22 +1962,16 @@ impl TerminalView { cx.notify(); } - /// Swap the bold face (`None` = synthesize bold from the primary face). The - /// alternate carries the primary's fallback chain so glyph coverage matches. pub fn set_font_family_bold(&mut self, family: Option<String>, cx: &mut Context<Self>) { self.font_bold = self.alt_font(family); cx.notify(); } - /// Swap the italic face (`None` = synthesize italic from the primary face). pub fn set_font_family_italic(&mut self, family: Option<String>, cx: &mut Context<Self>) { self.font_italic = self.alt_font(family); cx.notify(); } - /// Apply OpenType features to the live terminal fonts. `None` restores the - /// terminal-safe default path, where the renderer disables contextual - /// ligatures while building paint faces. pub fn set_font_features( &mut self, features: Option<gpui::FontFeatures>, @@ -3359,8 +1991,6 @@ impl TerminalView { cx.notify(); } - /// Build an alternate face from a family name, reusing the primary's - /// fallbacks. `None` → `None` (fall back to synthesizing from `self.font`). fn alt_font(&self, family: Option<String>) -> Option<Font> { family.map(|f| { let mut af = gpui::font(f); @@ -3372,19 +2002,12 @@ impl TerminalView { }) } - /// Detect command start/finish by watching the PTY's foreground process - /// group, and post a desktop notification when a long-running command - /// finishes while the window is in the background. Called ~1×/second. fn poll_foreground(&mut self, window: &Window, cx: &mut Context<Self>) { if self.terminal.exited { return; } let at_prompt = self.terminal.at_prompt(); - // A deferred history record is finalized once the shell has reported - // back at its prompt: the daemon's `last_exit` is now this command's. - // Sequence-based, so a fast command whose not-at-prompt window fell - // between polls still gets its exit code. if self .pending_history .as_ref() @@ -3394,56 +2017,32 @@ impl TerminalView { cx.notify(); } - // Re-rank history when the working directory changes (a `cd`), so ghost text - // and completion start favouring commands run in the new directory. Only on - // a real, known change — an unknown cwd keeps the previous ranking. if let Some(cwd) = self.cwd() && self.ranked_cwd.as_ref() != Some(&cwd) { self.rerank_history(Some(&cwd)); } - // Shell integration engaging late (a slow rc file finally reported) - // makes a visible integration notice wrong — retract it. The - // once-per-pane latch stays set: the overlay works now, there is - // nothing left to explain. if self.integration_notice.is_some() && self.terminal.shell_active() { self.integration_notice = None; cx.notify(); } - // Redraw when the prompt/running state flips, so the line editor shows or - // hides promptly even when the shell produced no output to trigger a - // repaint (e.g. a command that prints nothing). Without this the editor's - // visibility — computed in `render` — could lag until the next redraw. if at_prompt != self.last_at_prompt { self.last_at_prompt = at_prompt; cx.notify(); } - // Whether the configured notification policy allows a post right now: - // never / only-when-unfocused / always. Shared by the command-finished, - // agent-finished, and agent-waiting notifications. let notify_allowed = match cx.global::<Config>().notify_on_command_finish { NotifyMode::Never => false, NotifyMode::Unfocused => !window.is_window_active(), NotifyMode::Always => true, }; - // "Command finished" notification: a foreground command (not at prompt) - // that ran long and finished while the window was in the background. When - // the command was a recognized coding agent, brand the notification with - // the agent instead of the generic "command finished" copy. let running = !at_prompt; - // While a command runs, latch the agent the daemon reports for it — the - // detection poll can land a beat after the command starts, so capture it - // whenever it appears rather than only at the start edge. if running && self.running_agent.is_none() { self.running_agent = self.terminal.foreground_agent(); } - // A command finishing (back-to-prompt edge) may have edited files or - // switched branch, so reprobe git after it — captured before the match - // below clears `running_since`. let cmd_finished = self.running_since.is_some() && !running; match (self.running_since, running) { (None, true) => { @@ -3458,13 +2057,7 @@ impl TerminalView { self.running_since = None; if notify_allowed { match agent { - // A rich-channel agent already announced each turn's - // end (`stop` events below); a second "finished" on - // process exit would be noise. Some(_) if self.agent_was_rich => {} - // An agent session ends the moment it finishes — no - // duration floor: "Claude Code finished" is worth saying - // even for a quick turn you stepped away from. Some(agent) => notify_agent_finished(agent, elapsed), None => { let threshold = std::time::Duration::from_secs( @@ -3482,35 +2075,7 @@ impl TerminalView { let turn_finished = self.poll_agent_status(notify_allowed, cx); - // Refresh the sidebar's git branch/diff line when the working directory - // changed (a `cd`), a command just finished, or an agent turn ended — - // an agent's session is one long foreground command, so its edits would - // otherwise stay invisible until it exits. All rare edges, so the - // off-thread `git` shell-out runs seldom, not every 300ms tick. - // - // Those edges alone left the counts badly stale during the case they - // matter most: a long agent turn writes file after file for minutes - // with nothing to show for it. A tool completion is the one signal that - // the tree may have just moved mid-turn, so it refreshes too — through - // the throttled path, since a busy agent emits them several a second - // and each one would otherwise cost a `git diff` across the repo. - // - // An agent that reports its own cwd through the hook channel wins over - // the proc probe: it tracks internal chdirs the PTY can't observe - // (Claude Code's EnterWorktree) and works where the proc fallback - // doesn't (Windows). The claim dies with the session (`session-end` - // clears it, and the agent leaving the foreground drops the whole - // state), so an exited agent falls back to the pane's real directory. - // The agent's report goes through the same host gate as the pane's own - // cwd. A native-SSH pane keeps sentinel-sourced agent state on purpose - // (`spawn_native_ssh`), so an agent running *on the remote host* reports - // a remote path — and being first in the chain it would win over the - // pane's own cwd unconditionally and hand that path to a `git` that - // cannot see it, which is the collision `cwd_is_on_host` prevents. let session = self.terminal.agent_session(); - // A count that moved means at least one tool finished since the last - // tick. With no session the counter resets, so a fresh agent's very - // first tool call still reads as activity. let tool_activity = match session.as_ref().map(|s| s.activity) { Some(n) => std::mem::replace(&mut self.last_agent_activity, n) != n, None => { @@ -3534,19 +2099,6 @@ impl TerminalView { } } - /// Kick off an off-thread git probe for `cwd` on this pane's - /// [`host`](Self::host) and fold the result into the shared per-repo - /// [`GitStatusCache`] on the main thread. The cache brackets the flight - /// (`begin_probe`/`finish_probe`) per `(host, cwd)`: a probe already in - /// flight for the same pair absorbs this trigger instead of spawning a - /// duplicate, and reruns once when it lands. With no cwd the pane simply - /// stops reading a status. Callers must source the cwd from - /// [`host_cwd`](Self::host_cwd): a pane whose paths its host cannot answer - /// for *does* get a cwd once its OSC 7 lands, so "such panes have no cwd" - /// holds only before that and cannot be what keeps the probe away from a - /// path it would misread. - /// - /// [`GitStatusCache`]: crate::terminal::git_status::GitStatusCache fn refresh_git_status( &mut self, cwd: Option<std::path::PathBuf>, @@ -3564,18 +2116,6 @@ impl TerminalView { return; }; let id = self.host_id; - // A host that is not there cannot be probed, and a probe that fails - // would replace a good branch line with nothing. Keep showing the last - // answer instead — the reconnect fires a fresh trigger. - // - // Two ways for it not to be there, both landing here: the machine is - // unregistered (its workspace closed, or this process never connected), - // or it is registered but its connection is down. - // - // Still repaint if the cwd moved: `git_status_cwd` is what - // `git_status()` resolves through, so leaving the frame unnotified - // would keep the *previous* directory's branch line on screen until - // some unrelated event happened to notify. let Some(host) = self.host(cx) else { if changed { cx.notify(); @@ -3588,7 +2128,7 @@ impl TerminalView { } return; } - cx.default_global::<GitStatusCache>(); // first probe of the process creates it + cx.default_global::<GitStatusCache>(); let claimed = cx.update_global::<GitStatusCache, _>(|cache, _| match trigger { GitRefresh::Edge => cache.begin_probe(id, &cwd), GitRefresh::Opportunistic => { @@ -3600,26 +2140,14 @@ impl TerminalView { } let probe_cwd = cwd.clone(); let pane = cx.weak_entity(); - // `run_detached`, not `run`: the result has to reach the shared cache - // whether or not this pane outlives the probe. The claim is keyed by - // `(host, cwd)`, so a pane closed mid-flight that never released its - // claim would wedge the git line of every other pane in that directory - // — permanently, since nothing else ever clears it. crate::ui::host_ops::HostOps::run_detached( host, cx, move |h| crate::terminal::git_status::probe(h, &probe_cwd), move |cx, result| { - // Landing through `update_global` wakes the sidebar's - // `observe_global`, so every pane in the repo repaints — not - // just this one. let rerun = cx.update_global::<GitStatusCache, _>(|cache, _| { cache.finish_probe(id, &cwd, result) }); - // A trigger arrived while we flew; go once more so its state is - // observed — unless this pane has since left that cwd (or left - // entirely). Only edge triggers set that flag, so the rerun is - // an edge too. if rerun { let _ = pane.update(cx, |view, cx| { if view.git_status_cwd.as_deref() == Some(&cwd) { @@ -3631,21 +2159,6 @@ impl TerminalView { ); } - /// Fold the pane's rich agent status into turn-level notifications and the - /// status dot. Runs on the same cadence as the notification poll above. - /// - /// Only *transitions* act: entering `Waiting` says the agent needs you - /// (the reason attached), and a `Working → Done` edge says the turn - /// finished — with its duration when we saw it start. Non-rich (fallback) - /// state paints the dot but stays silent: the agent's own OSC notification - /// was already toasted by the reader thread, and echoing it would double - /// up. Attach replays land as a bare status with no observed transition - /// history, so a restored `Done` never re-notifies. - /// - /// Returns whether a turn just ended (a transition *into* `Done`) — the - /// caller uses it to reprobe git: an agent's whole session is one long - /// foreground command, so the back-to-prompt edge that normally refreshes - /// the branch/diff line never fires while it works. fn poll_agent_status(&mut self, notify_allowed: bool, cx: &mut Context<Self>) -> bool { use crate::core::cli_agent::AgentStatus; @@ -3657,9 +2170,6 @@ impl TerminalView { self.agent_was_rich = false; } - // Ahead of the status early-return below, because this does not move - // with the status: an id appears when the agent's hooks first report a - // conversation, which is a moment the status has no opinion about. let identity = ( session.as_ref().and_then(|s| s.session_id.clone()), session.as_ref().and_then(|s| s.launch_argv.clone()), @@ -3676,9 +2186,6 @@ impl TerminalView { let prev = std::mem::replace(&mut self.last_agent_status, status); let turn_finished = status == Some(AgentStatus::Done) && prev != Some(AgentStatus::Done); - // Read/unread for the green Done dot: a turn just finished is "unread" - // only if you weren't looking (focused pane = you watched it finish, so - // it's already read). Any non-Done status has no result to be unread. match status { Some(AgentStatus::Done) if prev != Some(AgentStatus::Done) => { self.agent_result_unread = !self.focused; @@ -3708,8 +2215,6 @@ impl TerminalView { .unwrap_or_else(|| "Waiting for your input".to_string()); super::remote::notify_desktop(Some(agent_name), &body); } - // Done only counts off an *observed* turn (working/waiting seen - // live), so an attach replay of old state stays quiet. Some(AgentStatus::Done) if rich && notify_allowed @@ -3726,36 +2231,14 @@ impl TerminalView { } _ => {} } - // Status changed: repaint so the avatar dot / sidebar line track it. cx.notify(); turn_finished } - /// True when the shell sits idle at its prompt: the PTY's foreground process - /// group is the shell's own (established as the first group we observe), as - /// opposed to a foreground command having taken over the terminal. `false` - /// while a command runs or before the group can be read. Reuses the same - /// `prompt_pgid` baseline that `poll_foreground` learns. fn at_shell_prompt(&self) -> bool { self.terminal.at_prompt() } - /// The shell cursor's current viewport cell `(row, col)`, accounting for - /// scrollback offset — the same mapping `element::build_grid` uses to place - /// the block cursor. `None` only when the cursor is scrolled off the top of - /// the viewport. Used to anchor the inline line editor right where the shell - /// prompt ends. - /// - /// The cursor's `Hidden` *shape* is deliberately ignored. A full-screen TUI - /// (e.g. Claude Code) hides the cursor with DECTCEM (`\e[?25l`) and can hand - /// back to the shell prompt — or exit — before a matching `\e[?25h` reaches - /// our local grid, leaving the shape stale-`Hidden` while the shell is - /// already idle at its prompt. These callers only run while `input_active()` - /// (at the prompt, off the alt screen), where the cursor *position* is valid - /// even if the shape is momentarily hidden. Treating hidden as `None` here - /// made `render_input_bar` fall back to `(0, 0)` and paint the caret in the - /// top-left corner; `element::build_grid` already ignores the shape the same - /// way when anchoring the IME window. fn cursor_cell(&self) -> Option<(usize, usize)> { let term = self.terminal.term.lock(); let content = term.renderable_content(); @@ -3764,15 +2247,6 @@ impl TerminalView { (row >= 0).then_some((row as usize, col)) } - /// How many rows the whole surface — grid and input overlay together — - /// shifts up so a wrapped command at a bottom-of-screen prompt stays - /// visible, emulating the scroll the shell itself would perform if the - /// input were echoed. `element::paint` raises the grid origin by this many - /// lines (clipping the top rows) and `render_input_bar` anchors the same - /// rows higher, so the wrapped tail lands in the vacated strip. Zero - /// whenever nothing overflows, while scrolled into history (the overlay is - /// off-screen anyway and the view shouldn't fight the user's scroll), or - /// in reverse-search mode (a single fixed row). pub(super) fn input_scroll_rows(&self) -> usize { if !self.input_active() || self.reverse_search.is_some() { return 0; @@ -3802,17 +2276,6 @@ impl TerminalView { input_overflow_shift(crow, caret_vrow, visual_rows, rows) } - /// Handle a left click while the command editor is live: if it lands on the - /// input line, move the caret to the clicked position and report `true` (so - /// the caller skips starting a terminal text-selection). The line is rendered - /// starting at the shell's cursor cell, so the clicked char index is the - /// column offset from there. (Approximate for wide CJK glyphs, which span two - /// cells — fine for typical ASCII command lines.) - /// Map a click cell `(col, row)` to a char index in the edited line, accounting - /// for wrapping: the input occupies `prompt_cols + len` cells laid out grid-row - /// by grid-row from the prompt cell. With `clamp`, positions before/after the - /// input snap to `0`/`len` (for drags); without it, they return `None` (so a - /// click outside the input isn't treated as an editor click). fn editor_char_index(&self, col: usize, row: usize, clamp: bool) -> Option<usize> { if !self.input_active() { return None; @@ -3838,31 +2301,26 @@ impl TerminalView { return false; }; match clicks { - // Shift+click extends the selection from the current caret to the - // click (anchoring one at the old caret if none is active), matching - // shift-arrow selection; a plain click collapses to the caret. 1 if shift => { self.cmd.extend_to(idx); - self.editor_selecting = true; // a drag from here keeps extending + self.editor_selecting = true; self.editor_drag_word = None; } 1 => { self.cmd.set_cursor(idx); self.cmd.clear_selection(); - self.editor_selecting = true; // a drag from here extends selection + self.editor_selecting = true; self.editor_drag_word = None; } 2 => { let cfg = cx.global::<Config>(); let (seps, smart) = (cfg.word_separators.clone(), cfg.smart_select); self.cmd.select_word_at(idx, &seps, smart); - // Drag now grows the selection by whole words around this one. self.editor_selecting = true; self.editor_drag_word = self.cmd.selection(); } _ => { self.cmd.select_all(); - // The whole line is selected; a drag has nothing left to extend. self.editor_selecting = false; self.editor_drag_word = None; } @@ -3875,8 +2333,6 @@ impl TerminalView { true } - /// Extend the editor selection during a left-drag that began on the input. - /// Returns whether it handled the drag (so the terminal selection is skipped). pub fn editor_drag(&mut self, col: usize, row: usize, cx: &mut Context<Self>) -> bool { if !self.editor_selecting { return false; @@ -3884,7 +2340,6 @@ impl TerminalView { let Some(idx) = self.editor_char_index(col, row, true) else { return false; }; - // A drag begun on a double-click extends by whole words; otherwise by char. if let Some((s, e)) = self.editor_drag_word { let cfg = cx.global::<Config>(); let (seps, smart) = (cfg.word_separators.clone(), cfg.smart_select); @@ -3897,30 +2352,14 @@ impl TerminalView { true } - /// Whether the local line editor should be live and focused: idle at a shell - /// prompt, not on the alternate screen, no search bar open, process alive. - /// Everywhere else this is `false`, so the raw terminal keeps the keyboard and - /// behaves exactly as without the editor. pub fn input_active(&self) -> bool { self.input_inactive_reason().is_none() } - /// Why the line editor is standing down, phrased for a log line — `None` - /// when it is live. - /// - /// The conditions live here rather than inline in [`input_active`] because - /// "the editor didn't engage" is the shape almost every report of this - /// feature takes ("Tab did nothing", "it fell back to the shell"), and six - /// silent booleans are indistinguishable from the outside. One list, so the - /// answer is a `TTY7_LOG=debug` away instead of a bisect. fn input_inactive_reason(&self) -> Option<&'static str> { if self.terminal.exited { return Some("the shell has exited"); } - // Suppress our command editor only while the search field actually holds - // keyboard focus (it claims Tab / ↑ / ↓ / typing). If search is open but - // blurred — e.g. the user clicked back into the terminal — the editor must - // resume, otherwise keys fall through to the raw PTY path and can't be edited. if self.search_focused { return Some("the search field holds the keyboard"); } @@ -3930,9 +2369,6 @@ impl TerminalView { if self.shell_vi_prompt() { return Some("the shell prompt is in vi mode"); } - // A Tab handoff gave this prompt's line to the shell; until a command - // runs and a fresh prompt cycle starts, the shell's editor owns it, - // and re-engaging ours would fork the two line buffers. if self.editor_handoff == Some(self.terminal.prompt_cycle()) { return Some("this prompt's line was already handed to the shell"); } @@ -3946,28 +2382,16 @@ impl TerminalView { self.terminal.shell_vi_mode() && self.terminal.at_prompt() && !self.on_alt_screen() } - /// True while a Tab handoff has given the current prompt's line to the - /// shell (see [`Self::handoff_tab_to_shell`]) and the shell is still in - /// that prompt cycle. Over once a command runs and the next prompt - /// arrives (a false→true `at_prompt` edge bumps the cycle). fn handoff_active(&self) -> bool { self.editor_handoff == Some(self.terminal.prompt_cycle()) && self.terminal.at_prompt() && !self.on_alt_screen() } - /// True while the shell's own line editor owns the prompt line — a - /// vi-mode prompt, or one whose line a Tab handoff shipped over. Raw - /// input then goes to the PTY with no hold and no typeahead record: - /// those bytes land on zle's line and are the shell's to keep, so a - /// deferred `^U` wipe would erase text the user can see. fn shell_owns_prompt(&self) -> bool { self.shell_vi_prompt() || self.handoff_active() } - /// True while the emulator is on the alternate screen — a full-screen TUI - /// owns the pane, so raw input belongs to that program, not the shell's - /// next command line. fn on_alt_screen(&self) -> bool { self.terminal .term @@ -3976,18 +2400,6 @@ impl TerminalView { .contains(TermMode::ALT_SCREEN) } - /// Handoff once zle is reading at the new prompt: wipe the type-ahead it - /// just consumed and adopt it into the editor (see the `typeahead` module - /// docs for the full failure mode). The `^U` (kill-whole-line — same - /// binding in zsh emacs/vi-insert, bash and fish) is written *after* - /// every stray byte, and the TTY queue is FIFO, so zle always reads the - /// strays first and then the wipe — correct with no timing assumptions. - /// The seed is *prepended*: the editor engages at `133;D` but this flush - /// waits for `133;B` (`zle_reading` — a ^U written while precmd hooks - /// still run in canonical mode is kernel-echoed as literal `^U` junk), - /// and anything typed in between already sits in the editor, - /// chronologically *after* the strays. Runs every render with the editor - /// live; an untouched record drains to `None` and sends nothing. fn flush_typeahead(&mut self) { let Some(seed) = self.typeahead.drain() else { return; @@ -3998,29 +2410,16 @@ impl TerminalView { } } - /// The editor is about to write bytes the shell will act on (a submitted - /// line, ^D EOF) while gap typeahead may still sit unadopted on zle's - /// line (its wipe waits for `zle_reading`). Wipe first — FIFO puts the - /// ^U ahead of the caller's bytes — and drop the seed: grafting it into - /// an action the user just chose would run something they never saw. fn wipe_pending_typeahead(&mut self) { if self.typeahead.drain().is_some() { self.terminal.write(vec![0x15]); } } - /// True when gap input may be held for the editor: shell integration is - /// live (a prompt will come and adopt it) and no full-screen TUI owns the - /// pane. Only consulted on the raw path, so "the editor is disengaged" is - /// already implied. fn gap_holdable(&self) -> bool { self.terminal.shell_active() && !self.on_alt_screen() && !self.shell_owns_prompt() } - /// Write printable gap text (IME commit, paste) toward the shell: offered - /// to the hold when reconstructable (see `hold`), otherwise released + - /// written raw and recorded for the deferred wipe. `bytes` is the exact - /// PTY encoding (paste may be bracketed-wrapped). fn write_gap_text(&mut self, text: &str, bytes: Vec<u8>, cx: &mut Context<Self>) { if self.shell_owns_prompt() { self.release_hold(); @@ -4038,8 +2437,6 @@ impl TerminalView { Verdict::Passthrough => {} } } else { - // Unreconstructable (control chars / TUI input): anything held - // must precede these bytes on the wire. self.release_hold(); } self.terminal.write(bytes); @@ -4047,8 +2444,6 @@ impl TerminalView { self.typeahead.observe(RawInput::Text(text), alt); } - /// Release any held gap input to the PTY (order-preserving) and record it - /// for the deferred wipe; the rest of this gap is raw passthrough. fn release_hold(&mut self) { if let Some((net, bytes)) = self.hold.release() { self.terminal.write(bytes); @@ -4057,7 +2452,6 @@ impl TerminalView { } } - /// Start the one-shot dump timer for a freshly opened hold window. fn arm_hold_timer(&mut self, epoch: u64, cx: &mut Context<Self>) { cx.spawn(async move |this, cx| { cx.background_executor().timer(HOLD_WINDOW).await; @@ -4066,16 +2460,7 @@ impl TerminalView { .detach(); } - /// The hold window lapsed with the editor still disengaged: the command - /// is long-running (or reading stdin) — release the bytes to the PTY and - /// record them for the deferred wipe. fn dump_hold(&mut self, epoch: u64, cx: &mut Context<Self>) { - // The one gate that is not next to a keystroke. This runs off a timer - // armed while the pane was still attached, so it can fire *after* the - // link dropped and after every other check has already returned — held - // bytes would then reach the machine as the one thing the read-only - // degrade promises cannot happen. Nothing is buffered for later (D6): - // the hold is dropped, not queued. if !self.accepts_input(cx) { let _ = self.hold.timeout(epoch); return; @@ -4088,50 +2473,21 @@ impl TerminalView { } } - /// The `InsertNewline` action: insert a literal newline at the caret so the - /// user can author (or extend) a multi-line command, which plain Enter then - /// submits whole. Bound to Shift+Enter and Alt+Enter by default. - /// - /// Only the local command editor answers this. When the editor isn't holding - /// the line — a foreground application owns the screen, the search field has - /// focus — or while a reverse search owns the keyboard, we `propagate` - /// instead, so the chord takes the exact path it took before this action - /// existed: on to `on_key_down`, and from there to the widget or out to the - /// application as raw bytes. - /// - /// An open completion menu deliberately does *not* decline it. A newline - /// ends the word being completed, so the menu is closed and the newline - /// inserted — for both chords. Warp draws the same line: only a bare Enter - /// reaches the popup-acceptance path (`FixedBinding::new("enter", …)` → - /// `input_enter`), while Shift+Enter / Alt+Enter dispatch their own actions - /// that the editor resolves as a newline without the popup ever seeing them. - /// Plain Enter here still runs `accept_line`, which takes the highlighted - /// candidate — that path is untouched. fn insert_newline_action(&mut self, cx: &mut Context<Self>) { if !self.input_active() || self.reverse_search.is_some() { cx.propagate(); return; } - // A key pressed while scrolled up must edit the line the viewport is - // showing, the way every editor key does (see `handle_editor_key`). self.jump_to_prompt(); self.close_completion(); self.cursor_visible = true; self.cmd.insert_str("\n"); self.history_nav = None; - // This action bypasses `handle_editor_key`, so it has to repeat that - // dispatcher's per-key state resets itself — same reason `commit_text` - // does for the IME path. Without them the next ↑/↓ takes its column - // from a stale goal, and an ⌥. walk would continue across the newline. self.editor_goal_col = None; self.last_word_nav = None; cx.notify(); } - /// readline's accept-line, as the editor means it: with a completion - /// candidate highlighted, take the candidate (a second stroke then runs the - /// line); otherwise close any menu and submit. Shared by Enter and its - /// control-code aliases ⌃J / ⌃M. fn accept_line(&mut self, cx: &mut Context<Self>) { if self .completion @@ -4145,25 +2501,14 @@ impl TerminalView { self.submit_command(cx); } - /// Ship the edited command line to the PTY — the whole line plus a carriage - /// return — record it in history, then clear the editor for the next command. fn submit_command(&mut self, cx: &mut Context<Self>) { if self.terminal.exited { return; } - // A sub-frame race can land Enter before the render that adopts held - // gap input: fold it in first so the submitted line is what the user - // actually typed. if let Some(net) = self.hold.engage() { self.cmd.prepend_str(&net); } let line = self.cmd.text(); - // Record in history (skip blanks and immediate duplicates for ↑/↓ recall), - // but always tally the run — count, the directory it ran in, and when — - // for ranking and the Ctrl+R menu, then refresh the ranked view for the - // current directory. The file record is deferred until the shell reports - // back at its prompt, so it can carry this run's exit code; a previous - // record still deferred goes out first. if !line.trim().is_empty() { let cwd = self.cwd(); let now = unix_now(); @@ -4197,14 +2542,7 @@ impl TerminalView { self.history_stash.clear(); self.close_completion(); - // Any gap typeahead still waiting for its wipe (the ^U is deferred - // until zle reads) would prefix the submitted line on zle's side — - // "ls" strays + "pwd\r" runs `lspwd`. Wipe first: FIFO puts the ^U - // ahead of the line bytes. self.wipe_pending_typeahead(); - // One paste + one CR when the shell takes bracketed paste, so a - // multi-line command costs one prompt cycle instead of one per line - // (see `submit_bytes`); per-line CRs otherwise. let bracketed = self .terminal .term @@ -4218,17 +2556,7 @@ impl TerminalView { cx.notify(); } - /// Readline's `yank-last-arg` (⌥.): drop the last word of the previous - /// command at the caret. Repeating the chord walks further back through the - /// history, each press swapping out the word the one before it inserted, so - /// a run of presses leaves exactly one word behind. Entries with no words - /// are stepped over rather than inserting nothing. fn insert_last_word(&mut self, cx: &mut Context<Self>) { - // Only trust the recorded walk while the line still shows it: its word - // sitting at `at`, caret at the word's end, nothing selected. The keys - // this dispatcher sees reset `last_word_nav` themselves, but edits that - // bypass it (IME-committed text, a paste, a completion pick, ⌘Z) don't - // — resuming over those would delete text the walk never inserted. let resumed = self.last_word_nav.take().filter(|walk| { let len = walk.word.chars().count(); self.cmd.cursor() == walk.at + len @@ -4241,15 +2569,11 @@ impl TerminalView { .take(len) .eq(walk.word.chars()) }); - // A repeat resumes one entry older than the last press; a fresh walk - // starts at the newest entry. let start = match &resumed { Some(walk) => walk.entry.checked_sub(1), None => self.history.len().checked_sub(1), }; let Some(mut entry) = start else { - // Nothing older to reach (or no history at all) — leave the line as - // it stands, the word the previous press inserted included. self.last_word_nav = resumed; return; }; @@ -4264,8 +2588,6 @@ impl TerminalView { entry = older; }; - // Take back what the previous press left, so the walk swaps words in - // place rather than piling them up. if let Some(walk) = resumed { self.cmd.clear_selection(); self.cmd.set_cursor(walk.at); @@ -4273,18 +2595,12 @@ impl TerminalView { self.cmd.delete_selection(); } self.cmd.insert_str(&word); - // `insert_str` replaces a live selection first, which moves the caret - // to the selection's start — so the word's position is wherever the - // caret landed minus the word, not the pre-insert cursor. let at = self.cmd.cursor() - word.chars().count(); self.last_word_nav = Some(LastWordWalk { entry, at, word }); - // The line is now the user's own edit, not a recalled entry. self.history_nav = None; cx.notify(); } - /// Recall the previous (older) history entry into the editor (↑). On the first - /// step it stashes the in-progress line so ↓ can restore it. fn history_prev(&mut self, cx: &mut Context<Self>) { if self.history.is_empty() { return; @@ -4294,7 +2610,7 @@ impl TerminalView { self.history_stash = self.cmd.text(); self.history.len() - 1 } - Some(0) => 0, // already at the oldest + Some(0) => 0, Some(i) => i - 1, }; self.history_nav = Some(next); @@ -4302,8 +2618,6 @@ impl TerminalView { cx.notify(); } - /// Move to the next (newer) history entry (↓); stepping past the newest - /// restores the stashed in-progress line. fn history_next(&mut self, cx: &mut Context<Self>) { let Some(i) = self.history_nav else { return; @@ -4312,7 +2626,6 @@ impl TerminalView { self.history_nav = Some(i + 1); self.cmd.set(&self.history[i + 1]); } else { - // Past the newest entry: back to the line the user was typing. self.history_nav = None; let stash = std::mem::take(&mut self.history_stash); self.cmd.set(&stash); @@ -4320,9 +2633,6 @@ impl TerminalView { cx.notify(); } - /// Re-rank `history_ranked` by frecency for `cwd`, so commands previously run - /// in that directory float to the top of ghost text and completion. Records the - /// directory used, so `poll_foreground` can skip re-ranking until it changes. fn rerank_history(&mut self, cwd: Option<&std::path::Path>) { let cwd_str = cwd.and_then(|p| p.to_str()); self.history_ranked = super::history::rank_by_frecency( @@ -4340,11 +2650,6 @@ impl TerminalView { self.ranked_cwd = cwd.map(std::path::Path::to_path_buf); } - /// Write the deferred history record (see [`PendingHistory`]), if any. The - /// exit code is attached only when the shell has reported back *and* sits - /// at its prompt again — then `last_exit_code()` is this command's; - /// otherwise (pane going away mid-command, a new submit racing in) the - /// record goes out without one, like a plain shell history line. fn flush_pending_history(&mut self) { let Some(p) = self.pending_history.take() else { return; @@ -4360,12 +2665,6 @@ impl TerminalView { super::history::append(&p.line, p.cwd.as_deref(), p.ts, exit); } - /// The autosuggestion (ghost text): the most *frecent* history entry that - /// starts with the current line, when the caret is at the end. Returns the - /// *full* suggested line; the renderer shows the remainder in muted text and - /// Right / Ctrl+F accepts it. `None` when the line is empty, the caret isn't at - /// the end, or nothing matches. Ranking by frecency (not raw recency) means the - /// command you actually run a lot wins over the last thing you happened to type. fn ghost_suggestion(&self) -> Option<String> { if self.cmd.is_empty() || self.cmd.cursor() != self.cmd.len() { return None; @@ -4377,17 +2676,6 @@ impl TerminalView { .cloned() } - /// Raise the one-shot integration notice if this Ctrl+R fell through to the - /// raw PTY path because shell integration never engaged (#46). Silent when - /// the raw path is expected instead: integration did engage and a foreground - /// command merely owns the PTY, a full-screen TUI owns the pane, or the - /// shell is still inside its startup grace window (slow rc files haven't - /// reached the first prompt report yet). - /// - /// Shows a generic message immediately, then refines it off-thread: the - /// daemon's foreground query sees the process actually holding the PTY, and - /// when that is a known shim (it exec'd over the shell we spawned), naming - /// it turns "the feature looks broken" into "here is the culprit". fn note_integration_gap(&mut self, cx: &mut Context<Self>) { if self.integration_notice_shown || self.terminal.shell_active() @@ -4401,13 +2689,8 @@ impl TerminalView { cx.notify(); let pane_id = self.pane_id; - // This pane's own daemon: the id below is only meaningful there, and on - // a remote workspace the local daemon would answer about a different - // pane that happens to share the number. let route = self.pane_route(); cx.spawn(async move |this, cx| { - // Best-effort: no daemon / unknown pane / unreadable process just - // leaves the generic message standing. let fg = cx .background_executor() .spawn(async move { @@ -4425,7 +2708,6 @@ impl TerminalView { } }); } - // Reading time is over either way; a keystroke usually beat us here. cx.background_executor() .timer(INTEGRATION_NOTICE_TIMEOUT) .await; @@ -4438,27 +2720,13 @@ impl TerminalView { .detach(); } - /// Begin a Ctrl+R history search (no-op if one is already active). Opens - /// with the empty query's frecency listing, so the menu is browsable - /// before a single key is typed. fn start_reverse_search(&mut self) { if self.reverse_search.is_none() { self.reverse_search = Some(ReverseSearch::new(&self.history, &self.history_frecency)); } } - /// Handle a key while a reverse search is active. The search itself owns the - /// query/match logic (`reverse_search` module); the view just applies the - /// resulting [`reverse_search::Action`] and repaints. fn handle_reverse_search_key(&mut self, ks: &gpui::Keystroke, cx: &mut Context<Self>) { - // Printable text typed into the query. A CJK input source routes it through - // the IME (`input_text` → `push_query`), but a plain ASCII input source — - // and Linux, where `prefers_ime_for_printable_keys` is false — delivers it - // here as an ordinary key event carrying `key_char`. Without this the search - // field can only be typed into via an IME: Ctrl+R opens, but ASCII - // keystrokes vanish. Mirror the editor's `key_char` path (`handle_editor_key`); - // control / Cmd / Alt chords and non-printable keys (Enter/Backspace/Esc have - // no printable `key_char`) fall through to the control-key handling below. let m = &ks.modifiers; if !m.control && !m.platform && !m.alt { if let Some(ch) = ks.key_char.as_deref() { @@ -4484,7 +2752,6 @@ impl TerminalView { } } reverse_search::Action::Run(line) => { - // Cmd+Enter: accept the selection and run it in one stroke. self.reverse_search = None; self.cmd.set(&line); self.submit_command(cx); @@ -4493,36 +2760,17 @@ impl TerminalView { cx.notify(); } - /// Hand the prompt line over to the shell so *its* keymap answers a chord - /// tty7 declines: ship the locally edited text to the PTY (no newline), - /// clear the editor, send `chord`, and suspend the local editor until the - /// shell's next report. From here the shell's own editor holds the text — - /// re-engaging ours mid-line would fork the two buffers (its Enter would - /// submit an empty local line on top of zle's populated one). - /// - /// A multi-line draft can't make the trip (see below): the chord is - /// swallowed and the line stays local. fn handoff_line_to_shell(&mut self, chord: &[u8], cx: &mut Context<Self>) { - // Fold in any gap input still held, so the shipped line is what the - // user actually typed. if let Some(net) = self.hold.engage() { self.cmd.prepend_str(&net); } let line = self.cmd.text(); - // An embedded newline would submit on the shell side (zle runs the - // line on `\r`), so a multi-line draft can't be handed over losslessly - // — keep it local and swallow the chord as before. if line.contains('\n') { cx.notify(); return; } self.close_completion(); - // A pending typeahead wipe's deferred `^U` would erase the very text - // we're about to ship; flush it first (FIFO keeps it ahead). self.wipe_pending_typeahead(); - // Chars right of the caret: after the shipped text lands, walk zle's - // cursor back over them so the shell acts on the word the caret was - // on, not the line's tail. let tail = line.chars().count().saturating_sub(self.cmd.cursor()); if !line.is_empty() { self.terminal.write(line.into_bytes()); @@ -4535,12 +2783,6 @@ impl TerminalView { self.send_to_pty(chord, cx); } - /// Tab / Shift-Tab arrived. Either our completion answers it, or the raw - /// key goes to the shell. - /// - /// One entry point for both directions so the "why didn't the menu open" - /// trace has one place to live — this is the question every report about - /// completion turns out to be. fn tab_pressed(&mut self, forward: bool, cx: &mut Context<Self>) { if self.search_focused { cx.propagate(); @@ -4555,29 +2797,15 @@ impl TerminalView { self.complete_tab(forward, cx); } - /// Hand the line over and let the shell have the Tab, so its native - /// completion (compsys, fzf-tab, …) answers what tty7 has nothing for. fn handoff_tab_to_shell(&mut self, shift: bool, cx: &mut Context<Self>) { let bytes = self.tab_bytes(shift); self.handoff_line_to_shell(&bytes, cx); } - /// Tab completion over our own engine (command names in command - /// position, filesystem paths elsewhere — history is deliberately absent: - /// whole-line recall is ghost text's and Ctrl+R's job). A fresh Tab applies a - /// unique match immediately; multiple matches fill the candidates' longest - /// common prefix and open the menu as a *picker* with the first row - /// highlighted — the line isn't touched again until a candidate is accepted. - /// With the menu open, Tab fills any further common prefix, else moves the - /// highlight (`forward` reverses for Shift-Tab). fn complete_tab(&mut self, forward: bool, cx: &mut Context<Self>) { - // Ctrl+R search owns the keyboard: `self.cmd` still holds the stale - // pre-search line, so neither completing it nor shipping it to the - // shell makes sense here. if self.reverse_search.is_some() { return; } - // tty7 completion switched off: every Tab goes to the shell. if !cx.global::<Config>().tab_completion { log::debug!(target: "tty7::completion", "handing the line to the shell: tab_completion is off"); self.handoff_tab_to_shell(!forward, cx); @@ -4588,15 +2816,6 @@ impl TerminalView { return; } - // Fresh completion. Path candidates come off the local filesystem, so - // they need a local cwd — a remote pane passes `None` and gets command - // completion only. Falling back to tty7's own directory there would - // offer *this* machine's filenames for insertion into a remote command - // line, where they don't exist. - // - // The `current_dir` fallback is for a *local* pane whose shell has not - // reported OSC 7 yet, and must stay behind the same gate: reaching it - // from a remote pane is the same wrong answer by a longer route. let cwd = self .paths_are_local() .then(|| self.local_cwd().or_else(|| std::env::current_dir().ok())) @@ -4604,14 +2823,9 @@ impl TerminalView { let line = self.cmd.text(); let cursor = self.cmd.cursor(); let Some(comp) = super::completion::complete(&line, cursor, cwd.as_deref()) else { - // Nothing *locally*. A native-SSH pane can still answer for the - // remote filesystem over its own connection — ask before giving up - // the line (see `spawn_remote_path_completion`). if self.spawn_remote_path_completion(&line, cursor, forward, cx) { return; } - // Nothing to offer. Don't swallow the keypress (#136) — hand the - // line to the shell and let its completion have the Tab. log::debug!( target: "tty7::completion", "handing the line to the shell: no candidates for {line:?} at {cursor} \ @@ -4622,16 +2836,8 @@ impl TerminalView { return; }; - // With generators inbound the candidate set is still growing, so the - // usual "unique sync match → accept" and "fill the common prefix" - // shortcuts are unsafe: a result landing a moment later could add or - // change the pick. Only the fully-static case (no pending) keeps the - // classic behavior byte-for-byte. let has_pending = !comp.pending.is_empty(); - // The word range is carried by any candidate; with none (pure-generator - // slot) derive it from the caret so the session still knows what it - // replaces. let (word_start, word_end) = match comp.candidates.first() { Some(c) => (c.start, c.end), None => (word_start_of(&line, cursor), cursor), @@ -4644,15 +2850,9 @@ impl TerminalView { has_pending, cx, ) else { - // A unique match was accepted outright; nothing is open to merge into, - // and a static-only slot has no generators anyway. return; }; - // Kick off each generator on the background executor and merge results - // back on the main thread, tagged with this session's generation. - // Generators are local shell-outs and only ever come from `complete`'s - // `Some(cwd)` branch, so a remote pane has none to run. let Some(cwd) = cwd else { return }; for pending in comp.pending { let script = pending.script; @@ -4673,14 +2873,6 @@ impl TerminalView { } } - /// Put `cands` in front of the user: accept a unique match outright, else - /// open the menu over them (filling the longest common prefix first). - /// Returns the opened session's generation, or `None` when a unique match - /// was accepted and no menu exists. - /// - /// `has_pending` means more candidates are still inbound, which disables - /// both shortcuts: a result landing a moment later could add to or change - /// the pick, and mutating the line before then would be jarring. fn offer_candidates( &mut self, line: &str, @@ -4707,8 +2899,6 @@ impl TerminalView { && let Some(lcp) = s.common_prefix() && lcp.chars().count() > word.chars().count() { - // Fill the longest common prefix when it extends the typed word. - // All candidates share it, so the fill never invalidates the set. self.apply_candidate(line, word_start, word_end, &lcp); } let generation = self.open_completion(s); @@ -4717,22 +2907,6 @@ impl TerminalView { Some(generation) } - /// The pane's cwd as a path on the *remote*, for panes whose filesystem - /// tty7 can ask about over a connection it holds. `None` for every other - /// kind, including a plain local pane. - /// - /// Two shapes qualify, and they are found by different signals: - /// - a **native-SSH pane** — tty7 dialled it, so `remote_context` says so - /// and the daemon holds the authenticated connection under its pane id; - /// - a **remote-workspace pane** — its `tty7-server` reports it as - /// an ordinary local pane (it *is* one, over there), so `remote_context` - /// is `None` and only this side's `workspace` binding reveals it. The - /// connection is the workspace's, not the pane's. - /// - /// Everything else declines rather than pretend: a foreground `ssh` and WSL - /// have no tty7-owned connection to ask (WSL falls out of - /// [`RemoteTerminal::workspace_request`], which needs an SSH spec), and a - /// local pane is the local engine's business. fn remote_ssh_cwd(&self) -> Option<String> { let owned = match self.terminal.remote_context() { Some(remote) => remote.kind == crate::daemon::protocol::RemoteKind::NativeSsh, @@ -4745,25 +2919,6 @@ impl TerminalView { cwd.starts_with('/').then_some(cwd) } - /// Complete a path against the *remote* filesystem, over the pane's own SSH - /// connection. Returns whether a request went out — the caller then leaves - /// the Tab to us instead of handing the line to the shell. - /// - /// The listing is a daemon round-trip (`SftpList` on the pane's existing - /// authenticated connection — the same channel the SFTP panel browses - /// with), so it cannot answer this keystroke synchronously. Results land on - /// the main thread and only *then* behave as a local Tab would; see - /// [`Self::remote_path_results`] for what happens to a stale or empty one. - /// - /// Out-of-band deliberately. The other way to read a remote directory is to - /// inject a listing command into the live shell and scrape it back out of - /// the PTY — the only option for a terminal that merely *bootstrapped into* - /// a session someone else dialled. tty7 opened this connection itself, so it - /// can just ask: nothing is echoed into the scrollback, no prompt hooks need - /// suppressing, and there's no stray background process to cancel when the - /// user hits Enter. The in-band route stays the answer for the pane kinds - /// that have no tty7-owned connection (a foreground `ssh`, WSL), which is - /// why those decline in [`Self::remote_ssh_cwd`] rather than pretend. fn spawn_remote_path_completion( &mut self, line: &str, @@ -4781,17 +2936,10 @@ impl TerminalView { ); return false; }; - // A listing for this same keystroke is already on the wire. Swallow the - // repeat rather than dialling again — the answer is about to arrive and - // will open the menu. if self.remote_completion_inflight { return true; } self.remote_completion_inflight = true; - // Resolved here, on the UI thread: the background call cannot reach the - // pane entity, and a remote-workspace pane's listing has to go out on - // the *workspace's* connection — its pane id names a pane on the far - // daemon, which this one cannot resolve. let route = crate::ui::sftp::SftpRoute::new(self.pane_id, self.workspace.clone()); let dir = req.dir.clone(); let line = line.to_string(); @@ -4817,17 +2965,6 @@ impl TerminalView { true } - /// Land a remote directory listing as completion candidates. - /// - /// Three outcomes, in the order they're checked: - /// - **the line moved on** while the network answered: drop it. The - /// answer describes a word the user is no longer typing, and the Tab - /// that asked for it is long past. - /// - **nothing matched**: fall back to the shell handoff, exactly as a - /// local no-match does (#136). A directory we couldn't read, or a - /// prefix with no entries, is then no worse off than before this - /// existed — the remote's own completion still gets its shot. - /// - **candidates**: offer them like any local Tab. fn remote_path_results( &mut self, req: completion::RemotePathRequest, @@ -4847,10 +2984,6 @@ impl TerminalView { let entries: Vec<completion::RemoteEntry> = listed .into_iter() .map(|e| completion::RemoteEntry { - // Follow symlinks when classifying, as the local path engine - // does: a link to a directory takes the trailing `/` and - // survives a dirs-only filter (`cd` into one is routine). The - // daemon resolved the target for us. is_dir: e.kind == crate::daemon::protocol::SftpEntryKind::Dir || e.target_is_dir, name: e.name, }) @@ -4870,25 +3003,16 @@ impl TerminalView { self.offer_candidates(line, req.word_start, req.cursor, cands, false, cx); } - /// Open a completion menu and bump the generation tag, returning it so a - /// caller spawning generators can stamp their in-flight results. Every open - /// gets a fresh generation, so a slow generator from a prior session can't be - /// mistaken for one belonging to this menu. fn open_completion(&mut self, session: CompletionSession) -> u64 { self.completion = Some(session); self.completion_generation = self.completion_generation.wrapping_add(1); self.completion_generation } - /// Close the menu, bumping the generation so any generator still running for - /// it is orphaned — its result will be dropped on arrival. No-op when nothing - /// is open. fn close_completion(&mut self) { let _ = self.take_completion(); } - /// Take the open session (for accept), bumping the generation like - /// [`Self::close_completion`]. fn take_completion(&mut self) -> Option<CompletionSession> { let s = self.completion.take(); if s.is_some() { @@ -4897,11 +3021,6 @@ impl TerminalView { s } - /// Merge a finished generator's candidates into the open menu. Dropped unless - /// the session that spawned it is still the current one (`generation` match) — - /// the guard against a result outliving its menu. Rebuilds the candidate set - /// against the *live* word (the caret may have moved on while the generator - /// ran) and repaints. fn completion_merge( &mut self, generation: u64, @@ -4937,10 +3056,6 @@ impl TerminalView { } } - /// Tab / Shift-Tab with the menu open: first try extending the line to the - /// filtered candidates' common prefix (bash-style fill); when that makes no - /// progress, move the highlight instead. A fill that pins down a single - /// candidate accepts it outright. fn completion_tab_step(&mut self, forward: bool, cx: &mut Context<Self>) { if forward { let Some(s) = self.completion.as_ref() else { @@ -4965,8 +3080,6 @@ impl TerminalView { self.completion_select(forward, cx); } - /// Move the completion highlight (Tab cycling and ↑/↓). Visual only — the - /// editor line changes on accept, not while browsing. fn completion_select(&mut self, forward: bool, cx: &mut Context<Self>) { if let Some(s) = self.completion.as_mut() { s.select(forward); @@ -4975,9 +3088,6 @@ impl TerminalView { } } - /// Accept the highlighted candidate: write it into the line and close the - /// menu. The command does not run — a second Enter (or Cmd+Enter in one - /// stroke) submits. fn completion_accept(&mut self, cx: &mut Context<Self>) { let Some(s) = self.take_completion() else { return; @@ -4989,10 +3099,6 @@ impl TerminalView { cx.notify(); } - /// Write `cand` into the editor over chars `[start, caret)` — the accept - /// action. Directories keep a trailing `/` so a further Tab descends; other - /// candidates get a trailing space only when the caret is at the end of the - /// line (mid-line, the existing tail already separates the word). fn completion_insert(&mut self, cand: &completion::Candidate, start: usize) { let line = self.cmd.text(); let len = line.chars().count(); @@ -5008,10 +3114,6 @@ impl TerminalView { self.apply_candidate(&line, start, cursor, &text); } - /// Re-filter the open menu after an edit at the caret: the live word must - /// still extend the word the menu opened on and keep at least one candidate, - /// else the menu closes. Whitespace in the word (a new argument) closes it - /// too. No-op when no menu is open. fn completion_refilter(&mut self) { let Some(s) = self.completion.as_mut() else { return; @@ -5031,9 +3133,6 @@ impl TerminalView { } } - /// Splice `text` into `orig` over the char range `[start, end)` and put the - /// result into the editor. Delegates to `completion::Replacement` so the - /// edit is unit-tested there. fn apply_candidate(&mut self, orig: &str, start: usize, end: usize, text: &str) { let (line, cursor) = completion::Replacement { orig: orig.to_string(), @@ -5045,21 +3144,14 @@ impl TerminalView { self.cmd.set_with_cursor(&line, cursor); } - /// Commit text from the terminal's IME handler. While idle at the prompt this - /// inserts into our local command editor; while a command runs it writes - /// straight to the PTY (bare-terminal behavior). Covers both plain typed text - /// (routed through the IME) and committed CJK characters. pub fn input_text(&mut self, text: &str, cx: &mut Context<Self>) { self.commit_text(text, cx); } - /// See `input_text`. The single text-commit path, split by whether the editor - /// is live at the prompt. pub fn commit_text(&mut self, text: &str, cx: &mut Context<Self>) { if self.terminal.exited || text.is_empty() || !self.accepts_input(cx) { return; } - // While reverse-searching, typed text edits the query, not the line. if let Some(rs) = self.reverse_search.as_mut() { rs.push_query(text, &self.history, &self.history_frecency); self.cursor_visible = true; @@ -5067,37 +3159,26 @@ impl TerminalView { return; } if self.input_active() { - // Editing the command line locally — insert at the caret. Typing - // breaks out of history navigation; an open completion menu - // re-filters to the extended word (and closes once nothing matches). self.cmd.insert_str(text); self.history_nav = None; self.editor_goal_col = None; - // Typed text ends an ⌥. run: IME-committed text bypasses - // `handle_editor_key`'s reset, so it has to happen here too. self.last_word_nav = None; self.completion_refilter(); self.cursor_visible = true; cx.notify(); return; } - // Gap typing: offered to the hold first (a fast command's typeahead - // then lands in the editor without ever echoing), else written raw - // and kept in step with the typeahead record (see `hold`/`typeahead`). self.write_gap_text(text, text.as_bytes().to_vec(), cx); - // Keep the cursor solid while committing input (resets the blink phase). self.cursor_visible = true; self.jump_to_prompt(); cx.notify(); } - /// Set the IME pre-edit (composing) text to display at the cursor. pub fn set_marked_text(&mut self, text: String, cx: &mut Context<Self>) { self.marked_text = text; cx.notify(); } - /// Clear the IME pre-edit state. pub fn clear_marked_text(&mut self, cx: &mut Context<Self>) { if !self.marked_text.is_empty() { self.marked_text.clear(); @@ -5119,10 +3200,6 @@ impl TerminalView { let display_offset = term.grid().display_offset() as i32; let point = Point::new(Line(row as i32 - display_offset), Column(col)); let side = if left { Side::Left } else { Side::Right }; - // Shift+click extends the existing selection to the click instead of - // starting over (à la iTerm2). A plain click always leaves a - // collapsed Simple selection behind, so the anchor is wherever the - // last gesture ended. if shift && clicks == 1 && term.selection.is_some() { if let Some(sel) = term.selection.as_mut() { sel.update(point, side); @@ -5133,16 +3210,11 @@ impl TerminalView { return; } let ty = match clicks { - 2 => SelectionType::Semantic, // word + 2 => SelectionType::Semantic, n if n >= 3 => SelectionType::Lines, _ => SelectionType::Simple, }; let mut selection = Selection::new(ty, point, side); - // Double-click smart selection: a URL / path / email / bracket pair / - // CJK word containing the clicked word replaces the plain word span. - // Boundary-flanked candidates anchor a Semantic selection (keeping - // the drag gesture word-wise); exact ones use Simple so alacritty - // can't re-expand the endpoints past the smart boundary. if clicks == 2 && smart && let Some(r) = super::smart_select::grid_smart_range(&term, point) @@ -5176,12 +3248,6 @@ impl TerminalView { cx.notify(); } - /// Drive selection auto-scroll from a drag's vertical overshoot past the - /// pane bounds, in lines (0 while the pointer is inside, positive above - /// the top edge). Called on every left-drag move: entering the edge zone - /// arms a repeating task that scrolls the scrollback and keeps extending - /// the selection at the edge row; later moves just retune its speed and - /// column, and moving back inside (or releasing) stops it. pub fn select_autoscroll( &mut self, overshoot: f32, @@ -5201,13 +3267,8 @@ impl TerminalView { side, }); if !was_idle { - // The running task reads the fresh state on its next tick. return; } - // First step immediately so a quick flick past the edge still moves, - // then keep stepping on a timer. The task stops itself once the state - // clears (pointer back inside, drag ended), a newer task supersedes - // it (epoch mismatch), or the view is dropped. self.drag_scroll_epoch += 1; let epoch = self.drag_scroll_epoch; self.drag_scroll_tick(epoch, cx); @@ -5227,16 +3288,8 @@ impl TerminalView { .detach(); } - /// One auto-scroll step: scroll by an amount that grows with the - /// overshoot, then re-anchor the selection's moving end to the edge row - /// it is pushing past (top row when scrolling up, bottom when down). - /// Returns whether the task should keep ticking. Scrolling clamps at the - /// history limits, so pinning the pointer past the edge at the top of - /// scrollback just idles until it moves. fn drag_scroll_tick(&mut self, epoch: u64, cx: &mut Context<Self>) -> bool { if epoch != self.drag_scroll_epoch { - // Superseded by a newer task: the state now belongs to it, so just - // bow out without clearing anything. return false; } if !self.selecting { @@ -5266,11 +3319,6 @@ impl TerminalView { true } - /// Mouse-up: the selection gesture (if any) is over. With copy-on-select - /// enabled, the selection the gesture drove goes straight to the clipboard - /// — [`select_end_copy`] picks the buffer, and empty selections (a plain - /// click repositioning the caret / collapsing the old selection) write - /// nothing because both copy paths drop empty text. pub fn on_select_end(&mut self, cx: &mut Context<Self>) { let copy = select_end_copy( cx.global::<Config>().copy_on_select, @@ -5301,12 +3349,6 @@ impl TerminalView { }; let delta = raw * mult; - // Mouse-tracking reports and alternate-scroll arrow keys consume whole - // lines, so those paths accumulate fractional deltas and spend only the - // whole part: rounding each trackpad event separately either discards - // them all (slow scrolls stall) or over-counts them (each tiny nudge - // becomes a full line). Shift forces local scrollback, matching - // `scroll`'s own routing. let quantized = !ev.modifiers.shift && { let mode = *self.terminal.term.lock().mode(); mode.intersects(TermMode::MOUSE_MODE) @@ -5322,25 +3364,13 @@ impl TerminalView { return; } - // Local scrollback keeps the fraction instead: the view position is - // continuous and every wheel event moves pixels, not lines. self.smooth_scroll(delta, cx); } - /// The pane's OSC 133 command marks, newest last — the Outline's rows. pub fn command_marks(&self) -> Vec<crate::terminal::marks::CommandMark> { self.terminal.marks().list() } - /// Scroll so the command recorded at `row` sits near the top of the viewport. - /// Returns `false` when the mark has aged out of the scrollback, so the - /// caller can say so rather than leaving the user staring at an unchanged - /// screen wondering whether the click registered. - /// - /// `row` is an index from the top of history, which drifts once the - /// scrollback saturates (see the `terminal::marks` docs). A drifted mark - /// still scrolls *somewhere* — it just may not be the exact prompt — so the - /// only failure reported here is a row that has fallen off entirely. pub fn scroll_to_mark(&mut self, row: i64, cx: &mut Context<Self>) -> bool { use alacritty_terminal::grid::Dimensions as _; let mut term = self.terminal.term.lock(); @@ -5348,23 +3378,15 @@ impl TerminalView { if row < 0 || row > history + term.grid().screen_lines() as i64 { return false; } - // `display_offset` counts *up* from the bottom of history, so the offset - // that puts `row` at the viewport's top line is its distance from there. let target = (history - row).max(0); let current = term.grid().display_offset() as i64; term.scroll_display(Scroll::Delta((target - current) as i32)); drop(term); - // A jump lands wherever it lands; the fractional offset is a smooth-scroll - // artifact and would otherwise shift the paint off the line boundary. self.scroll_frac = 0.; cx.notify(); true } - /// Scroll the local scrollback by a possibly-fractional number of lines, - /// pixel-smooth: whole lines go to the emulator's `display_offset`, the - /// remainder stays in `scroll_frac` and shifts the paint. The position may - /// come to rest between line boundaries, like a native scroll view. fn smooth_scroll(&mut self, delta: f32, cx: &mut Context<Self>) { let mut term = self.terminal.term.lock(); let offset = term.grid().display_offset(); @@ -5380,15 +3402,6 @@ impl TerminalView { } } - /// The grid line a screen `row` currently maps to, or `None` when that row - /// is outside the grid. - /// - /// Mandatory before indexing: `Grid`'s `Index<Line>` only `debug_assert`s - /// the bound, so a release build walks off the storage and panics on the - /// slice check instead. A remembered cell goes stale whenever the grid - /// shrinks under it — split a pane, drag the window smaller — and the - /// callers here run inside gpui's `extern "C"` input callbacks, where that - /// panic can't unwind and aborts the process. fn grid_line( term: &alacritty_terminal::Term<crate::terminal::remote::EventProxy>, row: usize, @@ -5397,8 +3410,6 @@ impl TerminalView { (line >= term.topmost_line() && line <= term.bottommost_line()).then_some(line) } - /// Open the link under the given cell, if any (OSC 8 hyperlink, plain URL or - /// existing file or directory path detected in the row text). Returns true if one opened. pub fn open_link_at( &self, col: usize, @@ -5417,8 +3428,6 @@ impl TerminalView { match target { LinkTarget::Url(url) => self.open_url(&url, window, cx), LinkTarget::File { path, line, column } => { - // A configured template (e.g. opening the file in an editor) - // takes precedence; otherwise fall back to the OS opener. match cx.global::<Config>().link_file_command.as_deref() { Some(template) => run_file_command(template, &path, line, column), None => open_file_path(&path), @@ -5432,8 +3441,6 @@ impl TerminalView { match self.forwarded_loopback_url(url, cx) { LoopbackOpen::Forwarded(url) => cx.open_url(&url), LoopbackOpen::NotLoopback => cx.open_url(url), - // The click produced no browser tab, so it has to say why: silence - // here reads as tty7 having ignored the click. LoopbackOpen::ForwardFailed(reason) => { window.push_notification(reason, cx); } @@ -5448,20 +3455,10 @@ impl TerminalView { let Some(loopback) = super::loopback::parse_loopback_url(url) else { return LoopbackOpen::NotLoopback; }; - // WSL shares the Windows host's `localhost`, so the URL already points at - // the right place — building a forward would be pure overhead. if matches!(plan, LoopbackPlan::NoForwardNeeded) { return LoopbackOpen::NotLoopback; } - // Establishing a local forward is a `bind()` on this machine plus a - // registry insert — no SSH round-trip happens until the browser actually - // connects — so this stays a blocking call rather than paying for an - // async hop the user would perceive as the click doing nothing. - // - // The local port is always ephemeral (`bind_port: 0`, chosen by the OS): - // the remote's 3000 may well be taken here, and the URL is rewritten to - // whichever port we actually got. let forwarded = match &plan { LoopbackPlan::ForwardOnPane(pane_id) => RemoteTerminal::ensure_loopback_forward( *pane_id, @@ -5480,7 +3477,6 @@ impl TerminalView { } } - /// Ask the daemon for a workspace-owned forward to `loopback`'s port. fn ensure_workspace_loopback( &self, ws: &crate::terminal::PaneWorkspace, @@ -5501,7 +3497,6 @@ impl TerminalView { } } - /// Decide how a ⌘-clicked `localhost:PORT` in *this* pane should be opened. fn loopback_plan(&self, cx: &mut Context<Self>) -> LoopbackPlan { loopback_plan( cx.global::<Config>().ssh_loopback_forward, @@ -5515,10 +3510,6 @@ impl TerminalView { !matches!(self.loopback_plan(cx), LoopbackPlan::Direct) } - /// Update the remembered hovered link for the screen cell `(col, row)` and - /// repaint if it changed. Returns whether a link sits under the cursor, so the - /// element can switch to a pointing-hand cursor. Cheap on the common case: any - /// non-URL cell resolves to `None` and bails. pub fn hover_link_at( &mut self, col: usize, @@ -5527,8 +3518,6 @@ impl TerminalView { cx: &mut Context<Self>, ) -> bool { self.last_hover_cell = Some((col, row)); - // URL detection off → never underline or switch to the pointing hand, - // and drop any underline a prior hover left behind. if !cx.global::<Config>().link_url { self.clear_hovered_link(cx); return false; @@ -5554,8 +3543,6 @@ impl TerminalView { self.link_modifier_down } - /// Forget any hovered link (mouse left the grid, or moved onto plain text), - /// repainting to drop the underline. pub fn clear_hovered_link(&mut self, cx: &mut Context<Self>) { self.last_hover_cell = None; if self.hovered_link.take().is_some() { @@ -5563,10 +3550,6 @@ impl TerminalView { } } - /// Resolve the link span at screen cell `(col, row)`: an OSC 8 hyperlink (the - /// contiguous run of cells sharing the same target), a bare URL token, or an - /// existing file or directory path in the row text. Mirrors [`open_link_at`](Self::open_link_at)'s - /// detection so the underline covers exactly what a Cmd+click would open. fn link_span_at( &self, col: usize, @@ -5578,11 +3561,6 @@ impl TerminalView { .map(|(_, start, end)| HoveredLink { start, end }) } - /// The link under screen cell `(col, row)` and the inclusive grid points it - /// spans, shared by hover-underline and click-to-open so both agree on the - /// extent. Resolution runs over the *logical* line — soft-wrapped rows plus - /// producer hard newlines are stitched back together — so a URL split across - /// rows resolves whole instead of stopping at the first row edge. fn resolve_link_at( &self, col: usize, @@ -5598,8 +3576,6 @@ impl TerminalView { } let click = Point::new(line, Column(col)); - // 1) Explicit OSC 8 hyperlink: highlight the whole contiguous run - // carrying the same URI, following soft wraps across rows. if let Some(hl) = term.grid()[line][Column(col)].hyperlink() { let uri = hl.uri().to_string(); if let Some((start, end)) = super::smart_select::hyperlink_run(&term, click) { @@ -5607,13 +3583,8 @@ impl TerminalView { } } - // 2) Bare URL or file path detected in the logical line. `bridge_hard_wrap` - // is on so a URL a program printed with a literal `\n` mid-way is - // recovered whole, not truncated at the break. let (text, points, click_idx) = super::smart_select::logical_line_at(&term, click, true)?; drop(term); - // Same gate as the click path — a relative path is resolved against the - // cwd and stat-checked, so a remote pane's cwd must not be used. let cwd = self.local_cwd(); let link = super::search::link_at(&text, click_idx, cwd.as_deref(), include_files) .or_else(|| { @@ -5630,27 +3601,12 @@ impl TerminalView { Some((link.target, points[link.start], points[link.end])) } - /// The inline command line, anchored right where the shell prompt - /// ends (the cursor cell) and shown only while `input_active`. It carries the - /// terminal's own font over a transparent background, with no chrome of its - /// own, so the typed text reads as a natural continuation of the shell prompt - /// rather than a separate widget. The terminal's own block cursor is hidden - /// while the editor is live (see `element::paint`), leaving the field's caret - /// as the single cursor. fn render_input_bar(&self, cx: &mut Context<Self>) -> impl IntoElement + use<> { let (crow, ccol) = self.cursor_cell().unwrap_or((0, 0)); let cx_left = px(GRID_PAD_X) + self.cell_width * (ccol as f32); - // The overlay rides the same upward shift `element::paint` applies to - // the grid when the wrapped input would spill past the bottom, so its - // first line keeps hugging the (shifted) prompt row. May go negative - // when the input is taller than the screen — the parent's - // `overflow_hidden` clips the rows that scroll off the top. let shift = self.input_scroll_rows(); let cy_top = px(GRID_PAD_Y) + self.line_height * (crow as f32 - shift as f32); - // Reverse-search mode replaces the line with a `(reverse-i-search)` prompt - // showing the query and the selected match; the ranked candidates float - // in their own menu (`render_reverse_search_menu`). if let Some(rs) = &self.reverse_search { let label = format!("(reverse-i-search)`{}': ", rs.query()); let matched = rs @@ -5692,22 +3648,13 @@ impl TerminalView { let fg = theme.foreground; let caret_col = theme.caret; let muted = theme.muted_foreground; - // The theme's dedicated selection color, kept translucent so the colored - // text still reads through it. let mut sel_bg = theme.selection; sel_bg.a = 0.55; let cell_w = self.cell_width; let lh = self.line_height; - // The blinking bar caret should be as tall as the *text*, not the full - // line box: `lh` is `font_size × line_height_mul` (e.g. 1.35×), so a - // full-height bar visibly pokes above/below the glyphs (which the cells - // centre within `lh`). Size it to roughly the glyph extent and centre it - // in the cell so it hugs the text like a normal editor caret. let caret_h = px((self.font_size.as_f32() * 1.2).min(lh.as_f32())); let caret_top = px((lh.as_f32() - caret_h.as_f32()) / 2.0); - // Per-char syntax color, expanded from the highlighter's spans (which tile - // the whole line), so each character cell can be colored independently. let line: String = chars.iter().collect(); let mut colors: Vec<gpui::Hsla> = Vec::with_capacity(len); for span in highlight::highlight(&line) { @@ -5717,19 +3664,7 @@ impl TerminalView { } } - // Render the input one fixed-width cell per character. This makes the wrap - // deterministic (exactly grid-width cells per row), so a click anywhere — - // including a wrapped continuation line — maps back to a char index (see - // `editor_char_index`). The caret is an absolutely-positioned bar inside a - // cell, so it never perturbs cell widths. let cursor_on = self.cursor_visible; - // The editor caret honours the configured `cursor_style`, matching the - // grid cursor `paint_cursor` draws while a program runs — otherwise the - // shape setting would appear to do nothing at the (most common) prompt. - // Bar = a thin vertical line; Block = a translucent fill over the cell (so - // the glyph still reads through, like the grid block); Underline = a line - // along the cell's baseline. All are absolutely positioned inside their - // relative parent cell, so `w_full` spans exactly one (wide-aware) cell. let cursor_style = cx.global::<Config>().cursor_style; let caret_bar = move || { use crate::core::config::CursorStyle; @@ -5744,9 +3679,6 @@ impl TerminalView { } }; let cell = |color: gpui::Hsla, ch: char, selected: bool, caret: bool, underline: bool| { - // Wide (CJK / fullwidth / emoji) glyphs occupy two terminal cells, so - // size the box accordingly — otherwise the glyph is clipped by the - // next cell and the click→char mapping drifts. let w = cell_w * (display_width(ch) as f32); let mut d = div() .relative() @@ -5769,30 +3701,14 @@ impl TerminalView { d.into_any_element() }; - // A blank cell of the given width and the line height — used for the - // leading prompt spacer and for a selected/caret slot standing in for a - // hard line break. let blank = move |w: gpui::Pixels| div().flex_none().w(w).h(lh); - // The buffer's logical lines, each rendered as its own `flex_wrap` row and - // stacked in a column, so an embedded `'\n'` (from a pasted multi-line - // command, or Shift/Opt+Enter) shows as a real line break instead of - // flowing into one wrapped blob. Within a line, soft-wrapping is left to - // `flex_wrap` exactly as before. `lines` grows a fresh row on each `'\n'`. - let mut lines: Vec<Vec<gpui::AnyElement>> = vec![vec![ - // Leading spacer the width of the shell prompt: the first line begins - // right after the prompt; continuation lines start at the grid's left - // edge, matching how the shell lays a multi-line command out. - blank(cell_w * (ccol as f32)).into_any_element(), - ]]; + let mut lines: Vec<Vec<gpui::AnyElement>> = + vec![vec![blank(cell_w * (ccol as f32)).into_any_element()]]; - // Ghost suggestion only makes sense for a single-line command (it completes - // the whole history entry); suppress it once the buffer holds a newline. let is_multiline = chars.contains(&'\n'); for i in 0..len { - // IME pre-edit shows underlined at the caret; the bar caret is hidden - // while composing. if i == cursor && has_marked { for mc in marked.chars() { lines @@ -5802,11 +3718,6 @@ impl TerminalView { } } if chars[i] == '\n' { - // The newline is a hard break, not a glyph. If the caret sits on it - // (end of this visual line) draw a trailing caret slot before the - // break so it stays visible; if the newline falls inside a selection - // draw a thin selected slot so a multi-line selection reads across - // the break. Then start the next row. if selection.is_none() && !has_marked && cursor_on && cursor == i { lines.last_mut().unwrap().push( blank(cell_w) @@ -5831,10 +3742,6 @@ impl TerminalView { .push(cell(colors[i], chars[i], selected, caret, false)); } - // Ghost autosuggestion remainder (only when caret is at the end, no - // selection / IME / newline), computed up front so the end-of-line caret can - // ride on the first ghost cell instead of needing its own (which would push - // the ghost a full cell to the right). let ghost: Option<String> = if selection.is_none() && !has_marked && !is_multiline { self.ghost_suggestion() .map(|full| full.chars().skip(len).collect::<String>()) @@ -5843,8 +3750,6 @@ impl TerminalView { None }; - // Caret / pre-edit at the end of the buffer — lands on the last row (a - // fresh empty row when the buffer ends in a newline). if cursor == len { let last = lines.last_mut().unwrap(); if has_marked { @@ -5852,15 +3757,12 @@ impl TerminalView { last.push(cell(fg, mc, false, false, true)); } } else if ghost.is_none() { - // No ghost following: a trailing cell carries the caret (and is the - // click target for "end of line"). let mut tail = blank(cell_w).relative(); if selection.is_none() && cursor_on { tail = tail.child(caret_bar()); } last.push(tail.into_any_element()); } - // else: the caret rides on the first ghost cell below. } if let Some(rem) = ghost { @@ -5889,8 +3791,6 @@ impl TerminalView { .min_h(lh) .flex() .flex_col() - // Transparent: the text overlays the grid in place, reading as a - // natural continuation of the shell prompt rather than a separate bar. .font_family(self.font.family.clone()) .text_size(self.font_size) .line_height(lh) @@ -5898,29 +3798,15 @@ impl TerminalView { .children(rows) } - /// The floating completion menu, shown below the word while a completion is - /// active. Renders the re-filtered candidates with the picked row - /// highlighted; the list is capped with a "+N more" footer so a huge match - /// set stays compact. fn render_completion_menu(&self, cx: &mut Context<Self>) -> Option<impl IntoElement + use<>> { let s = self.completion.as_ref()?; - // The re-filtered view of the candidates; refilter() closes the session - // before this can go empty, but guard anyway. let items: Vec<&completion::Candidate> = s.filtered.iter().map(|&i| &s.all[i]).collect(); if items.is_empty() { return None; } let (srow, scol) = self.cursor_cell()?; - // Anchor rows are *visual*: when the overflowing input shifts the whole - // surface up (`input_scroll_rows`), the menu must follow the shifted - // input row, not the unshifted grid row. let srow = srow.saturating_sub(self.input_scroll_rows()); - // Decide how many rows to show and whether to drop the menu below the input - // row or flip it above — based on the room actually available in the grid, - // so a prompt near the bottom of the window doesn't push the menu off - // screen. The window-around-the-selection keeps the highlighted candidate - // visible even when the full list is taller than the space. const MAX_ROWS: usize = 10; let total_rows = self.terminal.term.lock().screen_lines(); let (place_above, visible, first) = menu_layout( @@ -5934,24 +3820,16 @@ impl TerminalView { let hidden_below = items.len() - first - visible; let theme = cx.theme(); - // Each row is forced to exactly `line_height` so the `menu_h` estimate - // below is exact — critical for upward placement, where an underestimate - // would let the menu's real bottom edge cover the input line. let lh = self.line_height; let row = |i: usize| { let cand = items[i]; let selected = s.index == Some(i); - // Leading icon: the Fig spec's per-entry icon when present (emoji - // rendered as-is, `fig://icon?type=…` mapped to a bundled glyph), - // else a per-kind default. Glyphs stay monochrome (muted, like the - // tab strip); emoji keep their own color. let icon_color = if selected { theme.foreground } else { theme.muted_foreground }; let icon = completion_row_icon(cand.icon.as_deref(), cand.kind, icon_color); - // Directories show their trailing `/` in the menu too. let label = if cand.is_dir() && !cand.text.ends_with('/') { format!("{}/", cand.text) } else { @@ -5964,20 +3842,11 @@ impl TerminalView { .gap_1p5() .px_2() .whitespace_nowrap() - // Use the app-tuned `list_active` fill (same as the command - // palette) rather than the stock `accent`: `apply_theme` never - // overrides `accent`, so in light mode it stays a near-white - // `neutral-100` that vanishes against the white popover — the - // selection looked unhighlighted. `list_active` is a per-theme - // bg/fg blend that reads clearly in both light and dark. .when(selected, |d| { d.bg(theme.list_active).text_color(theme.foreground) }) .child(icon) .child(div().flex_shrink_0().child(label)) - // Second column: the flag/subcommand description from the command - // signature — muted, sized to its content. The menu's `max_w` + - // `overflow_hidden` clip an over-long line; the name never shrinks. .when_some(cand.description.clone(), |d, desc| { d.child(div().ml_2().text_color(theme.muted_foreground).child(desc)) }) @@ -5985,7 +3854,6 @@ impl TerminalView { }; let rows: Vec<gpui::AnyElement> = (first..first + visible).map(row).collect(); - // Menu height (for upward placement) = rows + any overflow footers. let footer = |n: usize, label: String| { (n > 0).then(|| { div() @@ -6002,10 +3870,7 @@ impl TerminalView { let line_count = visible + footer_lines; let menu_h = self.line_height * (line_count as f32) + px(10.); - // A small gap so the menu never sits flush against the input line — in - // particular, when flipped above it clears the caret instead of covering it. let gap = px(6.); - // Anchor at the command start (the cursor cell), where the line begins. let x = px(GRID_PAD_X) + self.cell_width * (scol as f32); let y = if place_above { px(GRID_PAD_Y) + self.line_height * (srow as f32) - menu_h - gap @@ -6037,12 +3902,6 @@ impl TerminalView { ) } - /// The floating Ctrl+R history menu: the ranked matches (best first) in a - /// completion-style popup anchored to the input row — matched characters - /// highlighted, the last-run time and a failure badge on the right. The - /// classic `(reverse-i-search)` prompt stays on the input row itself - /// (`render_input_bar`); this menu is the browsable view of the candidates, - /// windowed around the selection like the completion menu. fn render_reverse_search_menu( &self, cx: &mut Context<Self>, @@ -6077,8 +3936,6 @@ impl TerminalView { theme.popover_foreground }; - // The command in runs of matched/unmatched characters, so the - // query's hits read highlighted inside the (possibly clipped) text. let mut spans: Vec<gpui::AnyElement> = Vec::new(); let mut flush = |run: &mut String, hit: bool| { if run.is_empty() { @@ -6106,8 +3963,6 @@ impl TerminalView { } flush(&mut run, run_hit); - // Right column: a failure badge when the last run exited non-zero, - // and how long ago that run was. let meta = self.history_meta.get(line); let failed = meta.and_then(|em| em.exit).filter(|&e| e != 0); let ago = meta @@ -6121,8 +3976,6 @@ impl TerminalView { .gap_1p5() .px_2() .whitespace_nowrap() - // Same selection fill as the completion menu (see the note - // there on `list_active` vs the stock `accent`). .when(selected, |d| d.bg(theme.list_active)) .child(div().flex_1().flex().overflow_hidden().children(spans)) .when_some(failed, |d, code| { @@ -6145,7 +3998,6 @@ impl TerminalView { }; let rows: Vec<gpui::AnyElement> = (first..first + visible).map(row).collect(); - // Menu height (for upward placement) = rows + any overflow footers. let footer = |n: usize, label: String| { (n > 0).then(|| { div() @@ -6162,11 +4014,6 @@ impl TerminalView { let line_count = visible + footer_lines; let menu_h = lh * (line_count as f32) + px(10.); - // Anchored at the line's left edge (unlike the completion menu, which - // anchors at the current word): history rows are whole commands, so - // the menu spans the input area at a fixed width — that keeps the - // right-hand metadata column vertically aligned across rows. A small - // gap keeps it clear of the input line and its caret. let gap = px(6.); let grid_w = self.cell_width * (total_cols as f32); let menu_w = if grid_w < px(720.) { grid_w } else { px(720.) }; @@ -6199,10 +4046,6 @@ impl TerminalView { ) } - /// The one-shot "shell integration didn't engage" notice (#46): a single - /// floating line, bottom-right so it reads as a status aside rather than - /// part of the prompt. Rendered whenever set — unlike the editor overlays - /// it exists precisely because `input_active()` is false. fn render_integration_notice( &self, cx: &mut Context<Self>, @@ -6227,7 +4070,6 @@ impl TerminalView { ) } - /// Map a highlighter token kind to a theme color. fn kind_color(&self, kind: TokenKind, cx: &App) -> gpui::Hsla { let theme = cx.theme(); match kind { @@ -6250,30 +4092,13 @@ impl Focusable for TerminalView { impl Drop for TerminalView { fn drop(&mut self) { - // A history record still deferred when the pane goes away (tab closed, - // window closed — possibly mid-command) is flushed rather than lost; - // it carries an exit code only if the shell had already reported back. self.flush_pending_history(); } } impl Render for TerminalView { fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { - // Editor live: adopt anything typed while it was disengaged. Held gap - // input goes straight in — the PTY never saw those bytes, so nothing - // needs a wipe. Input that did reach the PTY waits for zle to read - // (`zle_reading`) so its ^U wipe is consumed silently; the `133;B` - // that arms the flag arrives as pane output, so a render always - // follows it (Output → Wakeup → notify). Both prepend: they were - // typed before any post-engage keys already sitting in the editor. if self.shell_owns_prompt() { - // A vi-mode (or handed-off) prompt never engages the editor: - // release held gap input to the shell's own line editor (raw, no - // typeahead record — there is no local adoption to reconcile - // against) and drop any pending record without its `^U`. Those - // bytes land on zle's line and are the shell's to keep; a record - // surviving this prompt would flush at the next editor-engaged - // prompt and resurrect long-consumed text into the editor. if let Some((_net, bytes)) = self.hold.release() { self.terminal.write(bytes); } @@ -6292,10 +4117,6 @@ impl Render for TerminalView { .as_ref() .map(|s| self.render_search_bar(s, window, cx)); - // The command editor lives on the terminal's own focus handle (no separate - // input widget to focus), so there's no per-frame focus routing: the - // terminal keeps focus throughout, and the editor overlay is rendered only - // while idle at the prompt. let input_bar = self.input_active().then(|| self.render_input_bar(cx)); let completion_menu = self .input_active() @@ -6305,19 +4126,10 @@ impl Render for TerminalView { .input_active() .then(|| self.render_reverse_search_menu(cx)) .flatten(); - // Not gated on `input_active()`: the notice explains why the editor - // overlays are absent, so it renders exactly when they can't. let integration_notice = self.render_integration_notice(cx); - // Captured for the right-click menu: the focus handle routes dispatched - // actions to this terminal (and lets tab/split ones bubble to the root), - // and the selection state greys out "Copy" / "Cut" when there's nothing - // selected in either the grid or the prompt editor. let menu_focus = self.focus_handle.clone(); let has_selection = self.any_selection(); - // Read at menu-open time (see the fork block below), so the fork rows' - // enablement can't go stale between render and click — and so the - // render path doesn't pay for an `agent_session()` clone every frame. let menu_view = cx.entity(); div() @@ -6329,41 +4141,23 @@ impl Render for TerminalView { .overflow_hidden() .px(px(GRID_PAD_X)) .py(px(GRID_PAD_Y)) - // No background of its own: the window root paints the theme's - // background (solid, gradient, or image — see `Tty7App::render`), - // and default-background cells don't paint either, so it shows - // through every pane. A surface-level fill here would both hide - // gradients/images and double-composite a translucent theme's alpha. .text_color(cx.theme().foreground) .on_key_down(cx.listener(Self::on_key_down)) .on_scroll_wheel(cx.listener(Self::on_scroll)) .on_mouse_down( MouseButton::Left, cx.listener(|this, _ev: &MouseDownEvent, window, cx| { - // A focusable child that was clicked (e.g. the search field) - // has already claimed focus via gpui's track_focus auto-focus - // and called `prevent_default`. Honor that convention and don't - // steal focus back — otherwise clicking into the search bar - // instantly bounces focus to the terminal and the field can - // never be re-entered for editing. if window.default_prevented() { return; } window.focus(&this.focus_handle, cx); }), ) - // Files dragged from Finder (etc.) onto the terminal insert their - // shell-escaped paths like a paste. `drag_over` tints the surface so - // the drop target is obvious while a drag hovers. .drag_over::<ExternalPaths>(|s, _, _, cx| s.bg(cx.theme().drag_border.opacity(0.12))) .on_drop(cx.listener(|this, paths: &ExternalPaths, window, cx| { window.focus(&this.focus_handle, cx); this.drop_files(paths, cx); })) - // Context-menu actions handled by this view; tab/split actions in the - // same menu fall through to `Tty7App`. - // Menu-dispatched copy leaves the selection up (`clear_on_copy: - // false`) — only the dual-purpose ⌃C chord has to consume it. .on_action(cx.listener(|this, _: &CopyText, _w, cx| { this.copy_contextual(false, cx); })) @@ -6377,8 +4171,6 @@ impl Render for TerminalView { .on_action( cx.listener(|this, _: &FindInTerminal, window, cx| this.open_search(window, cx)), ) - // Find-again: step to the next / previous match. No-op when the bar is - // closed (nothing to step through). .on_action(cx.listener(|this, _: &FindNext, _w, cx| { this.step_match(Direction::Right, cx); })) @@ -6386,16 +4178,9 @@ impl Render for TerminalView { this.step_match(Direction::Left, cx); })) .on_action(cx.listener(|this, _: &ClearScrollback, _w, cx| this.clear_scrollback(cx))) - // Soft newline in the prompt editor (Shift+Enter / Alt+Enter by - // default). Propagates when the editor isn't holding the line, so a - // foreground application still sees the chord unchanged. .on_action(cx.listener(|this, _: &InsertNewline, _w, cx| { this.insert_newline_action(cx); })) - // Tab / Shift-Tab are claimed here (in the "Terminal" key context) so - // they reach the shell instead of triggering Root's focus navigation. - // Tab → HT (0x09); Shift-Tab → CSI Z (back-tab), the standard sequence. - // While the search field is focused it owns these keys, so propagate. .on_action(cx.listener(|this, _: &SendTab, _w, cx| { this.tab_pressed(true, cx); })) @@ -6408,19 +4193,7 @@ impl Render for TerminalView { .children(completion_menu) .children(reverse_search_menu) .children(integration_notice) - // Right-click context menu (gpui-component PopupMenu). .context_menu(move |menu, window, cx| { - // Default (26px) rows: with the flat full-bleed highlight (no - // floating pill, no inter-row gap) they read dense, not airy, and - // match the command palette's row height. A fixed min-width keeps - // the menu a consistent, intentional size instead of hugging the - // longest label (which reads ragged). - // Copy/Cut/Paste/Select All are dispatched inline (see - // `handle_cmd_shortcut`) with no registered `KeyBinding`, so the menu - // can't auto-derive their hints the way it does for the items below. - // We render the hint ourselves via `menu_row_with_hint` to keep the - // whole menu consistent, rather than register real bindings (which - // would risk the Ctrl+C SIGINT fall-through on Windows/Linux). let menu = menu .min_w(px(220.)) .action_context(menu_focus.clone()) @@ -6429,8 +4202,6 @@ impl Render for TerminalView { !has_selection, menu_row_with_hint("Copy", Some("secondary-c")), ) - // Cut is prompt-only; it shares Copy's enablement cue rather - // than offering a row that silently does nothing on output. .menu_element_with_disabled( Box::new(CutText), !has_selection, @@ -6445,25 +4216,9 @@ impl Render for TerminalView { menu_row_with_hint("Select All", mac_only("secondary-a")), ) .separator() - // Find now has a real registered binding, so let the menu - // auto-render its shortcut hint (correct per platform) like the - // items below, instead of a hand-rolled mac-only one. .menu("Find…", Box::new(FindInTerminal)) .menu("Clear", Box::new(ClearScrollback)); - // The fork block. Its rows dispatch actions that `Tty7App` - // handles, so the submenu carries the same `action_context` as - // the parent — a submenu is a menu of its own and does not - // inherit it. - // - // Offered only for agents tty7 has a verified fork command for. - // A *pane*-level ask is a spatial one, so this menu asks where - // the fork goes; a tab-level ask is not, so the tab menu just - // opens a new tab (issue #211). - // Disabled — not hidden — until the session id is known, so the - // capability stays discoverable when the agent's hooks aren't - // installed; a remote pane can't fork at all, since the fork - // command would run against the *local* agent. let view = menu_view.read(cx); let fork_label = view.agent().and_then(|a| a.fork_label()); let can_fork = fork_label.is_some() @@ -6483,10 +4238,6 @@ impl Render for TerminalView { .menu("Split Up", Box::new(ForkAgentSessionUp)) }) } - // Forkable agent, but nothing to fork *from* yet (no - // session id) or the wrong machine (a remote pane). A flat - // disabled row rather than an empty submenu: there is no - // placement to pick when the fork itself can't run. Some(label) => menu .separator() .item(PopupMenuItem::new(label).disabled(true)), @@ -6504,19 +4255,12 @@ impl Render for TerminalView { } } -/// Build a context-menu row that shows its shortcut right-aligned, matching the -/// hint gpui-component auto-renders for items whose action has a registered -/// keybinding. `key` is `None` when the action has no shortcut on this platform, -/// leaving the row hint-less like a plain item. fn menu_row_with_hint( label: &'static str, key: Option<&'static str>, ) -> impl Fn(&mut Window, &mut App) -> gpui::AnyElement { move |_window, _cx| { let hint = key.map(|k| { - // Strip Kbd's keycap box (filled bg + border) so it reads as the same - // quiet muted-foreground hint the auto-rendered items show — see - // gpui-component's `PopupMenu::render_key_binding`. Kbd::new(gpui::Keystroke::parse(k).expect("valid static keystroke")) .p_0() .flex_nowrap() @@ -6534,9 +4278,6 @@ fn menu_row_with_hint( } } -/// `Some(key)` on macOS, `None` elsewhere. ⌘A (Select All) and ⌘F (Find) are -/// wired only on macOS; on Windows/Linux those chords keep their readline meaning -/// (line-start / forward-char), so the menu must not advertise them there. #[cfg(target_os = "macos")] fn mac_only(key: &'static str) -> Option<&'static str> { Some(key) @@ -6546,13 +4287,6 @@ fn mac_only(_key: &'static str) -> Option<&'static str> { None } -/// Approximate terminal display width of a char in cells: 2 for East-Asian -/// wide / fullwidth glyphs and most emoji, 1 otherwise. Mirrors how the grid -/// (alacritty) lays out wide characters, so the editor's per-char cells and -/// click hit-testing line up with the shell's own rendering. -/// The char index where the whitespace-delimited word ending at `cursor` begins. -/// Mirrors the word-splitting the completion engine does, used when a completion -/// is all generators (no sync candidate to read the range off). fn word_start_of(line: &str, cursor: usize) -> usize { let chars: Vec<char> = line.chars().collect(); let mut start = cursor.min(chars.len()); @@ -6565,40 +4299,30 @@ fn word_start_of(line: &str, cursor: usize) -> usize { fn display_width(c: char) -> usize { let u = c as u32; let wide = matches!(u, - 0x1100..=0x115F // Hangul Jamo + 0x1100..=0x115F | 0x2329 | 0x232A - | 0x2E80..=0x303E // CJK radicals, Kangxi, punctuation - | 0x3041..=0x33FF // Hiragana, Katakana, CJK symbols - | 0x3400..=0x4DBF // CJK Ext A - | 0x4E00..=0x9FFF // CJK Unified - | 0xA000..=0xA4CF // Yi - | 0xAC00..=0xD7A3 // Hangul syllables - | 0xF900..=0xFAFF // CJK compatibility - | 0xFE10..=0xFE19 | 0xFE30..=0xFE6F // vertical / compat forms - | 0xFF00..=0xFF60 | 0xFFE0..=0xFFE6 // fullwidth forms - | 0x1F300..=0x1FAFF // emoji & pictographs - | 0x20000..=0x3FFFD // CJK Ext B+ + | 0x2E80..=0x303E + | 0x3041..=0x33FF + | 0x3400..=0x4DBF + | 0x4E00..=0x9FFF + | 0xA000..=0xA4CF + | 0xAC00..=0xD7A3 + | 0xF900..=0xFAFF + | 0xFE10..=0xFE19 | 0xFE30..=0xFE6F + | 0xFF00..=0xFF60 | 0xFFE0..=0xFFE6 + | 0x1F300..=0x1FAFF + | 0x20000..=0x3FFFD ); if wide { 2 } else { 1 } } -/// Where a wheel tick goes, decided by the modes the app negotiated. #[derive(Debug, PartialEq)] enum WheelRoute { - /// Mouse-wheel reporting: one report per scrolled line (64 up / 65 down). Report { base: u8 }, - /// Alternate scroll: the wheel becomes arrow keys (less, man). Arrows { seq: &'static [u8] }, - /// Nothing negotiated: scroll the local scrollback. Scrollback, } -/// Route a wheel tick. Shift always bypasses app handling (the standard -/// "scroll the terminal anyway" escape hatch), mouse reporting wins over -/// alternate scroll when both are on, and alternate scroll additionally -/// requires the *alt screen* — an app that set ALTERNATE_SCROLL but has -/// returned to the primary screen must not hijack the wheel from the -/// scrollback. fn wheel_route(mode: TermMode, shift: bool, up: bool) -> WheelRoute { if !shift && mode.intersects(TermMode::MOUSE_MODE) { return WheelRoute::Report { @@ -6617,18 +4341,10 @@ fn wheel_route(mode: TermMode, shift: bool, up: bool) -> WheelRoute { WheelRoute::Scrollback } -/// What a finished mouse-selection gesture should auto-copy when -/// copy-on-select is enabled (see `Config::copy_on_select`). #[derive(Debug, PartialEq)] enum SelectEndCopy { - /// Feature off, or the mouse-up ended no selection gesture (a plain - /// click, a right/middle release): leave the clipboard alone. None, - /// The gesture drove the terminal grid selection (drag / double / triple - /// click over output): copy `term.selection`. Grid, - /// The gesture landed on the command editor's line: copy the editor's - /// own selection. Editor, } @@ -6654,10 +4370,6 @@ fn open_file_path(path: &std::path::Path) { } } -/// Run a user-configured file-open command for a clicked file link. The template -/// is expanded by [`expand_file_command_template`] and the first token is the -/// program; the rest are its arguments. Spawned detached — tty7 doesn't wait for -/// or read from the editor it launches. fn run_file_command( template: &str, path: &std::path::Path, @@ -6674,15 +4386,6 @@ fn run_file_command( } } -/// Expand a file-open command template into an argv vector. -/// -/// The template is split on whitespace into tokens. Within a token the -/// placeholders `{path}`, `{line}`, and `{column}` are replaced with their -/// values. If a token references a placeholder whose value is absent (e.g. -/// `{line}` for a link with no line number), the whole token is dropped — this -/// lets a combined token like `--line={line}` disappear cleanly rather than -/// leaving a dangling flag. `{path}` is always present, so a token that only -/// references `{path}` is never dropped. fn expand_file_command_template( template: &str, path: &std::path::Path, @@ -6696,8 +4399,6 @@ fn expand_file_command_template( .collect() } -/// Substitute placeholders in a single template token, or return `None` if the -/// token references a placeholder with no value (so the caller drops it). fn expand_file_command_token( token: &str, path: &str, @@ -6708,7 +4409,6 @@ fn expand_file_command_token( let mut rest = token; while let Some(open) = rest.find('{') { let Some(close_rel) = rest[open..].find('}') else { - // No closing brace: the remainder is literal text. break; }; let close = open + close_rel; @@ -6717,11 +4417,8 @@ fn expand_file_command_token( "path" => Some(path.to_string()), "line" => line.map(|l| l.to_string()), "column" => column.map(|c| c.to_string()), - // An unknown placeholder is left verbatim rather than dropping the - // token, so a stray brace doesn't silently swallow an argument. other => Some(format!("{{{other}}}")), }; - // A recognized-but-absent placeholder drops the entire token. out.push_str(&value?); rest = &rest[close + 1..]; } @@ -6729,13 +4426,6 @@ fn expand_file_command_token( Some(out) } -/// One mouse report, encoded for the protocol the app negotiated. SGR (1006) -/// prints decimal 1-based coordinates and keeps the button in the final -/// letter (`M` press / `m` release); X10 packs everything into three bytes, -/// which caps coordinates at 223 (255 − 32 − 1) — events beyond that are -/// dropped (`None`) rather than sent corrupted — and loses the button -/// identity on release (code 3). Modifier bits (shift 4 / alt 8 / ctrl 16) -/// are added to `base` in both encodings. fn encode_mouse( sgr: bool, base: u8, @@ -6760,7 +4450,6 @@ fn encode_mouse( let msg = format!("\x1b[<{};{};{}{}", base + mod_bits, col + 1, row + 1, c); Some(msg.into_bytes()) } else { - // X10 encoding caps coordinates at 223 (255 - 32). if col >= 223 || row >= 223 { return None; } @@ -6780,9 +4469,6 @@ fn encode_mouse( } } -/// The focus-event report for a focus change, when the app enabled focus -/// reporting (mode 1004): `CSI I` on gain, `CSI O` on loss, `None` when the -/// mode is off (the overwhelmingly common case — nothing reaches the PTY). fn focus_report_bytes(mode: TermMode, focused: bool) -> Option<&'static [u8]> { if !mode.contains(TermMode::FOCUS_IN_OUT) { return None; @@ -6790,10 +4476,6 @@ fn focus_report_bytes(mode: TermMode, focused: bool) -> Option<&'static [u8]> { Some(if focused { b"\x1b[I" } else { b"\x1b[O" }) } -/// A completion row's leading icon, in a fixed-width centered slot so emoji and -/// SVG glyphs share one column. Prefers the Fig spec's `icon` (emoji rendered -/// as text, `fig://icon?type=…` mapped to a bundled glyph), falling back to a -/// per-kind default. fn completion_row_icon( raw: Option<&str>, kind: CandidateKind, @@ -6828,8 +4510,6 @@ fn completion_row_icon( } } - // Per-kind default: a terminal glyph for commands / subcommands / values, a - // dash for flags, folder / file for paths. let name = match kind { CandidateKind::Command | CandidateKind::Value => IconName::SquareTerminal, CandidateKind::Flag => IconName::Dash, @@ -6844,8 +4524,6 @@ fn completion_row_icon( ) } -/// The emoji to render for a Fig `icon`, if it is one: a bare emoji string, or -/// the `badge` of a `fig://template?…`. `None` for a named `fig://icon?type=…`. fn fig_icon_emoji(raw: &str) -> Option<&str> { if raw.is_empty() { None @@ -6858,8 +4536,6 @@ fn fig_icon_emoji(raw: &str) -> Option<&str> { } } -/// Map a `fig://icon?type=X` to one of tty7's bundled glyphs, or `None` to fall -/// back to the per-kind default — we ship no brand glyph for node/docker/npm/…. fn fig_icon_glyph(raw: &str) -> Option<IconName> { let ty = raw .strip_prefix("fig://icon") @@ -6873,7 +4549,6 @@ fn fig_icon_glyph(raw: &str) -> Option<IconName> { } } -/// Extract `key`'s value from a `fig://…?a=1&b=2` query string. fn fig_query_param<'a>(raw: &'a str, key: &str) -> Option<&'a str> { raw.split_once('?')?.1.split('&').find_map(|kv| { let (k, v) = kv.split_once('=')?; @@ -6881,15 +4556,6 @@ fn fig_query_param<'a>(raw: &'a str, key: &str) -> Option<&'a str> { }) } -/// Place the completion menu and window its rows around the selection — the -/// pure core of [`TerminalView::render_completion_menu`]. `total_rows` is the -/// grid height, `srow` the input row the menu anchors to, `count` the number of -/// candidates (≥ 1), `sel` the selected index, and `max_rows` the display cap. -/// Returns `(place_above, visible, first)`: whether the menu flips above the -/// input row (only when it doesn't fit below), how many candidate rows to show -/// (at least 1, even squeezed against an edge), and the index of the first -/// visible candidate — chosen so `sel` always lies within -/// `first..first + visible`. fn menu_layout( total_rows: usize, srow: usize, @@ -6900,10 +4566,6 @@ fn menu_layout( let want = count.min(max_rows); let below = total_rows.saturating_sub(srow + 1); let above = srow; - // The space budget must include the up-to-two "↑/↓ N more" footer lines a - // *windowed* list renders — they share the menu box with the candidate - // rows, so sizing on candidates alone let the menu (and, downward, the - // selected row riding the window's bottom edge) spill off screen. let footers = if count > want { 2 } else { 0 }; let need = want + footers; let (place_above, visible) = if below >= need { @@ -6911,9 +4573,6 @@ fn menu_layout( } else if above >= need { (true, want) } else { - // Cramped on both sides: take the larger side and squeeze the - // candidate rows under it, reserving the footer lines that squeezing - // (which hides candidates) makes appear. Always show at least one row. let squeeze = |room: usize| room.saturating_sub(2).max(1); if above > below { (true, squeeze(above)) @@ -6922,29 +4581,12 @@ fn menu_layout( } }; let visible = visible.min(count); - // Scroll the visible window so the selected candidate stays in view. let first = sel .saturating_sub(visible.saturating_sub(1)) .min(count.saturating_sub(visible)); (place_above, visible, first) } -/// Map a click cell to a char index in the wrapped input line — the pure core -/// of [`TerminalView::editor_char_index`], simulating the layout exactly as -/// `render_input_bar` produces it: char 0 starts at column `scol` (right after -/// the prompt) of the input's first row, each char advances by its display -/// width, and a char that wouldn't fit wraps whole to column 0 of the next row. -/// `col` is the clicked column and `target` the clicked row minus the input's -/// first row. A hit on a char cell returns its index; a click left of a row's -/// first char snaps to that char; past a row's content snaps to the next row's -/// first char (or the line end). Rows beyond the input return `len` with -/// `clamp` (for drags) and `None` without (so the click isn't an editor click). -/// Visual `(row, start-col, width)` of every char in the wrapped input line, -/// matching `render_input_bar`'s layout: char 0 starts at column `scol` (right -/// after the prompt), a `'\n'` is a hard break to column 0 of the next row (and -/// occupies no cell — width 0), and within a line a char that would overflow -/// wraps whole to column 0 of the next row. Also returns the pen `(row, col)` -/// after the last char, so callers can place the trailing end-of-line caret. fn input_char_positions( chars: &[char], scol: usize, @@ -6971,13 +4613,6 @@ fn input_char_positions( (positions, r, c) } -/// Visual size of the rendered input overlay: how many wrapped rows it -/// occupies and which of them carries the caret. Mirrors `render_input_bar`'s -/// layout: the IME pre-edit is inserted at the caret, and a one-cell caret -/// slot trails the buffer when the caret sits at the end (wrapping to a fresh -/// row when the content exactly fills its last one). The ghost autosuggestion -/// is deliberately excluded — the screen shouldn't scroll to reveal a -/// suggestion the user hasn't accepted. fn input_overlay_rows( chars: &[char], cursor: usize, @@ -7000,11 +4635,6 @@ fn input_overlay_rows( (end_row + 1, caret_vrow) } -/// Rows the grid (and the input overlay riding on it) must shift up so the -/// wrapped command editor stays visible when the prompt sits near the bottom: -/// enough that the overlay's last row lands on the last grid row — capped so -/// the caret's row never scrolls off the top when the input is taller than -/// the whole screen. fn input_overflow_shift(crow: usize, caret_vrow: usize, visual_rows: usize, rows: usize) -> usize { (crow + visual_rows) .saturating_sub(rows) @@ -7020,60 +4650,38 @@ fn wrapped_click_index( clamp: bool, ) -> Option<usize> { let len = chars.len(); - // `positions[i]` is the (row, start-col, width) of char `i`; `r`/`c` are the - // pen position after the last char. let (positions, r, c) = input_char_positions(chars, scol, cols); - // The renderer appends a one-cell end-of-line caret slot after the last - // char; when the content exactly fills its row, that slot wraps to the next - // row (where the caret is visibly drawn), so clicks there must still count - // as "this input", not fall past it. let end_row = if c >= cols { r + 1 } else { r }; if target > end_row { return clamp.then_some(len); } - // Exact hit on a char cell. for (i, &(pr, pc, pw)) in positions.iter().enumerate() { if pr == target && col >= pc && col < pc + pw { return Some(i); } } - // Click on the row but left of its first char. if let Some(fi) = positions.iter().position(|&(pr, _, _)| pr == target) { if col < positions[fi].1 { return Some(fi); } } - // Past the row's content. If the row ends at a hard line break, snap to that - // newline — the end of this logical line — rather than jumping onto the next - // line. (A soft-wrapped row has no newline, so it continues below.) if let Some(last) = positions.iter().rposition(|&(pr, _, _)| pr == target) { if chars[last] == '\n' { return Some(last); } } - // Otherwise the line soft-wraps: snap to the first char of the next visual - // row, or the buffer end. match positions.iter().position(|&(pr, _, _)| pr > target) { Some(ni) => Some(ni), None => Some(len), } } -/// Advance the continuous scroll position `offset + frac` (in lines, 0 = -/// bottom, growing into history) by `delta` lines, clamped to `[0, max]`. -/// Returns the whole-line jump to hand to the emulator's `display_offset` -/// and the new sub-line fraction in `[0, 1)`. fn smooth_scroll_step(offset: usize, frac: f32, delta: f32, max: usize) -> (i32, f32) { let pos = (offset as f32 + frac + delta).clamp(0., max as f32); let new_offset = pos.floor(); (new_offset as i32 - offset as i32, pos - new_offset) } -/// Lines to scroll per auto-scroll tick for a selection drag sitting -/// `overshoot` lines past the pane edge (sign = direction, positive = up into -/// history). At least one line per tick so grazing the edge still crawls; -/// farther out speeds up, capped so a wild fling stays controllable -/// (8 lines/tick at a 50ms cadence ≈ 160 lines/s). fn drag_scroll_step(overshoot: f32) -> i32 { let lines = overshoot.abs().ceil().clamp(1., 8.) as i32; if overshoot < 0. { -lines } else { lines } @@ -7096,8 +4704,6 @@ mod tests { use gpui_component::IconName; use std::path::{Path, PathBuf}; - // ── ⌘-click `localhost:PORT` routing ──────────────────────── - use crate::core::session::{RemoteTarget, WorkspaceId}; use crate::daemon::protocol::RemoteKind; use crate::terminal::PaneWorkspace; @@ -7117,32 +4723,23 @@ mod tests { } } - /// A plain local pane never forwards: `localhost:3000` there already means - /// this machine. #[test] fn local_pane_opens_localhost_directly() { assert_eq!(loopback_plan(true, None, None, 1), LoopbackPlan::Direct); } - /// An SSH pane forwards over its own connection, owned by the pane — the - /// behaviour that shipped, unchanged. #[test] fn ssh_pane_forwards_on_the_pane() { assert_eq!( loopback_plan(true, None, Some(RemoteKind::NativeSsh), 7), LoopbackPlan::ForwardOnPane(7) ); - // A non-native remote pane (a plain `ssh` typed into a shell) has no - // russh connection to forward over. assert_eq!( loopback_plan(true, None, Some(RemoteKind::Wsl), 7), LoopbackPlan::Direct ); } - /// A remote-workspace pane forwards over the *workspace's* connection. It - /// carries no `RemoteContext` at all — its shell is local to the remote - /// daemon — which is exactly why the workspace has to be consulted first. #[test] fn remote_workspace_pane_forwards_on_the_workspace() { let w = ws(RemoteTarget::direct("me", "dev.box", 22), true); @@ -7151,16 +4748,12 @@ mod tests { LoopbackPlan::ForwardOnWorkspace(Box::new(w.clone())), "no RemoteContext, but still forwarded" ); - // And the workspace wins over a pane-level answer. assert_eq!( loopback_plan(true, Some(&w), Some(RemoteKind::NativeSsh), 7), LoopbackPlan::ForwardOnWorkspace(Box::new(w)) ); } - /// **The WSL exception.** WSL shares `localhost` with its - /// Windows host, so the URL already resolves — building a forward would be - /// pure overhead. #[test] fn wsl_workspace_needs_no_forward() { let w = ws( @@ -7175,17 +4768,12 @@ mod tests { ); } - /// A non-WSL workspace with no connection spec cannot be forwarded over. - /// Opening the *client's* `localhost` instead would be a wrong answer that - /// looks right, so the link is left alone. #[test] fn workspace_without_a_spec_does_not_forward() { let w = ws(RemoteTarget::direct("me", "dev.box", 22), false); assert_eq!(loopback_plan(true, Some(&w), None, 7), LoopbackPlan::Direct); } - /// The `ssh_loopback_forward` off switch disables every route, including the - /// hover underline that `can_forward_loopback` drives off the same plan. #[test] fn the_off_switch_disables_every_route() { let w = ws(RemoteTarget::direct("me", "dev.box", 22), true); @@ -7215,8 +4803,6 @@ mod tests { #[test] fn file_command_template_drops_tokens_for_absent_values() { - // No line/column: the combined flag tokens vanish entirely, leaving no - // dangling `--line` for the downstream parser. let argv = expand_file_command_template( "herdr edit {path} --line={line} --column={column}", Path::new("/tmp/foo.rs"), @@ -7225,7 +4811,6 @@ mod tests { ); assert_eq!(argv, vec!["herdr", "edit", "/tmp/foo.rs"]); - // Column absent but line present: only the column flag drops. let argv = expand_file_command_template( "herdr edit {path} --line={line} --column={column}", Path::new("/tmp/foo.rs"), @@ -7237,15 +4822,12 @@ mod tests { #[test] fn file_command_template_keeps_path_only_token_and_unknown_placeholder() { - // A path-only program still runs; an unknown placeholder is left verbatim - // rather than dropping its token. let argv = expand_file_command_template( "code --goto {path}:{line} {other}", Path::new("/tmp/foo.rs"), None, None, ); - // `{path}:{line}` drops (line absent); `{other}` stays literal. assert_eq!(argv, vec!["code", "--goto", "{other}"]); } @@ -7254,8 +4836,6 @@ mod tests { fn clipboard_image_transcodes_bmp_to_png_and_passes_png_through() { use gpui::{Image, ImageFormat}; - // A BMP (what a Windows screenshot lands as) must be re-encoded to PNG, - // since agent vision rejects BMP. Build one with the image crate. let pixel = image::RgbaImage::from_pixel(1, 1, image::Rgba([1, 2, 3, 255])); let mut bmp = Vec::new(); image::DynamicImage::ImageRgba8(pixel) @@ -7263,10 +4843,8 @@ mod tests { .unwrap(); let path = super::write_clipboard_image(&Image::from_bytes(ImageFormat::Bmp, bmp)).unwrap(); assert_eq!(path.extension().unwrap(), "png"); - // PNG magic number: the staged file is genuinely a PNG, not renamed BMP. assert_eq!(&std::fs::read(&path).unwrap()[..8], b"\x89PNG\r\n\x1a\n"); - // A format agents already accept is written through byte-for-byte. let png = std::fs::read(&path).unwrap(); let out = super::write_clipboard_image(&Image::from_bytes(ImageFormat::Png, png.clone())) .unwrap(); @@ -7274,29 +4852,22 @@ mod tests { assert_eq!(std::fs::read(&out).unwrap(), png); } - /// The bundled Hack always anchors the fallback chain so prompt symbols - /// (`➜`, `❯`, powerline wedges) never fall through to the OS cascade — - /// unless the user already covers it as primary or in their own list. #[test] fn fallback_chain_pins_bundled_hack_last() { let configured = vec!["Menlo".to_string(), "Apple Color Emoji".to_string()]; - // A custom primary that may lack the prompt symbols → Hack appended. let chain = fallback_chain("JetBrains Mono", &configured); assert_eq!(chain[..2], ["Menlo", "Apple Color Emoji"]); assert_eq!(chain.last().unwrap(), "Hack"); - // Hack as the primary face already covers everything it could add. let chain = fallback_chain("Hack", &configured); assert_eq!(chain[..2], ["Menlo", "Apple Color Emoji"]); assert!(!chain.iter().any(|f| f == "Hack")); - // A user who lists Hack explicitly keeps their chosen position. let with_hack = vec!["Hack".to_string(), "Menlo".to_string()]; let chain = fallback_chain("SF Mono", &with_hack); assert_eq!(chain[..2], ["Hack", "Menlo"]); - // "Hack Nerd Font" is a different family — the bundled face still lands. assert_eq!( fallback_chain("Hack Nerd Font", &[]).last().unwrap(), "Hack", @@ -7304,16 +4875,11 @@ mod tests { ); } - /// Hack has no ideographs, so a chain naming only faces this OS lacks sends - /// every CJK glyph into the platform cascade — where a 1.0em face gets - /// left-aligned inside `element.rs`'s 1.2041em two-column slot. The stock - /// names have to be in the chain even for a config written before the fix. #[test] fn fallback_chain_appends_platform_stock_faces() { let stock = crate::core::config::platform_last_resort_fallbacks(); assert!(!stock.is_empty(), "every platform needs a CJK last resort"); - // The pre-fix default: macOS-only names, nothing Windows/Linux can match. let legacy = vec![ "Menlo".to_string(), "Hasklug Nerd Font Mono".to_string(), @@ -7328,10 +4894,8 @@ mod tests { ); } - // The user's own order is never displaced — stock faces land behind it. assert_eq!(chain[..legacy.len()], legacy[..]); - // Already-listed stock faces aren't duplicated. let explicit = vec![stock[0].to_string()]; let chain = fallback_chain("Hack", &explicit); assert_eq!( @@ -7340,15 +4904,11 @@ mod tests { "stock face duplicated in {chain:?}" ); - // A stock face chosen as the *primary* isn't re-added as its own fallback. assert!(!fallback_chain(stock[0], &[]).iter().any(|f| f == stock[0])); } - /// The wheel reaches the app only through the modes it negotiated: mouse - /// reporting first, alternate scroll second, local scrollback otherwise. #[test] fn wheel_routes_by_negotiated_mode_with_reporting_first() { - // Any mouse mode → per-line reports, 64 up / 65 down. let mouse = TermMode::MOUSE_REPORT_CLICK; assert_eq!( wheel_route(mouse, false, true), @@ -7359,8 +4919,6 @@ mod tests { WheelRoute::Report { base: 65 } ); - // Alt screen + alternate scroll (less, man) → arrow keys, and the - // cursor-keys mode picks between CSI and SS3 encodings. let alt = TermMode::ALT_SCREEN | TermMode::ALTERNATE_SCROLL; assert_eq!( wheel_route(alt, false, true), @@ -7379,22 +4937,17 @@ mod tests { WheelRoute::Arrows { seq: b"\x1bOB" } ); - // Both negotiated (vim with mouse on) → reporting wins. assert_eq!( wheel_route(mouse | alt, false, true), WheelRoute::Report { base: 64 } ); - // Nothing negotiated → local scrollback. assert_eq!( wheel_route(TermMode::empty(), false, true), WheelRoute::Scrollback ); } - /// ALTERNATE_SCROLL without the alt screen must NOT hijack the wheel: - /// after `less` exits back to the primary screen with the mode bit still - /// set, the wheel has to scroll the terminal's own history again. #[test] fn wheel_ignores_alternate_scroll_outside_the_alt_screen() { assert_eq!( @@ -7403,8 +4956,6 @@ mod tests { ); } - /// Shift is the universal "scroll the terminal anyway" escape hatch — it - /// bypasses both mouse reporting and alternate scroll. #[test] fn shift_wheel_always_scrolls_the_local_scrollback() { let everything = TermMode::MOUSE_MOTION @@ -7415,47 +4966,30 @@ mod tests { assert_eq!(wheel_route(everything, true, false), WheelRoute::Scrollback); } - /// Copy-on-select fires only when the released gesture actually drove a - /// selection, and copies the buffer that gesture touched — the terminal - /// grid or the command editor's line. Off, or a mouse-up that ended no - /// gesture (a plain click, a right-click), must leave the clipboard alone. #[test] fn copy_on_select_copies_the_buffer_the_gesture_touched() { - // Disabled: never copy, whatever kind of gesture just ended. assert_eq!(select_end_copy(false, true, false), SelectEndCopy::None); assert_eq!(select_end_copy(false, false, true), SelectEndCopy::None); - // A grid gesture (drag / double / triple click over output) copies - // the terminal selection; one on the editor line copies the editor's. assert_eq!(select_end_copy(true, true, false), SelectEndCopy::Grid); assert_eq!(select_end_copy(true, false, true), SelectEndCopy::Editor); - // No gesture ended → nothing to copy. assert_eq!(select_end_copy(true, false, false), SelectEndCopy::None); - // The press routes to exactly one buffer, but if both flags ever - // read set, the grid selection (the visible one) wins. assert_eq!(select_end_copy(true, true, true), SelectEndCopy::Grid); } - /// SGR (1006) reports print 1-based decimal coordinates, stack the - /// modifier bits onto the button code, and carry press/release in the - /// final letter. A drift in any of these lands clicks one cell off in - /// vim/tmux. #[test] fn sgr_mouse_reports_one_based_decimal_with_modifier_bits() { let plain = Modifiers::default(); - // Left press at 0-based (col 4, row 8) → "5;9", press = 'M'. assert_eq!( encode_mouse(true, 0, &plain, 4, 8, true).unwrap(), b"\x1b[<0;5;9M".to_vec() ); - // Release keeps the button identity (unlike X10) and flips to 'm'. assert_eq!( encode_mouse(true, 2, &plain, 4, 8, false).unwrap(), b"\x1b[<2;5;9m".to_vec() ); - // shift 4 + alt 8 + ctrl 16 = 28 on top of the base code. let all = Modifiers { shift: true, alt: true, @@ -7466,7 +5000,6 @@ mod tests { encode_mouse(true, 0, &all, 0, 0, true).unwrap(), b"\x1b[<28;1;1M".to_vec() ); - // Wheel (64/65) and drag-motion (32+) codes ride the same path. assert_eq!( encode_mouse(true, 64, &plain, 10, 3, true).unwrap(), b"\x1b[<64;11;4M".to_vec() @@ -7477,8 +5010,6 @@ mod tests { ); } - /// SGR exists precisely because X10 tops out at 223 — clicks on a wide - /// terminal past that column must still encode, not drop or wrap. #[test] fn sgr_mouse_has_no_coordinate_cap() { let plain = Modifiers::default(); @@ -7488,9 +5019,6 @@ mod tests { ); } - /// X10 packs the code and both coordinates into single bytes offset by - /// 32 (+1 for 1-based), loses the button identity on release (code 3), - /// and takes the same modifier bits. #[test] fn x10_mouse_packs_bytes_and_drops_button_on_release() { let plain = Modifiers::default(); @@ -7498,7 +5026,6 @@ mod tests { encode_mouse(false, 0, &plain, 4, 8, true).unwrap(), vec![0x1b, b'[', b'M', 32, 32 + 1 + 4, 32 + 1 + 8] ); - // Any button's release encodes as code 3 — X10 can't say which. assert_eq!( encode_mouse(false, 2, &plain, 4, 8, false).unwrap(), vec![0x1b, b'[', b'M', 32 + 3, 32 + 1 + 4, 32 + 1 + 8] @@ -7513,38 +5040,29 @@ mod tests { ); } - /// X10's byte packing can't express coordinates past 223 (255 − 32); the - /// event must be dropped whole — a wrapped byte would teleport the click - /// to the far side of the grid. #[test] fn x10_mouse_drops_out_of_range_coordinates_whole() { let plain = Modifiers::default(); assert!(encode_mouse(false, 0, &plain, 223, 0, true).is_none()); assert!(encode_mouse(false, 0, &plain, 0, 223, true).is_none()); - // The last representable cell still encodes, right at byte 255. let last = encode_mouse(false, 0, &plain, 222, 222, true).unwrap(); assert_eq!(&last[4..], &[255, 255]); } #[test] fn fig_icon_emoji_takes_bare_emoji_and_template_badge_only() { - // A bare emoji renders as-is. assert_eq!(fig_icon_emoji("⚙️"), Some("⚙️")); - // A colored template contributes its badge emoji. assert_eq!( fig_icon_emoji("fig://template?color=2ecc71&badge=🔥"), Some("🔥") ); - // A named glyph icon is not an emoji (it maps to an SVG instead). assert_eq!(fig_icon_emoji("fig://icon?type=git"), None); - // A badge-less template has no emoji to show. assert_eq!(fig_icon_emoji("fig://template?color=2ecc71"), None); assert_eq!(fig_icon_emoji(""), None); } #[test] fn fig_icon_glyph_maps_known_types_and_falls_back_otherwise() { - // `IconName` is neither `PartialEq` nor `Debug`, so match on the variant. assert!(matches!( fig_icon_glyph("fig://icon?type=folder"), Some(IconName::Folder) @@ -7557,48 +5075,34 @@ mod tests { fig_icon_glyph("fig://icon?type=git"), Some(IconName::Github) )); - // No bundled brand glyph → fall back to the per-kind default. assert!(fig_icon_glyph("fig://icon?type=docker").is_none()); assert!(fig_icon_glyph("⚙️").is_none()); } #[test] fn focus_reports_only_when_the_app_opted_in() { - // Mode 1004 off (the default): no bytes reach the PTY on focus changes. assert_eq!(focus_report_bytes(TermMode::empty(), true), None); assert_eq!(focus_report_bytes(TermMode::empty(), false), None); - // Opted in: CSI I on gain, CSI O on loss — what vim/tmux key off. let mode = TermMode::FOCUS_IN_OUT; assert_eq!(focus_report_bytes(mode, true), Some(b"\x1b[I".as_slice())); assert_eq!(focus_report_bytes(mode, false), Some(b"\x1b[O".as_slice())); - // Unrelated modes don't leak reports. assert_eq!(focus_report_bytes(TermMode::MOUSE_MOTION, true), None); } #[test] fn smooth_scroll_step_accumulates_and_clamps() { - // Sub-line deltas accumulate in the fraction without moving the grid. assert_eq!(smooth_scroll_step(0, 0.0, 0.4, 100), (0, 0.4)); - // Crossing a line boundary hands the whole line to the emulator and - // keeps the remainder. let (jump, frac) = smooth_scroll_step(0, 0.4, 0.8, 100); assert_eq!(jump, 1); assert!((frac - 0.2).abs() < 1e-4); - // Scrolling back down borrows from the offset. let (jump, frac) = smooth_scroll_step(5, 0.2, -0.5, 100); assert_eq!(jump, -1); assert!((frac - 0.7).abs() < 1e-4); - // The bottom clamps to exactly (0, 0): no fraction survives. assert_eq!(smooth_scroll_step(3, 0.5, -10.0, 100), (-3, 0.0)); - // The top of history clamps to (max, 0) likewise. assert_eq!(smooth_scroll_step(98, 0.0, 7.3, 100), (2, 0.0)); - // No history at all (alt screen / fresh shell): position is pinned. assert_eq!(smooth_scroll_step(0, 0.0, 2.5, 0), (0, 0.0)); } - /// Selection auto-scroll: grazing the edge crawls one line per tick, - /// farther out speeds up with the overshoot, a fling caps at 8, and the - /// sign follows the direction (positive = up into history). #[test] fn drag_scroll_step_scales_with_overshoot_and_caps() { assert_eq!(drag_scroll_step(0.2), 1); @@ -7611,54 +5115,34 @@ mod tests { #[test] fn trim_trailing_spaces_strips_per_line_and_preserves_structure() { - // Trailing spaces/tabs go; interior spaces and line count stay. assert_eq!(trim_trailing_spaces("a \nb\t\nc"), "a\nb\nc"); - // A trailing newline round-trips (no line gained or lost). assert_eq!(trim_trailing_spaces("a \n"), "a\n"); - // No trailing newline stays that way. assert_eq!(trim_trailing_spaces("a "), "a"); - // Leading whitespace is untouched. assert_eq!(trim_trailing_spaces(" a "), " a"); } #[test] fn paste_bytes_strips_esc_to_prevent_bracketed_paste_escape() { - // A benign paste is wrapped verbatim between the bracketed-paste markers. assert_eq!( paste_bytes("ls -la", true), b"\x1b[200~ls -la\x1b[201~".to_vec() ); - // Malicious clipboard text carrying its own `ESC[201~` end-marker followed - // by a newline + command: without stripping ESC this would break out of the - // paste and run `rm -rf ~` as typed input. The fix strips every ESC so the - // smuggled end-marker becomes inert. let evil = "foo\x1b[201~\nrm -rf ~\n"; let out = paste_bytes(evil, true); let end = b"\x1b[201~"; - // Exactly one end-marker survives — the trusted one we append, not the - // smuggled one (an unfiltered impl would leave two). let markers = out.windows(end.len()).filter(|w| *w == end).count(); assert_eq!(markers, 1); - // No raw ESC remains inside the wrapped payload. let inner = &out[b"\x1b[200~".len()..out.len() - end.len()]; assert!(!inner.contains(&0x1b)); - // Visible characters are preserved; only the ESC bytes are dropped. assert_eq!(inner, b"foo[201~\nrm -rf ~\n"); - // Without bracketed paste there is no wrapping, so bytes pass through as-is. assert_eq!(paste_bytes("a\x1b[201~b", false), b"a\x1b[201~b".to_vec()); } #[test] fn paste_bytes_normalizes_newlines_to_cr_without_bracketed_paste() { - // Regression: a raw-mode app (the only consumer of the non-bracketed - // PTY path) reads keys, and Enter is CR — pasted `\n`/`\r\n` must - // arrive as `\r`, matching xterm/alacritty, or apps that bind - // accept/submit to CR only mis-handle multi-line pastes. assert_eq!(paste_bytes("a\nb\r\nc\n", false), b"a\rb\rc\r".to_vec()); - // Under bracketed paste the receiver gets the text verbatim (minus - // ESC): the markers make line handling the app's own business. assert_eq!( paste_bytes("a\nb", true), b"\x1b[200~a\nb\x1b[201~".to_vec() @@ -7667,18 +5151,12 @@ mod tests { #[test] fn submit_bytes_sends_a_multi_line_command_as_one_bracketed_paste() { - // The regression this exists for: replaying each newline as its own CR - // made zle run a full prompt cycle per line (preexec + the user's - // precmd chain + a highlight pass), so a pasted block crawled down the - // screen. One paste, one CR — one cycle, whatever the line count. assert_eq!( submit_bytes("echo a\necho b\necho c", true), b"\x1b[200~echo a\necho b\necho c\x1b[201~\r".to_vec() ); - // Exactly one CR reaches the shell: the accept, not one per line. let out = submit_bytes("a\nb\nc\nd", true); assert_eq!(out.iter().filter(|&&b| b == b'\r').count(), 1); - // Single-line commands take the same shape — no special case. assert_eq!( submit_bytes("ls -la", true), b"\x1b[200~ls -la\x1b[201~\r".to_vec() @@ -7687,26 +5165,16 @@ mod tests { #[test] fn submit_bytes_falls_back_to_per_line_cr_without_bracketed_paste() { - // A shell that never enabled bracketed paste can only assemble a - // multi-line command the old way: one Enter per line, letting its - // editor do the PS2 continuation. assert_eq!(submit_bytes("a\nb", false), b"a\rb\r".to_vec()); - // A CRLF clipboard yields one CR per line, not a stray extra Enter - // (`\r\n` used to become `\r\r` — a blank line submitted mid-command). assert_eq!(submit_bytes("a\r\nb", false), b"a\rb\r".to_vec()); } #[test] fn submit_bytes_normalizes_line_breaks_inside_the_paste() { - // The CR of a CRLF clipboard must not ride inside the markers either: - // zsh turns a pasted CR into a newline (so the block would gain a blank - // line) and a shell that doesn't leaves a literal `^M` in the command. assert_eq!( submit_bytes("a\r\nb", true), b"\x1b[200~a\nb\x1b[201~\r".to_vec() ); - // A lone CR is a line break too — dropping it would glue the lines - // together into one command. assert_eq!( submit_bytes("a\rb", true), b"\x1b[200~a\nb\x1b[201~\r".to_vec() @@ -7716,30 +5184,21 @@ mod tests { #[test] fn submit_bytes_strips_esc_and_skips_markers_on_an_empty_line() { - // Clipboard text carrying its own `ESC[201~` would otherwise close the - // paste early and have the rest run as typed input. let out = submit_bytes("foo\x1b[201~\nrm -rf ~", true); let end = b"\x1b[201~"; assert_eq!(out.windows(end.len()).filter(|w| *w == end).count(), 1); assert_eq!(out, b"\x1b[200~foo[201~\nrm -rf ~\x1b[201~\r".to_vec()); - // ESC is stripped on the unbracketed path too — raw ESC reaching zle is - // an editor command, not text. assert_eq!(submit_bytes("a\x1bb", false), b"ab\r".to_vec()); - // An empty line is a bare Enter: zsh's `bracketed-paste-magic` errors - // on a paste with nothing between the markers. assert_eq!(submit_bytes("", true), b"\r".to_vec()); } #[test] fn shell_escape_path_escapes_spaces_and_metachars() { - // A plain path is untouched. assert_eq!( shell_escape_path("/Users/me/notes.txt"), "/Users/me/notes.txt" ); - // Spaces and shell metacharacters each gain a backslash so the whole - // path reaches the shell as a single argument. assert_eq!( shell_escape_path("/Users/me/My File (1).txt"), "/Users/me/My\\ File\\ \\(1\\).txt" @@ -7748,16 +5207,12 @@ mod tests { shell_escape_path("/a/$HOME & more"), "/a/\\$HOME\\ \\&\\ more" ); - // Empty becomes an explicit empty-string literal. assert_eq!(shell_escape_path(""), "''"); - // A newline can't be backslash-escaped, so the path is single-quoted. assert_eq!(shell_escape_path("a\nb"), "'a\nb'"); } #[test] fn clipboard_paste_text_escapes_and_space_joins_files() { - // Finder-style file copy: paths are escaped and space-joined — not glued - // together like gpui's `text()` fallback, and not left raw. let item = ClipboardItem { entries: vec![ClipboardEntry::ExternalPaths(ExternalPaths( vec![ @@ -7772,7 +5227,6 @@ mod tests { Some("/Users/me/My\\ File.txt /tmp/b.log") ); - // Plain text still passes through verbatim. let text = ClipboardItem::new_string("echo hi".to_string()); assert_eq!(clipboard_paste_text(&text).as_deref(), Some("echo hi")); } @@ -7787,27 +5241,25 @@ mod tests { #[test] fn display_width_cjk_and_kana_are_wide() { - assert_eq!(display_width('你'), 2); // CJK Unified - assert_eq!(display_width('한'), 2); // Hangul syllable - assert_eq!(display_width('あ'), 2); // Hiragana - assert_eq!(display_width(' '), 2); // fullwidth space (U+3000) + assert_eq!(display_width('你'), 2); + assert_eq!(display_width('한'), 2); + assert_eq!(display_width('あ'), 2); + assert_eq!(display_width(' '), 2); } #[test] fn display_width_emoji_are_wide() { - assert_eq!(display_width('🚀'), 2); // U+1F680, in emoji range + assert_eq!(display_width('🚀'), 2); assert_eq!(display_width('🎉'), 2); } #[test] fn display_width_latin_accents_stay_narrow() { - // Accented Latin and common symbols outside the wide ranges are 1 cell. assert_eq!(display_width('é'), 1); assert_eq!(display_width('©'), 1); assert_eq!(display_width('±'), 1); } - /// Shorthand: run `wrapped_click_index` over `text`'s chars. fn click(text: &str, scol: usize, cols: usize, col: usize, row: usize) -> Option<usize> { let chars: Vec<char> = text.chars().collect(); wrapped_click_index(&chars, scol, cols, col, row, false) @@ -7815,39 +5267,26 @@ mod tests { #[test] fn wrapped_click_index_hits_chars_on_the_first_row() { - // Prompt ends at column 4; "git" occupies columns 4..7 of row 0. assert_eq!(click("git", 4, 80, 4, 0), Some(0)); assert_eq!(click("git", 4, 80, 6, 0), Some(2)); - // Left of the first char (on the prompt itself) snaps to char 0. assert_eq!(click("git", 4, 80, 1, 0), Some(0)); - // Past the row's content → end of line. assert_eq!(click("git", 4, 80, 40, 0), Some(3)); } #[test] fn wrapped_click_index_maps_wrapped_rows() { - // 10-column grid, prompt at column 8: "abcdef" lays out as row 0 = - // "ab" (cols 8..10), row 1 = "cdef" (cols 0..4). - assert_eq!(click("abcdef", 8, 10, 9, 0), Some(1)); // 'b' - assert_eq!(click("abcdef", 8, 10, 0, 1), Some(2)); // 'c' - assert_eq!(click("abcdef", 8, 10, 3, 1), Some(5)); // 'f' - // A wide char that can't fit the row's last cell wraps whole, leaving a - // dead cell at the row end; clicking it snaps to the wrapped char — - // "a你" on a 4-col grid: 'a' at (0,2), dead cell (0,3), 你 at (1,0..2). + assert_eq!(click("abcdef", 8, 10, 9, 0), Some(1)); + assert_eq!(click("abcdef", 8, 10, 0, 1), Some(2)); + assert_eq!(click("abcdef", 8, 10, 3, 1), Some(5)); assert_eq!(click("a你", 2, 4, 3, 0), Some(1)); - // Past the last row's content → end of line. assert_eq!(click("abcdef", 8, 10, 9, 1), Some(6)); } #[test] fn wrapped_click_index_respects_wide_chars() { - // "你好" after a 2-col prompt: 你 covers cols 2..4, 好 covers 4..6 — - // either cell of a wide glyph resolves to its char index. assert_eq!(click("你好", 2, 80, 2, 0), Some(0)); assert_eq!(click("你好", 2, 80, 3, 0), Some(0)); assert_eq!(click("你好", 2, 80, 4, 0), Some(1)); - // A wide char that doesn't fit in the row's last cell wraps whole: on a - // 5-col grid with the prompt at column 4, 你 moves to row 1 cols 0..2. assert_eq!(click("你", 4, 5, 0, 1), Some(0)); assert_eq!(click("你", 4, 5, 1, 1), Some(0)); } @@ -7855,48 +5294,29 @@ mod tests { #[test] fn wrapped_click_index_rows_past_the_input_need_clamp() { let chars: Vec<char> = "ls".chars().collect(); - // A click two rows below a one-row input isn't an editor click… assert_eq!(wrapped_click_index(&chars, 4, 80, 3, 2, false), None); - // …but a drag (clamp) snaps to the end of the line. assert_eq!(wrapped_click_index(&chars, 4, 80, 3, 2, true), Some(2)); - // An empty line: any column of the input row maps to index 0. assert_eq!(wrapped_click_index(&[], 4, 80, 30, 0, false), Some(0)); - // One row below a one-row input that doesn't fill its row stays None - // (there is no caret slot down there). assert_eq!(wrapped_click_index(&chars, 4, 80, 3, 1, false), None); } #[test] fn wrapped_click_index_covers_the_wrapped_caret_slot() { - // Regression: "abcdef" after a 4-col prompt exactly fills a 10-col row, - // so the renderer's end-of-line caret slot wraps to row 1 col 0 — the - // blinking caret is visibly drawn there. A click on that row must map - // to the end of the line, not fall off the input (which turned the - // click into a terminal selection instead of a caret move). assert_eq!(click("abcdef", 4, 10, 0, 1), Some(6)); assert_eq!(click("abcdef", 4, 10, 7, 1), Some(6)); - // Two rows down is still past the input. let chars: Vec<char> = "abcdef".chars().collect(); assert_eq!(wrapped_click_index(&chars, 4, 10, 0, 2, false), None); } #[test] fn wrapped_click_index_treats_newlines_as_hard_breaks() { - // "a\nbc" after a 4-col prompt lays out as row 0 = "a" (col 4) and - // row 1 = "bc" (cols 0..2). Indices: 0='a', 1='\n', 2='b', 3='c'. - assert_eq!(click("a\nbc", 4, 80, 4, 0), Some(0)); // 'a' - assert_eq!(click("a\nbc", 4, 80, 0, 1), Some(2)); // 'b' on the next line - assert_eq!(click("a\nbc", 4, 80, 1, 1), Some(3)); // 'c' - // Clicking past the end of the first line snaps to the newline (the end - // of that logical line), not onto the second line. + assert_eq!(click("a\nbc", 4, 80, 4, 0), Some(0)); + assert_eq!(click("a\nbc", 4, 80, 0, 1), Some(2)); + assert_eq!(click("a\nbc", 4, 80, 1, 1), Some(3)); assert_eq!(click("a\nbc", 4, 80, 40, 0), Some(1)); - // Past the last line's content → buffer end. assert_eq!(click("a\nbc", 4, 80, 40, 1), Some(4)); - // A blank line in the middle ("a\n\nb") is its own row; clicking it lands - // on that empty line rather than falling through to "b". - // Indices: 0='a', 1='\n', 2='\n', 3='b'. Row 1 holds the second newline. assert_eq!(click("a\n\nb", 4, 80, 3, 1), Some(2)); - assert_eq!(click("a\n\nb", 4, 80, 0, 2), Some(3)); // 'b' on row 2 + assert_eq!(click("a\n\nb", 4, 80, 0, 2), Some(3)); } #[test] @@ -7905,119 +5325,63 @@ mod tests { let chars: Vec<char> = text.chars().collect(); input_overlay_rows(&chars, cursor, marked, scol, cols) }; - // Empty input: just the caret slot on the prompt row. assert_eq!(rows("", 0, "", 3, 8), (1, 0)); - // 10 chars after a 6-col prompt in an 8-col grid fill rows 0..=1 - // exactly, so the end-of-line caret slot wraps to row 2. assert_eq!(rows("aaaaaaaaaa", 10, "", 6, 8), (3, 2)); - // Same content with the caret in the middle: no trailing slot beyond - // the content, and the caret sits on the char's own row. assert_eq!(rows("aaaaaaaaaa", 3, "", 6, 8), (2, 1)); - // A hard newline is its own break; caret at the end lands on row 1. assert_eq!(rows("ab\ncd", 5, "", 0, 8), (2, 1)); - // IME pre-edit is inserted at the caret and counts its display width: - // the two-cell 漢 doesn't fit in the last column of row 0, so it wraps - // whole — pulling the caret's row down with it. assert_eq!(rows("ab", 1, "漢", 6, 8), (2, 1)); } #[test] fn input_overflow_shift_keeps_the_tail_and_caret_visible() { - // Fits: a 3-row input anchored at row 5 of a 22-row grid. assert_eq!(input_overflow_shift(5, 2, 3, 22), 0); - // Spills one row past the bottom → shift up by one. assert_eq!(input_overflow_shift(20, 2, 3, 22), 1); - // Taller than the whole screen, caret at the end: shift so the last - // row lands on the last grid row (caret stays visible with it). assert_eq!(input_overflow_shift(21, 29, 30, 22), 29); - // Same giant input with the caret back on its first row: the cap - // stops the caret row from scrolling off the top. assert_eq!(input_overflow_shift(21, 0, 30, 22), 21); } #[test] fn menu_layout_prefers_below_and_flips_above_when_cramped() { - // Plenty of room below: all 5 rows drop under the input row. assert_eq!(menu_layout(24, 3, 5, 0, 10), (false, 5, 0)); - // Input near the bottom: not enough room below, plenty above → flip. assert_eq!(menu_layout(24, 22, 5, 0, 10), (true, 5, 0)); - // Cramped on both sides: the larger side wins, squeezed to what fits - // *including* the footer lines squeezing makes appear. assert_eq!(menu_layout(6, 4, 10, 0, 10), (true, 2, 0)); assert_eq!(menu_layout(6, 1, 10, 0, 10), (false, 2, 0)); - // Even a 1-row grid shows at least one candidate row. let (_, visible, _) = menu_layout(1, 0, 8, 0, 10); assert_eq!(visible, 1); } #[test] fn menu_layout_budgets_the_overflow_footers() { - // Regression: a windowed list renders up to two "N more" footer lines - // in the same box. Sizing on candidate rows alone placed a 12-line menu - // (10 rows + 2 footers) into 10 free rows below — clipping the last two - // lines, one of which held the *selected* candidate (the window pins the - // selection to its bottom edge). The budget must count the footers, so - // this case flips above where all 12 lines fit. let (place_above, visible, first) = menu_layout(24, 13, 30, 17, 10); assert!( place_above, "12 needed lines don't fit in the 10 rows below" ); assert_eq!(visible, 10); - // The selection stays within the visible window. assert!((first..first + visible).contains(&17)); } #[test] fn menu_layout_caps_rows_and_windows_around_the_selection() { - // 30 candidates cap at max_rows; selecting deep into the list scrolls - // the window so the selection sits on its last visible row. let (_, visible, first) = menu_layout(40, 0, 30, 17, 10); assert_eq!(visible, 10); assert!((first..first + visible).contains(&17)); - assert_eq!(first, 8); // sel rides the window's bottom edge - // Selecting the last candidate clamps the window to the list's tail. + assert_eq!(first, 8); let (_, visible, first) = menu_layout(40, 0, 30, 29, 10); assert_eq!(first, 20); assert_eq!(first + visible, 30); - // A selection inside the first window leaves it unscrolled. assert_eq!(menu_layout(40, 0, 30, 3, 10).2, 0); } - /// The gate that keeps a remote path away from a `git` that cannot see it. - /// - /// Only the two *agreeing* pairings answer yes. The third row is the one - /// that matters — a shell on another machine paired with the local host — - /// because handing `/home/me/proj` to the local `git` does not fail - /// cleanly: on Windows that path is drive-relative and quietly resolves to - /// `C:\\home\\me\\proj`, so an unrelated local repository's branch and diff - /// would be reported as the remote pane's own. #[test] fn only_a_matching_host_may_answer_for_a_panes_paths() { - // Local shell on the local host: the ordinary case. assert!(cwd_is_on_host(false, true)); - // Remote shell on a host that is that machine: a remote-workspace pane, - // whose git line, diff and worktree offer all hang off this row. assert!(cwd_is_on_host(true, false)); - // Remote shell still paired with the local host — a native-SSH or WSL - // pane, which has no `Host` behind it: refused. assert!(!cwd_is_on_host(true, true)); - // Local shell paired with a remote host: equally meaningless. assert!(!cwd_is_on_host(false, false)); } - /// A pane's machine comes from its workspace, and the two move together: - /// [`TerminalView::set_workspace`] is the only thing that sets either, so a - /// pane cannot end up running its shell on one machine and asking `git` on - /// another. - /// - /// Checked against `PaneWorkspace` directly rather than through a live view - /// (which needs a window, a daemon and a pane): the derivation under test is - /// the target → `HostId` one, and pinning it here is what catches a future - /// `set_workspace` that forgets the host half. The ids must agree with what - /// `HostLinks::insert` registered — same `connection_key`, checked - /// by `connection_keys_match_the_contract_table` in `tty7-core`. #[test] fn a_panes_host_is_its_workspaces_machine() { use crate::core::session::{RemoteTarget, WorkspaceId}; @@ -8032,7 +5396,6 @@ mod tests { spec: None, }; - // What `set_workspace` computes, for each of its two inputs. let remote = ws.target.host_id(); assert_eq!(remote, target.host_id(), "the workspace's own machine"); assert!(!remote.is_local(), "a remote workspace is not this machine"); @@ -8042,8 +5405,6 @@ mod tests { "the id the connection was opened under, or the registry lookup misses" ); - // Two workspaces on one box are one machine — one connection, one host - // object, one git probe shared by both. let sibling = PaneWorkspace { workspace: WorkspaceId::new(), target, @@ -8053,14 +5414,6 @@ mod tests { } } -/// A pane the UI-level gpui tests can put in a tab: a real [`TerminalView`] on a -/// socketpair with nothing on the far end, so a test window has a pane without a -/// daemon, a shell, or a byte of output to repaint for. That silence is the -/// point — the render-idle tests measure what the *window* does when nothing is -/// happening, so the pane must not be a source of frames. -/// -/// The caller keeps the returned stream alive: dropping it closes the socket and -/// the reader retires the pane. #[cfg(all(test, unix))] pub(crate) fn quiet_test_pane( pane_id: u64, @@ -8074,9 +5427,6 @@ pub(crate) fn quiet_test_pane( (view, daemon_side) } -/// [`quiet_test_pane`], marked as a native-SSH pane — the shape a remote -/// window's local SSH split has. `ssh_spec` is otherwise set only by the real -/// spawn path, which needs an actual SSH handshake. #[cfg(all(test, unix))] pub(crate) fn quiet_test_ssh_pane( pane_id: u64, @@ -8095,11 +5445,6 @@ pub(crate) fn quiet_test_ssh_pane( (view, stream) } -/// gpui-harness tests: a real (headless) App + Window around a `TerminalView` -/// wired to a socketpair, so `handle_event` and the event pump run exactly as -/// in production. The test plays the daemon on the other end of the socket — -/// write `DaemonMsg`s to feed the terminal, read `ClientMsg`s to observe what -/// the view sent back. #[cfg(all(test, unix))] mod gpui_tests { use super::*; @@ -8108,14 +5453,9 @@ mod gpui_tests { use std::os::unix::net::UnixStream; fn harness(cx: &mut TestAppContext) -> (gpui::WindowHandle<TerminalView>, UnixStream) { - // The terminal's reader is a real OS thread feeding a real socket, so - // this test mixes deterministic scheduling with outside I/O — exactly - // what `allow_parking` exists for. cx.executor().allow_parking(); let (client_side, daemon_side) = UnixStream::pair().unwrap(); cx.update(|cx| { - // Same globals `main` installs: the component theme (view code - // reads it via `cx.theme()`) and the user config. gpui_component::init(cx); cx.set_global(Config::default()); }); @@ -8127,8 +5467,6 @@ mod gpui_tests { (window, daemon_side) } - /// Report the shell as idle at its prompt and wait for the view to see it, - /// so `input_active()` is true and the local command editor owns the line. fn prompt_ready( window: &gpui::WindowHandle<TerminalView>, cx: &mut TestAppContext, @@ -8153,15 +5491,6 @@ mod gpui_tests { panic!("the prompt report never reached the view"); } - /// **A session id the agent reports raises [`AgentSessionChanged`], and it - /// does so without the status moving.** That is the whole point: the id - /// arrives on the agent's hooks, minutes after anything structural happened - /// to the window, and nothing else was going to make the layout save. A - /// record with no session id in it is a workspace that cannot resume, which - /// is what made resume-after-End-Sessions look intermittent. - /// - /// The second poll must stay quiet — a save (and, on a remote workspace, a - /// push to the machine) per repaint would be a different bug. #[gpui::test] fn a_reported_session_id_asks_the_window_to_save(cx: &mut TestAppContext) { use crate::core::cli_agent::{AgentSessionState, AgentStatus}; @@ -8180,8 +5509,6 @@ mod gpui_tests { }); } - // The hooks report a conversation. `status` is `Idle` before and after, - // so a poll keyed only on the status would never notice. DaemonMsg::AgentStatus(Some(AgentSessionState { status: AgentStatus::Idle, message: None, @@ -8224,21 +5551,13 @@ mod gpui_tests { ); } - /// A hover cell remembered while the pane was tall names a row the grid no - /// longer has once the pane shrinks (a vertical split, a smaller window). - /// Resolving it must decline rather than index the grid — this path runs - /// from `ModifiersChanged` (the ⌘ of the very ⌘⇧D that split the pane), an - /// `extern "C"` callback where the panic can't unwind and aborts the app. #[gpui::test] fn a_stale_hover_row_does_not_index_the_shrunken_grid(cx: &mut TestAppContext) { let (window, _daemon) = harness(cx); window .update(cx, |view, _, cx| { - // Hover the last row of the 24-row grid, then shrink to 8 rows. view.hover_link_at(0, 23, true, cx); view.terminal.resize(TermSize::new(80, 8), 8, 17); - // `set_grid_size` drops the stale cell in the real app; pin it - // here so the guard inside the lookup is what's under test. view.last_hover_cell = Some((0, 23)); assert!( !view.refresh_link_hover(true, cx), @@ -8248,17 +5567,11 @@ mod gpui_tests { .unwrap(); } - /// The other half of the fix: the pane that shrank forgets the hover it was - /// holding, rather than carrying a cell (and the underline it resolved) that - /// now names different text. #[gpui::test] fn a_resize_forgets_the_hovered_cell(cx: &mut TestAppContext) { let (window, _daemon) = harness(cx); window .update(cx, |view, _, cx| { - // Pin a known geometry first — what the test window measured for - // itself is the element's business, and this is about the - // transition. Then hover the last row of those 24. view.set_grid_size(80, 24, px(8.), px(17.)); view.hover_link_at(0, 23, true, cx); assert_eq!(view.last_hover_cell, Some((0, 23))); @@ -8266,10 +5579,8 @@ mod gpui_tests { start: Point::new(Line(23), Column(0)), end: Point::new(Line(23), Column(3)), }); - // The same geometry again changes nothing... view.set_grid_size(80, 24, px(8.), px(17.)); assert_eq!(view.last_hover_cell, Some((0, 23))); - // ...but a split (or a window drag) that shrinks the pane does. view.set_grid_size(80, 8, px(8.), px(17.)); assert!(view.last_hover_cell.is_none(), "the cell is stale"); assert!(view.hovered_link.is_none(), "so is the link it resolved"); @@ -8291,9 +5602,6 @@ mod gpui_tests { .unwrap(); } - /// The first frames out of the socket may be `Resize`s — the headless - /// window really lays the element out, and the first prepaint syncs its - /// measured geometry. Skip to the next `Input`. fn next_input(daemon: &mut UnixStream) -> Vec<u8> { loop { match ClientMsg::read(daemon).expect("client socket stays open") { @@ -8303,11 +5611,6 @@ mod gpui_tests { } } - /// Deliver one printable character the way the running platform actually - /// does. macOS hands all text to the input context, which arrives as - /// `commit_text` (see `input::defer_to_ime`); elsewhere it travels the - /// `on_key_down` / `key_char` path. Tests that assert on *text* input must - /// go through here, or they exercise a path the platform never takes. fn type_char( view: &mut TerminalView, ch: &str, @@ -8437,7 +5740,6 @@ mod gpui_tests { panic!("an emacs-mode prompt should re-enable tty7's local editor"); } - /// Wait until the daemon-fed prompt state makes the local editor live. fn wait_for_input_active(window: &gpui::WindowHandle<TerminalView>, cx: &mut TestAppContext) { for _ in 0..200 { cx.run_until_parked(); @@ -8450,11 +5752,6 @@ mod gpui_tests { panic!("the local editor never engaged at the prompt"); } - /// Tab the engine has nothing for must not be swallowed (#136): the - /// locally edited line is shipped to the shell followed by the Tab - /// itself, and the local editor stays out of the way until the shell - /// reports its next prompt — from there the shell's own completion owns - /// the line. #[gpui::test] fn tab_with_no_candidates_hands_the_line_to_the_shell(cx: &mut TestAppContext) { let (window, mut daemon) = harness(cx); @@ -8469,8 +5766,6 @@ mod gpui_tests { window .update(cx, |view, window, cx| { - // A command-position word matching no builtin or $PATH entry, - // so the completion engine returns `None`. for ch in ["z", "z", "q", "q", "x"] { type_char(view, ch, window, cx); } @@ -8494,10 +5789,6 @@ mod gpui_tests { "the Tab reaches the PTY instead of being swallowed" ); - // A same-prompt redraw (a prompt framework re-emitting the - // PS1-embedded `133;B` on reset-prompt / a completion list reprint) - // must NOT re-engage the editor — zle still holds the handed-off - // text, and an engaged-empty editor would fork the two buffers. DaemonMsg::Prompt { active: true, at_prompt: true, @@ -8524,8 +5815,6 @@ mod gpui_tests { }) .unwrap(); - // A real command cycle — the shell leaves the prompt and comes back — - // re-engages the local editor. DaemonMsg::Prompt { active: true, at_prompt: false, @@ -8543,8 +5832,6 @@ mod gpui_tests { wait_for_input_active(&window, cx); } - /// With `tab_completion` off, Tab never opens tty7's menu — even when the - /// engine would have candidates, the line and the Tab go to the shell. #[gpui::test] fn tab_completion_off_sends_every_tab_to_the_shell(cx: &mut TestAppContext) { let (window, mut daemon) = harness(cx); @@ -8564,7 +5851,6 @@ mod gpui_tests { window .update(cx, |view, window, cx| { - // "cd " would offer path candidates were the engine consulted. for ch in ["c", "d", " "] { type_char(view, ch, window, cx); } @@ -8638,16 +5924,9 @@ mod gpui_tests { ); } - /// Text typed during a command gap is held for the next prompt's editor — - /// but a vi prompt never engages the editor, so the hold must be released - /// raw (the shell's own line editor consumes it) and the typeahead record - /// dropped. Without that, the record lingers past the whole vi prompt and - /// flushes at the next emacs-mode prompt: a spurious `^U` plus the long- - /// consumed gap text resurrected into the local editor. #[gpui::test] fn shell_vi_mode_prompt_releases_gap_hold_without_stale_typeahead(cx: &mut TestAppContext) { let (window, mut daemon) = harness(cx); - // Shell integration live, a command running: gap input gets held. DaemonMsg::Prompt { active: true, at_prompt: false, @@ -8671,7 +5950,6 @@ mod gpui_tests { .update(cx, |view, _, cx| view.commit_text("ls", cx)) .unwrap(); - // The command finishes into a vi-mode prompt. DaemonMsg::Prompt { active: true, at_prompt: true, @@ -8694,8 +5972,6 @@ mod gpui_tests { } std::thread::sleep(std::time::Duration::from_millis(5)); } - // Fire any pending hold-window timer too, so both release paths are - // covered regardless of which one runs first. cx.executor().advance_clock(HOLD_WINDOW * 2); cx.run_until_parked(); assert_eq!( @@ -8704,7 +5980,6 @@ mod gpui_tests { "gap text typed before a vi prompt must reach the shell" ); - // Back to an emacs-mode prompt: the editor re-engages empty-handed. DaemonMsg::Output(b"\x1b]133;V;0\x07\x1b]133;B\x07".to_vec()) .encode(&mut daemon) .unwrap(); @@ -8737,9 +6012,6 @@ mod gpui_tests { gpui::Keystroke::parse(spec).expect("valid keystroke spec") } - /// The notice names only known fig-style shims — an ordinary foreground - /// command (`ssh`) must not be blamed for intercepting anything, and the - /// generic message must not claim interception it can't prove. #[test] fn shim_detection_names_known_wrappers_only() { assert_eq!(known_pty_shim("zsh (kiro-cli-term)"), Some("kiro-cli-term")); @@ -8752,16 +6024,8 @@ mod gpui_tests { assert!(!integration_notice_message(None).contains("intercepting")); } - /// The Ctrl+R integration notice (#46), through the real key dispatcher: - /// silent inside the startup grace window, raised once integration has had - /// time to engage and never did, dismissed by the next keystroke, and - /// one-shot per pane. The chord itself still reaches the PTY throughout - /// (the shell's own reverse-i-search is the fallback). #[gpui::test] fn ctrl_r_without_integration_raises_the_notice_once(cx: &mut TestAppContext) { - // `note_integration_gap` queries the daemon for the pane's foreground - // process; pin the config dir to a scratch so the control connection - // fails cleanly instead of reaching a real user daemon. crate::core::config::pin_test_config_dir(); let (window, _daemon) = harness(cx); @@ -8772,14 +6036,12 @@ mod gpui_tests { is_held: false, prefer_character_input: false, }; - // Fresh pane: the shell may legitimately not have reported yet. view.on_key_down(&ctrl_r, window, cx); assert!( view.integration_notice.is_none(), "the grace window stays silent" ); - // Past the grace window with no OSC 133 ever seen → notice. view.created_at = std::time::Instant::now() - INTEGRATION_GRACE * 2; view.on_key_down(&ctrl_r, window, cx); assert!( @@ -8790,8 +6052,6 @@ mod gpui_tests { }) .unwrap(); - // Let the notified frame actually draw — a panic in the notice layout - // fails the test here. cx.run_until_parked(); window .update(cx, |view, window, cx| { @@ -8800,7 +6060,6 @@ mod gpui_tests { "the notice survives a real render pass" ); - // The next keystroke dismisses it; the latch keeps it one-shot. let ctrl_r = KeyDownEvent { keystroke: key("ctrl-r"), is_held: false, @@ -8820,13 +6079,8 @@ mod gpui_tests { .unwrap(); } - /// The `InsertNewline` action puts a literal newline at the caret and leaves - /// the line unsubmitted; a plain Enter then ships the whole multi-line - /// buffer. Behaviour that used to be hardcoded on Shift+Enter (#182). #[gpui::test] fn insert_newline_action_extends_the_line_and_enter_submits_it(cx: &mut TestAppContext) { - // `submit_command` defers a history-file record; pin the config dir to - // the shared test scratch so nothing touches the real user history. let dir = std::env::temp_dir().join(format!("tty7-covtest-{}", std::process::id())); std::fs::create_dir_all(&dir).ok(); crate::core::config::set_config_dir(dir); @@ -8848,17 +6102,11 @@ mod gpui_tests { .unwrap(); assert_eq!( next_input_until_timeout(&mut daemon), - // Submit sends each buffer line as a carriage return, the way a - // pasted multi-line command already goes out. Some(b"echo a\recho b\r".to_vec()), "the multi-line command reaches the PTY in one submit" ); } - /// With a completion menu open the action still inserts, and closes the - /// menu: the newline ends the word being completed, so a menu still - /// filtered on the old word would be stale. Plain Enter keeps its own - /// meaning there — it accepts the highlighted candidate (#182). #[gpui::test] fn insert_newline_action_closes_the_completion_menu_but_enter_still_accepts( cx: &mut TestAppContext, @@ -8877,7 +6125,6 @@ mod gpui_tests { window .update(cx, |view, _, cx| { - // Menu open on the word after "git ". view.cmd.set_with_cursor("git ", 4); view.open_completion(CompletionSession::new( 4, @@ -8892,8 +6139,6 @@ mod gpui_tests { ); assert_eq!(view.cmd.text(), "git \n"); - // Plain Enter with a menu open is a different gesture: it takes - // the highlighted candidate rather than submitting or inserting. view.cmd.set_with_cursor("git ", 4); view.open_completion(CompletionSession::new( 4, @@ -8901,23 +6146,18 @@ mod gpui_tests { vec![candidate("status")], )); view.handle_editor_key(&key("enter"), cx); - // Accepting a command candidate leaves the trailing space that - // starts the next word. assert_eq!(view.cmd.text(), "git status "); }) .unwrap(); } - /// The action is the prompt editor's alone: with a foreground application on - /// the alternate screen it declines, so the chord takes its old path out to - /// the application instead of editing a line that isn't there. #[gpui::test] fn insert_newline_action_declines_when_the_editor_is_not_live(cx: &mut TestAppContext) { let (window, _daemon) = harness(cx); window .update(cx, |view, _, cx| { view.cmd.set("keep me"); - view.terminal.exited = true; // simplest input_active() = false + view.terminal.exited = true; assert!(!view.input_active()); view.insert_newline_action(cx); assert_eq!(view.cmd.text(), "keep me", "no newline inserted"); @@ -8925,12 +6165,6 @@ mod gpui_tests { .unwrap(); } - /// The check the tests above structurally can't make: with the *real* keymap - /// installed, both default chords have to actually reach the action. They - /// call `insert_newline_action` directly, so a wrong key context — or a - /// `NoAction` from a later `rebind` shadowing the chord — would leave every - /// one of them green while Shift+Enter silently submitted the line. This - /// drives the keystroke through GPUI's dispatch instead (#182). #[gpui::test] fn the_keymap_routes_both_newline_chords_to_the_action(cx: &mut TestAppContext) { let (window, mut daemon) = harness(cx); @@ -8957,9 +6191,6 @@ mod gpui_tests { }) .unwrap(); - // And again after a rebind, which is when the suppression bindings go in: - // the `NoAction` retiring the old chord must not outrank the identical - // one being re-installed alongside it. cx.update(|cx| crate::ui::keymap::rebind(cx)); vcx.simulate_keystrokes("shift-enter"); window @@ -8973,9 +6204,6 @@ mod gpui_tests { .unwrap(); } - /// The Ctrl+R flow end-to-end at the editor dispatcher: Ctrl+R opens the - /// search, typed text (the IME/commit path) edits the query with fuzzy - /// matching, Enter loads the selection into the editor without running it. #[gpui::test] fn ctrl_r_fuzzy_search_accepts_into_the_editor(cx: &mut TestAppContext) { let (window, _daemon) = harness(cx); @@ -8989,7 +6217,6 @@ mod gpui_tests { view.handle_editor_key(&key("ctrl-r"), cx); assert!(view.reverse_search.is_some(), "Ctrl+R opens the search"); - // `gst` is a subsequence of `git status` — fuzzy, not substring. view.commit_text("gst", cx); assert_eq!( view.reverse_search @@ -9004,13 +6231,8 @@ mod gpui_tests { .unwrap(); } - /// Repeated Ctrl+R steps down the ranked matches, and Cmd+Enter runs the - /// selection outright: the line must come out of the client socket as - /// `Input` bytes ending in `\r`. #[gpui::test] fn ctrl_r_steps_matches_and_cmd_enter_runs(cx: &mut TestAppContext) { - // `submit_command` defers a history-file record; pin the config dir to - // the shared test scratch so nothing touches the real user history. crate::core::config::pin_test_config_dir(); let (window, mut daemon) = harness(cx); @@ -9024,8 +6246,6 @@ mod gpui_tests { view.handle_editor_key(&key("ctrl-r"), cx); view.commit_text("git", cx); - // Equal fuzzy scores: the newer entry ranks first; a second - // Ctrl+R steps to the older match. assert_eq!( view.reverse_search .as_ref() @@ -9051,13 +6271,8 @@ mod gpui_tests { ); } - /// Ctrl+J and Ctrl+M are accept-line's control codes, so at the prompt they - /// must submit exactly as Enter does (#163) — before the fix they fell into - /// `apply_readline_ctrl`'s no-op arm and the key did nothing at all. #[gpui::test] fn ctrl_j_and_ctrl_m_submit_the_line_like_enter(cx: &mut TestAppContext) { - // `submit_command` defers a history-file record; pin the config dir to - // the shared test scratch so nothing touches the real user history. crate::core::config::pin_test_config_dir(); let (window, mut daemon) = harness(cx); @@ -9077,9 +6292,6 @@ mod gpui_tests { } } - /// With `history_search` off, Ctrl+R never opens tty7's menu: the edited - /// line is handed to the shell and the raw `^R` follows it, so a user's own - /// binding there (fzf, percol, plain reverse-i-search) answers (#163). #[gpui::test] fn history_search_off_sends_ctrl_r_to_the_shell(cx: &mut TestAppContext) { let (window, mut daemon) = harness(cx); @@ -9109,16 +6321,9 @@ mod gpui_tests { ); } - /// The Ctrl+R menu actually renders while the shell sits at its prompt: - /// with `input_active` true and a search open over entries carrying run - /// metadata, a real (headless) frame draws `render_reverse_search_menu` — - /// guarding the row/highlight/badge layout code against panics that unit - /// tests of the search logic can't reach. #[gpui::test] fn reverse_search_menu_survives_a_real_render_pass(cx: &mut TestAppContext) { let (window, mut daemon) = harness(cx); - // Put the shell at its prompt so `input_active()` is true and the - // menu branch of `render` runs. DaemonMsg::Prompt { active: true, at_prompt: true, @@ -9144,7 +6349,6 @@ mod gpui_tests { .map(String::from) .collect(); view.history_frecency = vec![0.0; view.history.len()]; - // Metadata for the badge/ago column: one failed run, one aged. view.history_meta.insert( "cargo build --release".into(), super::super::history::EntryMeta { @@ -9163,8 +6367,6 @@ mod gpui_tests { cx.notify(); }) .unwrap(); - // Let the notified frame actually draw — a panic in the menu layout - // or row rendering fails the test here. cx.run_until_parked(); window .update(cx, |view, _, _| { @@ -9173,14 +6375,8 @@ mod gpui_tests { .unwrap(); } - /// The deferred history record picks up the command's exit code once the - /// shell reports back at its prompt (OSC 133;D → daemon `Prompt` frame → - /// `prompt_seq`/`last_exit_code`), and the file line carries it. #[gpui::test] fn submitted_command_backfills_its_exit_code(cx: &mut TestAppContext) { - // `set_config_dir` is first-call-wins and process-wide, so this pin only - // takes if no other test got there first — read the *effective* dir back - // rather than assuming this one won. crate::core::config::pin_test_config_dir(); let dir = crate::core::config::config_dir_path().expect("a config dir resolves"); @@ -9195,7 +6391,6 @@ mod gpui_tests { panic!("timed out waiting for {what}"); }; - // The shell reaches its prompt (integration active). DaemonMsg::Prompt { active: true, at_prompt: true, @@ -9214,7 +6409,6 @@ mod gpui_tests { }) .unwrap(); - // The command runs (leaves the prompt) and finishes with exit 3. DaemonMsg::Prompt { active: true, at_prompt: false, @@ -9247,7 +6441,6 @@ mod gpui_tests { }) .unwrap(); - // The file record is the current format with the exit code attached. let content = std::fs::read_to_string(dir.join("history")).expect("history file written"); let line = content .lines() @@ -9259,11 +6452,6 @@ mod gpui_tests { assert_eq!(fields.next(), Some("3"), "exit code field"); } - /// Readline's Meta word chords act on the local prompt editor: M-b / M-f - /// move by word, M-d deletes the word right of the caret. (On macOS these - /// chords reach the editor only with `macos_option_as_alt` on — the - /// `on_key_down` reshape otherwise strips the alt bit; here we drive the - /// editor dispatcher directly with the post-reshape keystroke.) #[gpui::test] fn meta_word_chords_edit_the_prompt_line(cx: &mut TestAppContext) { let (window, _daemon) = harness(cx); @@ -9278,29 +6466,20 @@ mod gpui_tests { key_char: None, }; view.cmd.set("echo hello"); - // M-b from the end lands at the start of "hello". view.handle_editor_key(&meta("b"), cx); assert_eq!(view.cmd.cursor(), 5); - // M-d deletes the word right of the caret. view.handle_editor_key(&meta("d"), cx); assert_eq!(view.cmd.text(), "echo "); - // M-b / M-f hop the remaining word: back to its start, then - // forward to its end. view.handle_editor_key(&meta("b"), cx); assert_eq!(view.cmd.cursor(), 0); view.handle_editor_key(&meta("f"), cx); assert_eq!(view.cmd.cursor(), 4); - // Other Meta letters have no local widget, so they hand the line - // to the shell rather than dying here — see - // `an_unknown_meta_chord_goes_to_the_shell_with_the_line`. view.handle_editor_key(&meta("z"), cx); assert_eq!(view.cmd.text(), ""); }) .unwrap(); } - /// Fill the scrollback and park the viewport `offset` lines up inside it, - /// so a test can watch a keystroke snap it back to the live prompt. fn scroll_into_history(view: &TerminalView, offset: usize) { let mut parser: alacritty_terminal::vte::ansi::Processor = Default::default(); let mut term = view.terminal.term.lock(); @@ -9317,11 +6496,6 @@ mod gpui_tests { view.terminal.term.lock().grid().display_offset() } - /// Scrolled up into the scrollback, recalling history with ↑ must bring the - /// viewport back to the live prompt (#208). The local editor owns ↑ and - /// returns early, so it never reached the "typing jumps to the prompt" - /// housekeeping on the raw key path — leaving the user editing a line they - /// cannot see. #[gpui::test] fn history_recall_snaps_the_viewport_back_to_the_prompt(cx: &mut TestAppContext) { let (window, _daemon) = harness(cx); @@ -9340,10 +6514,6 @@ mod gpui_tests { .unwrap(); } - /// ⌃P / ⌃N are readline's history motions, and a raw terminal passes them - /// to the shell as 0x10 / 0x0e. The local editor swallows every Ctrl chord - /// at the prompt, so without arms of their own they went from "works" to - /// "does nothing" the moment shell integration engaged. #[gpui::test] fn ctrl_p_and_ctrl_n_walk_the_history(cx: &mut TestAppContext) { let (window, _daemon) = harness(cx); @@ -9354,32 +6524,24 @@ mod gpui_tests { .map(String::from) .collect(); - // ⌃P walks back from the newest entry. view.handle_editor_key(&key("ctrl-p"), cx); assert_eq!(view.cmd.text(), "echo hello"); view.handle_editor_key(&key("ctrl-p"), cx); assert_eq!(view.cmd.text(), "cargo build"); - // ⌃N walks forward again. view.handle_editor_key(&key("ctrl-n"), cx); assert_eq!(view.cmd.text(), "echo hello"); - // Past the newest entry the in-progress line comes back. view.handle_editor_key(&key("ctrl-n"), cx); assert_eq!(view.cmd.text(), ""); }) .unwrap(); } - /// A Ctrl chord the local editor has no widget for used to be swallowed, so - /// engaging shell integration *removed* ⌃T, ⌥T, ⌥U and every `bindkey` - /// widget the user had bound. Hand the line to zle instead and let its - /// keymap answer — the same escape hatch ⌃R already uses. #[gpui::test] fn an_unknown_ctrl_chord_goes_to_the_shell_with_the_line(cx: &mut TestAppContext) { let (window, mut daemon) = harness(cx); window .update(cx, |view, _, cx| { view.cmd.set("echo hi"); - // ⌃T is readline's transpose-chars; tty7 has no widget for it. view.handle_editor_key(&key("ctrl-t"), cx); assert_eq!( view.cmd.text(), @@ -9396,9 +6558,6 @@ mod gpui_tests { assert_eq!(next_input(&mut daemon), vec![0x14], "⌃T reached the shell"); } - /// The Meta half of the same gap: ⌥U (upcase-word) and friends were dead at - /// the prompt. Unrecognized Meta chords ship the line and the ESC-prefixed - /// key, the way a raw terminal would have. #[gpui::test] fn an_unknown_meta_chord_goes_to_the_shell_with_the_line(cx: &mut TestAppContext) { let (window, mut daemon) = harness(cx); @@ -9423,10 +6582,6 @@ mod gpui_tests { assert_eq!(next_input(&mut daemon), b"\x1bu".to_vec()); } - /// ⌃W / ⌃U / ⌃K are *kills*, and ⌃Y is what puts a kill back — without it - /// the pair was half-implemented: the editor cut text with nowhere to paste - /// it from. ⌃Y has to stay local rather than reaching the shell, because - /// zle's kill ring is a different buffer and would yank unrelated text. #[gpui::test] fn ctrl_y_yanks_back_what_the_kill_chords_cut(cx: &mut TestAppContext) { let (window, _daemon) = harness(cx); @@ -9445,12 +6600,6 @@ mod gpui_tests { .unwrap(); } - /// ⌥. is readline's `yank-last-arg`: it pulls the last word of the previous - /// command into the line, and repeating it walks further back through the - /// history, replacing what the last press inserted. Frequent enough that - /// paying the handoff cost (ghost text and completion gone for the rest of - /// the line) on every press would be the wrong trade — tty7 holds the same - /// history, so it answers locally. #[gpui::test] fn meta_dot_walks_back_through_the_last_words(cx: &mut TestAppContext) { let (window, _daemon) = harness(cx); @@ -9476,18 +6625,13 @@ mod gpui_tests { assert_eq!(view.cmd.text(), "ls --release", "repeat steps one back"); view.handle_editor_key(&meta_dot, cx); assert_eq!(view.cmd.text(), "ls status"); - // Nothing older to reach: the line holds what it had. view.handle_editor_key(&meta_dot, cx); assert_eq!(view.cmd.text(), "ls status"); - // The caret sits after the inserted word, ready to keep typing. assert_eq!(view.cmd.cursor(), "ls status".chars().count()); }) .unwrap(); } - /// The walk is only a walk while ⌥. repeats. Once another key edits the - /// line, the next ⌥. starts over from the newest entry instead of eating - /// whatever happens to sit left of the caret. #[gpui::test] fn an_intervening_key_restarts_the_last_word_walk(cx: &mut TestAppContext) { let (window, _daemon) = harness(cx); @@ -9520,15 +6664,9 @@ mod gpui_tests { .unwrap(); } - /// Edits that bypass `handle_editor_key` — IME-committed text is the - /// everyday one (it's how all typing arrives on macOS and Windows) — must - /// end the walk too. Without that, the next ⌥. deletes the span the walk - /// recorded even though the user's typing now sits inside it. #[gpui::test] fn an_intervening_ime_commit_restarts_the_last_word_walk(cx: &mut TestAppContext) { let (window, mut daemon) = harness(cx); - // `commit_text` edits the local line only while the editor is engaged - // at a shell prompt; anywhere else it writes gap text to the PTY. DaemonMsg::Prompt { active: true, at_prompt: true, @@ -9565,11 +6703,6 @@ mod gpui_tests { .unwrap(); } - /// ⌥. with a selection active: the word replaces the selection (insertion - /// replaces selections everywhere in this editor), and the walk records - /// where the word actually landed — the caret the selection collapsed to, - /// not where the caret stood before the insert — so a repeat swaps the - /// word cleanly. #[gpui::test] fn meta_dot_over_a_selection_records_where_the_word_landed(cx: &mut TestAppContext) { let (window, _daemon) = harness(cx); @@ -9588,7 +6721,6 @@ mod gpui_tests { .map(String::from) .collect(); view.cmd.set("ls foo"); - // Select "foo" with the caret at the selection's far end. view.cmd.set_cursor(3); view.cmd.extend_to(6); @@ -9608,10 +6740,6 @@ mod gpui_tests { .unwrap(); } - /// A shifted Meta chord must ship the shifted character: ⌥⇧U is `ESC U` - /// on the wire (upcase-region in zsh's keymap), not the `ESC u` of plain - /// ⌥U — gpui reports the key name unshifted, so the handoff has to apply - /// Shift itself when no `key_char` is there to consult. #[gpui::test] fn a_shifted_meta_chord_hands_off_the_shifted_character(cx: &mut TestAppContext) { let (window, mut daemon) = harness(cx); @@ -9637,9 +6765,6 @@ mod gpui_tests { assert_eq!(next_input(&mut daemon), b"\x1bU".to_vec()); } - /// Chords the editor *does* answer stay local — handing off would forfeit - /// ghost text and completion for the rest of the line, and ⌃A/⌃E/⌃W are - /// exactly the keys pressed most often mid-edit. #[gpui::test] fn a_known_ctrl_chord_stays_in_the_local_editor(cx: &mut TestAppContext) { let (window, _daemon) = harness(cx); @@ -9653,9 +6778,6 @@ mod gpui_tests { .unwrap(); } - /// A `PtyWrite` raised by the VT layer (query replies, bracketed-paste - /// wrapping…) must come out of the client socket as an `Input` frame — - /// this is the half of the query round-trip the remote tests can't see. #[gpui::test] fn pty_write_events_reach_the_daemon_as_input(cx: &mut TestAppContext) { let (window, mut daemon) = harness(cx); @@ -9667,12 +6789,6 @@ mod gpui_tests { assert_eq!(next_input(&mut daemon), b"ping".to_vec()); } - // ── The read-only degrade, at the five keystroke entry points ─── - - /// Install a store holding one remote workspace with no connection, and - /// bind `view` to it. `RemoteLinks` has never heard of the machine, so - /// `status_of` answers `Disconnected` — the state a window sits in between - /// losing a link and getting it back. fn bind_to_a_disconnected_remote_workspace( view: &mut TerminalView, cx: &mut Context<TerminalView>, @@ -9707,13 +6823,6 @@ mod gpui_tests { id } - /// A window that is not attached **still shows, scrolls, - /// selects and searches — but typing goes nowhere**, and nothing is - /// buffered for later (D6). - /// - /// All five entry points a keystroke can take, because a rule enforced at - /// four of them is not enforced: the one that is missed is the one a user - /// finds. #[gpui::test] fn a_disconnected_remote_pane_swallows_every_kind_of_typing(cx: &mut TestAppContext) { let (window, mut daemon) = harness(cx); @@ -9725,7 +6834,6 @@ mod gpui_tests { view.commit_text("y", cx); view.paste("pasted".into(), cx); view.send_to_pty(b"raw", cx); - // The typeahead timer: armed while attached, fires afterwards. view.dump_hold(0, cx); }) .unwrap(); @@ -9736,17 +6844,6 @@ mod gpui_tests { ); } - /// The rest of the degrade, and the half a gate at the top of - /// `on_key_down` would silently destroy: **a read-only window is not an - /// inert one.** - /// - /// "能滚历史、能选能复制、能 ⌘F 搜索" — the window's own keyboard belongs to - /// the window, not to the machine. ⌘A and ⌘C are dispatched *inside* - /// `on_key_down` (`handle_cmd_shortcut`), so a gate at the top of that - /// function takes them away; this drives the real dispatcher to prove it - /// does not. (⌘F is a registered action and never enters `on_key_down` at - /// all, which is why it survives either placement — and why testing only - /// ⌘F would have missed this entirely.) #[gpui::test] fn a_disconnected_remote_pane_still_selects_and_copies(cx: &mut TestAppContext) { let (window, mut daemon) = harness(cx); @@ -9783,9 +6880,6 @@ mod gpui_tests { window .update(cx, |view, window, cx| { bind_to_a_disconnected_remote_workspace(view, cx); - // A dropped link marks the pane `exited` (the reader's teardown - // sets the same flag a real child exit does). That must not take - // the window's own keyboard away. view.terminal.exited = true; view.on_key_down(&chord("a"), window, cx); assert!( @@ -9802,12 +6896,6 @@ mod gpui_tests { ); } - /// The other half of the same rule, and the one that is easy to get wrong: - /// a **terminal query reply is not user input**. - /// - /// DA / DSR / OSC colour answers are the emulator replying to something the - /// *remote program* asked. Gating them would not degrade the window, it - /// would hang the program — it waits for an answer that never comes. #[gpui::test] fn a_disconnected_remote_pane_still_answers_terminal_queries(cx: &mut TestAppContext) { let (window, mut daemon) = harness(cx); @@ -9824,9 +6912,6 @@ mod gpui_tests { ); } - /// A dropped link and a finished shell are opposite facts, and the tab must - /// not confuse them: on a remote workspace the shell is still running over - /// there, which is the entire promise of the degrade. #[gpui::test] fn a_dropped_link_does_not_claim_the_process_exited(cx: &mut TestAppContext) { let (window, _daemon) = harness(cx); @@ -9836,7 +6921,6 @@ mod gpui_tests { view.handle_event(AlacEvent::Exit, cx); assert_eq!(view.title, "tty7 — disconnected"); - // A local pane's wording is untouched. view.set_workspace(None); view.handle_event(AlacEvent::Exit, cx); assert_eq!(view.title, "tty7 — process exited"); @@ -9844,9 +6928,6 @@ mod gpui_tests { .unwrap(); } - /// The other side of that exemption: **a local pane's exited check is not - /// touched.** A pane whose shell ended still swallows every key exactly as - /// it did before remote workspaces existed. #[gpui::test] fn an_exited_local_pane_still_swallows_every_key(cx: &mut TestAppContext) { let (window, _daemon) = harness(cx); @@ -9874,16 +6955,11 @@ mod gpui_tests { .unwrap(); } - /// **A local pane is not gated, ever.** It has no connection to lose, and a - /// gate that could answer `false` for one would brick the app — so the - /// check is a field test on `workspace()`, and this pins that it stays one. #[gpui::test] fn a_local_pane_types_exactly_as_it_always_did(cx: &mut TestAppContext) { let (window, mut daemon) = harness(cx); window .update(cx, |view, window, cx| { - // Even with a store installed that knows about a *remote* - // workspace — the global existing must not change a local pane. bind_to_a_disconnected_remote_workspace(view, cx); view.set_workspace(None); assert!(view.accepts_input(cx)); @@ -9893,16 +6969,6 @@ mod gpui_tests { assert_eq!(next_input(&mut daemon), b"z".to_vec()); } - // ── The reconnect: the pane relink ────────────────────────────── - - /// The pane half of a reconnect swaps the socket **in place**: same `Term`, - /// same event channel, same shared signals — because the view's event pump - /// subscribes once, at construction, and a fresh terminal would leave the - /// pane on screen and permanently deaf. - /// - /// Also pins the honest replay boundary: the mirror is reset, so what is on - /// screen after a relink is the machine's own record and not the pre-drop - /// screen with a second copy replayed underneath it. #[gpui::test] fn a_relink_moves_the_pane_onto_the_new_socket_and_resets_the_mirror(cx: &mut TestAppContext) { let (window, mut old_daemon) = harness(cx); @@ -9919,7 +6985,6 @@ mod gpui_tests { .unwrap() }; - // Something on screen from before the drop, through the real reader. DaemonMsg::Output(b"before".to_vec()) .encode(&mut old_daemon) .unwrap(); @@ -9952,16 +7017,12 @@ mod gpui_tests { ); }) .unwrap(); - // The pre-drop screen is gone rather than doubled: whatever the daemon - // replays next is the whole truth, and the part the ring dropped is - // simply absent — not interpolated, not implied to be coming. assert_ne!( read_row(cx, 6), "before", "the mirror must be reset before the daemon replays onto it" ); - // The last step: resize to *this* client's geometry. let resize = loop { match ClientMsg::read(&mut new_daemon).expect("the new socket is live") { ClientMsg::Resize(win) => break win, @@ -9970,15 +7031,11 @@ mod gpui_tests { }; assert_eq!((resize.cols, resize.rows), (100, 30)); - // And input now goes to the new machine, not the dead one. window .update(cx, |view, _, cx| view.send_to_pty(b"after", cx)) .unwrap(); assert_eq!(next_input(&mut new_daemon), b"after".to_vec()); - // The retired socket is *closed*, not merely unused: reading it runs - // out. That is what ends the old reader thread, and it is why a relink - // cannot leave two readers racing to feed one grid. let mut leftovers: Vec<Vec<u8>> = Vec::new(); loop { match ClientMsg::read(&mut old_daemon) { @@ -9993,21 +7050,14 @@ mod gpui_tests { ); } - /// Buffer search (Cmd+F) end-to-end: the case ("Aa") and regex (".*") - /// toggles change the match set, a broken regex flags an error instead of a - /// silent zero-match, and closing persists the query. Drives the real - /// `open_search` / `recompute_matches` / `close_search` path against a grid - /// seeded through the reader thread. #[gpui::test] fn buffer_search_honors_case_and_regex_toggles(cx: &mut TestAppContext) { let (window, mut daemon) = harness(cx); - // Three lines differing only by case, so the case toggle is observable. DaemonMsg::Output(b"Hello World\r\nhello world\r\nWORLD wide\r\n".to_vec()) .encode(&mut daemon) .unwrap(); - // Wait for the reader thread to parse the output into the grid. for _ in 0..200 { let ready = window .update(cx, |v, _, _| { @@ -10039,36 +7089,29 @@ mod gpui_tests { view.open_search(window, cx); assert!(view.search.is_some(), "Cmd+F opens the bar"); - // Smart-case default: a lowercase query matches all three casings. set_query(view, "world", window, cx); assert_eq!(view.search.as_ref().unwrap().matches.len(), 3); assert!(!view.search_regex_error); - // Force case-sensitive: only the exact-lowercase line matches. view.search_case_sensitive = true; view.recompute_matches(cx); assert_eq!(view.search.as_ref().unwrap().matches.len(), 1); view.search_case_sensitive = false; - // Literal mode: "wor.d" (a literal dot) matches nothing; regex - // mode turns "." into a wildcard so all three lines match. set_query(view, "wor.d", window, cx); assert_eq!(view.search.as_ref().unwrap().matches.len(), 0); view.search_regex = true; view.recompute_matches(cx); assert_eq!(view.search.as_ref().unwrap().matches.len(), 3); - // A broken regex flags an error rather than a silent zero-match. view.search_regex = true; set_query(view, "(", window, cx); assert!(view.search_regex_error); assert_eq!(view.search.as_ref().unwrap().matches.len(), 0); - // The same query is a valid literal once regex mode is off. view.search_regex = false; view.recompute_matches(cx); assert!(!view.search_regex_error); - // Closing remembers the query for the next open. view.close_search(window, cx); assert_eq!(view.search_last_query, "("); assert!(view.search.is_none()); @@ -10088,14 +7131,9 @@ mod gpui_tests { .unwrap(); } - /// CSI 14 t (text-area size in pixels) must be answered from the current - /// grid geometry — image TUIs (yazi, chafa) stall on a report that never - /// comes. #[gpui::test] fn text_area_size_request_replies_with_the_current_geometry(cx: &mut TestAppContext) { let (window, mut daemon) = harness(cx); - // The window may have re-measured the grid by now — derive the - // expectation from whatever size the terminal actually has. let want = window .update(cx, |view, _, cx| { let size = view.terminal.size(); @@ -10109,18 +7147,10 @@ mod gpui_tests { assert_eq!(next_input(&mut daemon), want.into_bytes()); } - /// The full ingress chain — daemon frame → reader thread → grid → event - /// pump → `handle_event(Wakeup)` — inside a real (headless) App. Guards - /// the pump against the "grid updated but the view never wakes" class of - /// bug, and the second frame proves the pump survives its own - /// redraw-scheduling step (a failed window refresh must degrade, never - /// tear the pump down). #[gpui::test] fn daemon_output_reaches_the_grid_through_the_event_pump(cx: &mut TestAppContext) { let (window, mut daemon) = harness(cx); - // Bounded poll: the reader is a real OS thread, so give it wall-clock - // time, then let the foreground pump run between checks. let read_row = |cx: &mut TestAppContext, len: usize| -> String { window .update(cx, |view, _, _| { @@ -10151,17 +7181,12 @@ mod gpui_tests { .unwrap(); assert_eq!(wait_for(cx, "hello"), "hello"); - // A second frame still lands: the pump outlived the first round-trip. DaemonMsg::Output(b" again".to_vec()) .encode(&mut daemon) .unwrap(); assert_eq!(wait_for(cx, "hello again"), "hello again"); } - /// Copy-on-select, end to end: real output through the pump, the same - /// start/update/end calls the mouse handlers make, then the clipboard. - /// Off (the default) the release must leave the clipboard alone; on, the - /// selected text lands at mouse-up with no ⌘C. #[gpui::test] fn copy_on_select_writes_the_clipboard_at_mouse_up(cx: &mut TestAppContext) { let (window, mut daemon) = harness(cx); @@ -10169,7 +7194,6 @@ mod gpui_tests { DaemonMsg::Output(b"hello world".to_vec()) .encode(&mut daemon) .unwrap(); - // Bounded poll for the reader thread, as in the pump test above. for _ in 0..400 { cx.run_until_parked(); let row: String = window @@ -10187,7 +7211,6 @@ mod gpui_tests { std::thread::sleep(std::time::Duration::from_millis(5)); } - // Drag across "hello" and release with the feature off: no copy. let drag_hello = |cx: &mut TestAppContext| { window .update(cx, |view, _, cx| { @@ -10204,14 +7227,11 @@ mod gpui_tests { "default-off must never write the clipboard" ); - // Same gesture with the feature on: "hello" is on the clipboard. cx.update(|cx| cx.update_global::<Config, _>(|cfg, _| cfg.copy_on_select = true)); drag_hello(cx); let text = cx.update(|cx| cx.read_from_clipboard().and_then(|item| item.text())); assert_eq!(text.as_deref(), Some("hello")); - // The mouse-up copy must NOT consume the selection: copy-on-select - // keeps the highlight, like every terminal with the feature. let selected = window .update(cx, |view, _, _| { view.terminal.term.lock().selection.is_some() @@ -10223,10 +7243,6 @@ mod gpui_tests { ); } - /// Ctrl+C means "copy the selection, else ^C (SIGINT)" — so the copy must - /// consume the selection, or a second Ctrl+C copies again forever and the - /// user can't interrupt the foreground command (#111). Cmd+C keeps the - /// selection (macOS convention), covered by the copy-on-select test above. #[gpui::test] fn ctrl_c_copy_consumes_the_selection_so_the_next_press_is_sigint(cx: &mut TestAppContext) { let (window, mut daemon) = harness(cx); @@ -10234,7 +7250,6 @@ mod gpui_tests { DaemonMsg::Output(b"hello world".to_vec()) .encode(&mut daemon) .unwrap(); - // Bounded poll for the reader thread, as in the pump test above. for _ in 0..400 { cx.run_until_parked(); let row: String = window @@ -10254,14 +7269,11 @@ mod gpui_tests { window .update(cx, |view, window, cx| { - // Mouse-select "hello" (copy-on-select is off by default, so - // the selection survives mouse-up). view.on_select_start(0, 0, true, 1, false, cx); view.on_select_update(4, 0, false, cx); view.on_select_end(cx); assert!(view.has_selection(), "the drag must leave a selection"); - // First Ctrl+C: copies and consumes the selection. let consumed = view.handle_cmd_shortcut(&key("ctrl-c"), window, cx); assert!(matches!(consumed, CmdKey::Consumed)); assert!( @@ -10269,8 +7281,6 @@ mod gpui_tests { "the Ctrl+C copy must consume the selection" ); - // Second Ctrl+C: no selection left, so the chord falls through - // to the raw ^C (SIGINT) path. let fell_through = view.handle_cmd_shortcut(&key("ctrl-c"), window, cx); assert!(matches!(fell_through, CmdKey::FallThrough)); }) @@ -10279,8 +7289,6 @@ mod gpui_tests { assert_eq!(text.as_deref(), Some("hello")); } - /// Pasting to the PTY consumes the selection like typing does, so the - /// reported select → copy → paste → Ctrl+C sequence ends in SIGINT (#111). #[gpui::test] fn paste_to_the_pty_consumes_the_selection(cx: &mut TestAppContext) { let (window, mut daemon) = harness(cx); @@ -10295,20 +7303,13 @@ mod gpui_tests { ); }) .unwrap(); - // The pasted bytes still reach the PTY. assert_eq!(next_input(&mut daemon), b"echo hi".to_vec()); } - /// Same dual-purpose rule at the prompt: Ctrl+A selects the edited line, - /// Ctrl+C copies it — and must consume the editor selection so the next - /// Ctrl+C reaches the editor's ^C (abort line) instead of copying again - /// (#111, editor-selection variant). #[gpui::test] fn ctrl_c_copy_consumes_the_editor_selection_at_the_prompt(cx: &mut TestAppContext) { let (window, mut daemon) = harness(cx); - // Shell reports it is idle at its prompt: this is what flips - // `input_active()` true and puts the inline editor in charge. DaemonMsg::Prompt { active: true, at_prompt: true, @@ -10316,7 +7317,6 @@ mod gpui_tests { } .encode(&mut daemon) .unwrap(); - // Poll until the prompt report has applied. for _ in 0..400 { cx.run_until_parked(); let active = window.update(cx, |view, _, _| view.input_active()).unwrap(); @@ -10332,7 +7332,6 @@ mod gpui_tests { view.cmd.insert_str("echo hi"); view.cmd.select_all(); - // First Ctrl+C: copies the line and consumes the selection. let consumed = view.handle_cmd_shortcut(&key("ctrl-c"), window, cx); assert!(matches!(consumed, CmdKey::Consumed)); assert!( @@ -10340,8 +7339,6 @@ mod gpui_tests { "the Ctrl+C copy must consume the editor selection" ); - // Second Ctrl+C: nothing selected anywhere, so the chord falls - // through to the editor's ^C (abort line) handling. let fell_through = view.handle_cmd_shortcut(&key("ctrl-c"), window, cx); assert!(matches!(fell_through, CmdKey::FallThrough)); }) @@ -10350,23 +7347,6 @@ mod gpui_tests { assert_eq!(text.as_deref(), Some("echo hi")); } - /// Reproduces the "orange caret jumps to the top-left corner after Claude - /// Code exits" bug at the state level, driving the two conditions that must - /// co-occur to trigger it: - /// - /// 1. the shell is idle at its prompt (`DaemonMsg::Prompt` → - /// `input_active()`), so the inline editor is live and draws its own - /// caret via `render_input_bar`, which anchors at `cursor_cell()`; and - /// 2. the local grid's cursor *shape* is still `Hidden` — a full-screen - /// TUI hid the cursor with DECTCEM (`\e[?25l`) and handed back to the - /// prompt before a matching `\e[?25h` landed. - /// - /// The cursor's real *position* is a valid cell (the prompt end), but the - /// stale-hidden shape used to make `cursor_cell()` return `None`, so - /// `render_input_bar`'s `unwrap_or((0, 0))` painted the caret at cell - /// `(0, 0)`. The assertions pin all three facts: the editor is active, the - /// shape genuinely is `Hidden` (the precondition that tripped the old - /// early-return), and `cursor_cell()` nonetheless reports the real cell. #[gpui::test] fn hidden_cursor_at_prompt_anchors_the_editor_at_the_real_cell_not_top_left( cx: &mut TestAppContext, @@ -10375,8 +7355,6 @@ mod gpui_tests { let (window, mut daemon) = harness(cx); - // Shell reports it is idle at its prompt: this is what flips - // `input_active()` true and puts the inline editor in charge. DaemonMsg::Prompt { active: true, at_prompt: true, @@ -10384,13 +7362,10 @@ mod gpui_tests { } .encode(&mut daemon) .unwrap(); - // CUP to row 4 / col 11 (1-based), then hide the cursor as a TUI would - // on the way out — leaving the shape `Hidden` at a valid position. DaemonMsg::Output(b"\x1b[4;11H\x1b[?25l".to_vec()) .encode(&mut daemon) .unwrap(); - // Poll until both the prompt report and the grid bytes have applied. let mut state = (false, false, None); for _ in 0..400 { cx.run_until_parked(); @@ -10425,12 +7400,6 @@ mod gpui_tests { ); } - /// A genuine child exit (`DaemonMsg::Exited`) must surface as a - /// `ChildExited` gpui event — the app's cue to close the pane/tab (the - /// "typing `exit` leaves a dead pane behind" bug). A daemon disconnect - /// marks the view exited through the same `AlacEvent::Exit` arm but must - /// emit nothing: auto-closing on a lost connection would silently discard - /// (and kill) a pane that may still be alive daemon-side. #[gpui::test] fn child_exit_emits_the_close_event_but_disconnect_does_not(cx: &mut TestAppContext) { use std::cell::Cell; @@ -10462,7 +7431,6 @@ mod gpui_tests { panic!("the view never noticed the exit"); }; - // The child really exits: the daemon says so. let (window, mut daemon) = harness(cx); let got = subscribe(&window, cx); DaemonMsg::Exited { code: Some(0) } @@ -10471,7 +7439,6 @@ mod gpui_tests { wait_exited(&window, cx); assert!(got.get(), "a genuine child exit must emit ChildExited"); - // The connection just drops. let (window, daemon) = harness(cx); let got = subscribe(&window, cx); drop(daemon); @@ -10479,38 +7446,19 @@ mod gpui_tests { assert!(!got.get(), "a daemon disconnect must not emit ChildExited"); } - /// Regression for the "cursor vanishes after an ssh session dies mid-TUI" - /// bug. Over ssh, a remote full-screen TUI entered the alt screen and hid - /// the cursor (`\e[?1049h\e[?25l`). The network then drops: the restore - /// sequences (`\e[?25h`, `\e[?1049l`) never arrive, ssh exits, and the - /// *host* shell draws its prompt (reported via OSC 133 → `Prompt`). - /// - /// Before the prompt-time scrub in the remote reader (see - /// `stale_mode_resets`), the grid stayed stranded on the alt screen with - /// a `Hidden` cursor shape, so *neither* cursor painted: - /// `element::build_grid` filters hidden grid cursors, and the inline - /// editor (which would ignore the stale-Hidden shape, see the test above) - /// never engaged because `input_active()` requires being off the alt - /// screen — a visible prompt with no cursor anywhere. The prompt report - /// must instead scrub the residue: off the alt screen, cursor shown, - /// editor live again. #[gpui::test] fn ssh_drop_mid_tui_recovers_at_the_next_prompt(cx: &mut TestAppContext) { use alacritty_terminal::vte::ansi::CursorShape; let (window, mut daemon) = harness(cx); - // Bytes that arrived over ssh before the drop: the remote TUI enters - // the alt screen and hides the cursor. The connection dies before any - // restore sequence is sent. DaemonMsg::Output(b"\x1b[?1049h\x1b[?25l".to_vec()) .encode(&mut daemon) .unwrap(); - // ssh exits; the host shell's integration reports a fresh prompt. DaemonMsg::Prompt { active: true, at_prompt: true, - last_exit: Some(255), // ssh's exit code after a connection loss + last_exit: Some(255), } .encode(&mut daemon) .unwrap(); @@ -10544,8 +7492,6 @@ mod gpui_tests { "the prompt report must re-show the DECTCEM-hidden cursor" ); - // With the residue scrubbed, the inline editor engages and owns the - // caret again — the user sees a cursor at the prompt. window .update(cx, |view, _, _| { assert!( @@ -10556,9 +7502,6 @@ mod gpui_tests { .unwrap(); } - /// A generator that finishes while its menu is still open merges its results - /// in: candidates land, the set filters to the word as it now stands, and the - /// highlight settles on the closest match — the async half of #51's fix. #[gpui::test] fn generator_results_merge_into_the_open_menu(cx: &mut TestAppContext) { use crate::terminal::generator::Parsed; @@ -10566,9 +7509,6 @@ mod gpui_tests { let (window, _daemon) = harness(cx); window .update(cx, |view, _, cx| { - // A pure-generator slot: the menu opened with no sync candidates - // (word_start at the caret, empty open word). The user has since - // typed "ma", so the live word narrows what the results show. view.cmd.set_with_cursor("git checkout ma", 15); let session = CompletionSession::new(13, String::new(), Vec::new()); let generation = view.open_completion(session); @@ -10591,16 +7531,12 @@ mod gpui_tests { let s = view.completion.as_ref().expect("menu still open"); let shown: Vec<&str> = s.filtered.iter().map(|&i| s.all[i].text.as_str()).collect(); - // "feature" filtered out by the live "ma"; closeness orders the - // rest; the top row is preselected. assert_eq!(shown, vec!["main", "mainline"]); assert_eq!(s.selected().unwrap().text, "main"); }) .unwrap(); } - /// A generator that finishes *after* its menu closed must not resurrect it: - /// closing bumps the generation, so the stale result is dropped. #[gpui::test] fn generator_result_for_a_closed_menu_is_dropped(cx: &mut TestAppContext) { use crate::terminal::generator::Parsed; @@ -10611,7 +7547,6 @@ mod gpui_tests { view.cmd.set_with_cursor("git checkout ", 13); let session = CompletionSession::new(13, String::new(), Vec::new()); let stale = view.open_completion(session); - // The user dismisses the menu before the generator returns. view.close_completion(); view.completion_merge( @@ -10627,8 +7562,6 @@ mod gpui_tests { "a result for a closed session never reopens the menu" ); - // And a result for an old generation can't bleed into a *new* - // session that has since opened. let fresh = view.open_completion(CompletionSession::new(13, String::new(), Vec::new())); assert_ne!(stale, fresh); @@ -10649,21 +7582,6 @@ mod gpui_tests { .unwrap(); } - /// A remote-workspace pane's cwd is a path on the **far** machine, and the - /// pane itself never says so: `tty7-server` over there spawned an ordinary - /// local shell and reports `remote_context: None`, exactly as a local - /// daemon would. The machine that moved is the *daemon*, which no - /// pane-level signal can express — only this side's workspace binding - /// knows. - /// - /// So both accessors have to consult it, and they fail in opposite - /// directions when they don't: - /// - `local_cwd` says yes and hands `/home/me/proj` to `read_dir`, to the - /// link opener, and to Tab's local path engine — answers about *this* - /// machine dressed as the remote's; - /// - `remote_ssh_cwd` says no, so Tab never asks the remote over SFTP and - /// silently hands the line to the shell instead. That one is only - /// annoying; the first one is wrong. #[gpui::test] fn a_remote_workspace_pane_reports_its_cwd_as_remote(cx: &mut TestAppContext) { use std::io::Write as _; @@ -10672,7 +7590,6 @@ mod gpui_tests { .encode(&mut daemon) .unwrap(); daemon.flush().unwrap(); - // The reader is a real thread; poll rather than sleep a fixed span. for _ in 0..200 { let seen = window .update(cx, |view, _, _| view.cwd().is_some()) @@ -10685,8 +7602,6 @@ mod gpui_tests { window .update(cx, |view, _, cx| { - // Unbound, this is a plain local pane: the path is this - // machine's, and there is no remote to ask about it. assert_eq!( view.local_cwd(), Some(std::path::PathBuf::from("/home/me/proj")) @@ -10695,7 +7610,6 @@ mod gpui_tests { bind_to_a_disconnected_remote_workspace(view, cx); - // The premise: nothing about the pane became remote. assert!( view.remote_context().is_none(), "the far daemon reports a plain local pane — if this ever \ diff --git a/src/ui/app.rs b/src/ui/app.rs index fc05b627..614c8202 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -1,6 +1,3 @@ -//! The window shell: a transparent unified title bar carrying the tab strip, -//! with the active terminal filling the rest. Owns all tabs (each its own PTY). - use gpui::{ App, Axis, Bounds, Context, Entity, Focusable, Pixels, PromptLevel, Subscription, Window, div, img, prelude::*, px, @@ -40,8 +37,6 @@ use crate::ui::settings::{ }; use crate::ui::theme::{apply_theme, set_menus, window_background}; -/// One editable color of a user theme, targeted by the in-app color editor. Maps -/// a picker to the seed field (or ANSI slot) it writes back to the theme's file. #[derive(Clone, Copy, PartialEq, Eq)] pub(crate) enum ThemeEdit { Background, @@ -52,138 +47,58 @@ pub(crate) enum ThemeEdit { Ansi(usize), } -/// Convert a picked `Hsla` to a `0xRRGGBB` value (alpha dropped) for storage in a -/// theme file. fn hsla_to_u32(color: gpui::Hsla) -> u32 { let rgba: gpui::Rgba = color.into(); let to = |f: f32| (f.clamp(0.0, 1.0) * 255.0).round() as u32; (to(rgba.r) << 16) | (to(rgba.g) << 8) | to(rgba.b) } -/// Global font-size bounds and step for the live zoom actions. const FONT_SIZE_MIN: f32 = 6.0; const FONT_SIZE_MAX: f32 = 48.0; pub(crate) const FONT_SIZE_STEP: f32 = 1.0; -/// Line-height multiplier bounds and step for the Typography setting. 1.0 packs -/// rows flush against each other; 2.0 is very airy. 1.35 is the default. const LINE_HEIGHT_MIN: f32 = 1.0; const LINE_HEIGHT_MAX: f32 = 2.0; pub(crate) const LINE_HEIGHT_STEP: f32 = 0.05; -/// Cap on the recently-closed-tab stack, bounding memory and the JSON we'd -/// otherwise keep growing without limit. const MAX_CLOSED_TABS: usize = 20; -/// How much one resize step nudges a split's ratio (see `resize_pane`). Matches -/// the divider's clamp band granularity in `pane.rs`. const RESIZE_STEP: f32 = 0.05; -/// Quiet window after the last captured chord before a recorded shortcut is -/// committed (see `schedule_recording_commit`). Long enough to type a second -/// chord of a sequence (`ctrl-b x`), short enough that a single chord commits -/// promptly. const RECORD_COMMIT_DELAY_MS: u64 = 650; -/// Height (px) of the unified title bar. Shared by `render` (the strip's height), -/// the settings overlay's nav-sidebar top zone, and the tab rail's top zone so -/// they all line up (and reach the very top of the window). pub(crate) const TITLE_BAR_HEIGHT: f32 = 40.; -/// The chrome tile rhythm: a square hit box centred on a glyph, in two sizes — -/// the chrome's controls (title bar, rail, panel tabs, code header), and the -/// smaller ones that sit *inside* a panel's body, which have to read as -/// subordinate to the header above them. -/// -/// The glyph sizes are nominal — the viewBox, not the mark. What they were -/// picked to land is ~10.8pt of actual *ink*, measured off a screenshot against -/// the macOS traffic lights (12pt across) in the same frame. That is a hair under -/// VS Code / Windsurf, which measure 12pt the same way, and well over the 8.4pt -/// these tiles drew before [`crate::ui::tab_strip::BUTTON_ICON_SCALE`] — the size -/// they had been pinned to regardless of what any call site asked for. pub(crate) const TILE_SIZE: f32 = 32.; pub(crate) const TILE_GLYPH: f32 = 13.; pub(crate) const TILE_SIZE_SM: f32 = 24.; pub(crate) const TILE_GLYPH_SM: f32 = 11.; -/// Line-art glyphs need a bigger nominal size than framed ones to draw the same -/// ink: in lucide's 24-unit box `plus` spans 5→19 where `panel-left` spans 3→21, -/// so at one shared size the "+" reads a fifth smaller than the tile beside it. -/// Sized off the measured ratios (58% against 72%), not the viewBox arithmetic. -/// (No `_SM` counterpart: every body-scale tile currently carries a framed mark.) pub(crate) const TILE_GLYPH_LINE: f32 = 16.; -/// Distance from a tile's edge to the glyph inside it — what anything lining a -/// tile up with text or with the window edge subtracts from the inset it wants, -/// so the *glyph* lands on the line rather than the invisible hit box around it. -/// -/// Deliberately the nominal gap, not the distance to the glyph's ink. Counting -/// the transparent margin lucide leaves inside the mark is more accurate about -/// where the ink is, and useless: it makes `TILE_PAD` bigger than -/// [`CONTENT_INSET`], which drove [`tile_trailing_inset`] to under a pixel and -/// left the hover capsule looking sheared off against the window edge. The -/// capsule is a thing you can see; it can't be pushed off screen to put the -/// glyph a truer 2px to the right. pub(crate) const TILE_PAD: f32 = (TILE_SIZE - TILE_GLYPH) / 2.; pub(crate) const TILE_PAD_SM: f32 = (TILE_SIZE_SM - TILE_GLYPH_SM) / 2.; -/// Help-menu destinations. The README already points people at these; the app -/// itself offered none of them, so the only in-product way to reach the docs or -/// the chat was to already know the URL. const DOCS_URL: &str = "https://github.com/l0ng-ai/tty7#readme"; const DISCORD_URL: &str = "https://discord.gg/s3dethqz2V"; const ISSUES_URL: &str = "https://github.com/l0ng-ai/tty7/issues/new"; -/// The one content inset the whole window aligns to: the rail's text and icons, -/// the title bar's chrome glyphs, and the side panels all start (or end) here, so -/// every vertical edge in the chrome falls on one of two lines rather than the -/// five slightly different ones each surface used to pick for itself. pub(crate) const CONTENT_INSET: f32 = 12.; -/// Smallest gap between a tile's hit box — the capsule its hover and selected -/// states paint — and the window edge it sits against. -/// -/// A floor, because the two rules that set that gap disagree once a tile is big -/// relative to [`CONTENT_INSET`]: aligning the glyph wants the box pushed out by -/// [`TILE_PAD`], and at `TILE_SIZE` 32 against an inset of 12 that leaves the -/// capsule flush with the edge, reading as clipped rather than aligned. Where -/// they conflict the visible thing wins. const TILE_EDGE_GAP: f32 = 5.; -/// Trailing inset for a group of tiles that ends on the window's right edge: the -/// glyph on [`CONTENT_INSET`] where there is room for it, never closer to the -/// edge than [`TILE_EDGE_GAP`]. pub(crate) fn tile_trailing_inset() -> f32 { (CONTENT_INSET - TILE_PAD).max(TILE_EDGE_GAP) } -/// The same floor for the body-scale tiles inside a panel. pub(crate) fn tile_trailing_inset_sm() -> f32 { (CONTENT_INSET - TILE_PAD_SM).max(TILE_EDGE_GAP) } -/// What gpui-component's `TitleBar` already insets its content by, to clear the -/// window controls: 80px on macOS (traffic lights on the left), 12px elsewhere -/// (controls on the right). Anything laid out *inside* the bar therefore starts -/// here, not at the window edge. pub(crate) const TITLE_BAR_LEAD: f32 = if cfg!(target_os = "macos") { 80. } else { 12. }; -/// What the bar reserves at its *trailing* edge for the window controls: three -/// 34px tiles (─ ▢ ✕) off macOS, nothing on macOS (the traffic lights are on the -/// left, and `TITLE_BAR_LEAD` covers them). Anything in the bar that has to line -/// up with a column below it measures from the window edge minus this. pub(crate) const WINDOW_CONTROLS_W: f32 = if cfg!(target_os = "macos") { 0. } else { 102. }; -/// Left offset for the tile group that sits beside the window controls. -/// -/// On macOS the thing that can collide with the traffic lights is the tile's -/// *hit box* — it paints a background on hover and when selected, [`TILE_PAD`] -/// wider than the glyph on each side — so this aligns the box, not the glyph, and the -/// bar's own 80px lead is already exactly the clearance macOS defines for that. -/// Hence zero: pulling back into the reserve to "hug" the lights only made the -/// hover capsule touch them. Off macOS the controls are on the right, nothing is -/// there to clear, and the group aligns its glyph to the content inset like the -/// rest of the chrome. pub(crate) fn title_bar_hug_offset() -> f32 { if cfg!(target_os = "macos") { 0. @@ -192,44 +107,8 @@ pub(crate) fn title_bar_hug_offset() -> f32 { } } -/// Edge of the brand mark that anchors the window's leading corner off macOS -/// (see [`window_mark`]). Between a chrome tile's 32px hit box and its 13px -/// glyph: the mark paints no hover capsule, so what has to sit level with the -/// tiles beside it is its *ink* — and solid art reads heavier than line work at -/// equal size, hence short of the tile box rather than matching it. pub(crate) const WINDOW_MARK_SIZE: f32 = 20.; -/// The "duo" mark — the same art the app icon and the About page carry — drawn -/// at the leading edge of the title-bar row, or `None` on macOS. -/// -/// macOS owns that corner: the traffic lights sit there, and [`TITLE_BAR_LEAD`] -/// reserves them 80px. Everywhere else it is empty. The row's contents are the -/// rail's controls at its *right* end and the window chrome at the far side, so -/// the window's leading corner — the slot Windows reads as the app's identity, -/// filled by Explorer, VS Code and Zed alike — held nothing at all, which comes -/// across as unfinished rather than restrained. -/// -/// Drawn, never clicked. It is not a menu button, so it stays out of the tile -/// rhythm (no hover capsule) and deliberately takes no `occlude()`: the row it -/// lives in is a `WindowControlArea::Drag`, and letting the mark fall through to -/// that keeps the strip grabbable instead of punching a dead 20px hole in it. -/// Make a row that stands in for the title bar behave like one: drag it to move -/// the window, double-click it to zoom. -/// -/// Three rows do this. The rail's top zone sits level with the real bar but -/// outside it (the bar only spans the column beside the rail), and the code and -/// diff overlays each cover the bar with a header of their own drawn to its line. -/// Without this they are all dead strips: 40px across the top of the window that -/// look exactly like the caption and do nothing when you grab them. -/// -/// Driven the way gpui-component's own `TitleBar` drives it — a press arms a -/// flag and the first *move* starts the window move — so a plain click, and a -/// double-click, still land intact. Note that on Windows the drag area maps to -/// HTCAPTION and the OS claims the press before gpui hit-tests, so every button -/// inside one of these rows needs an `occlude()` wrapper to get its clicks back. -/// -/// `key` must be unique among the stand-in rows that can be on screen together -/// — see [`window_move_gesture`], which owns the arming and explains why. pub(crate) fn title_bar_drag( row: gpui::Stateful<gpui::Div>, key: &'static str, @@ -237,14 +116,6 @@ pub(crate) fn title_bar_drag( cx: &mut gpui::App, ) -> gpui::Stateful<gpui::Div> { window_move_gesture(row, key, window, cx).on_double_click(|_, window, _| { - // gpui only implements `titlebar_double_click` on macOS — the trait - // method is an empty default everywhere else, so on Linux this row - // swallowed the double-click and nothing zoomed. `zoom_window` is the - // maximise toggle there (x11 `_NET_WM_STATE_MAXIMIZED_*`, wayland - // `set_maximized`), and what gpui-component's own `TitleBar` calls on - // Linux for exactly this reason. Windows needs neither: the row is a - // drag area, which maps to HTCAPTION, and the OS has already restored - // or maximised the window before this could run. if cfg!(target_os = "linux") { window.zoom_window(); } else { @@ -253,96 +124,10 @@ pub(crate) fn title_bar_drag( }) } -/// The armed flag behind [`window_move_gesture`]. A single bool, but *where* it -/// lives is the whole point — see there. pub(crate) struct WindowMoveArm { should_move: bool, } -// ── Every header in tty7 is draggable ──────────────────────────────────────── -// -// A standing rule, not a per-surface feature: a user should never have to learn -// which of this window's headers happen to move it. Any row that reads as a -// header, caption or title — the caption strip, the rail's and the detail -// panel's top zones, an overlay's header, the panel's section title — moves the -// window when you drag it. New headers are expected to arrive that way. -// -// Three things that takes, in the order they bite: -// -// 1. **Arm it with [`window_move_gesture`]**, never a fresh `Rc<Cell>` per -// render. That function's doc explains why (#221). -// 2. **Non-controls take no hit box.** Anything drawn inside a header that is not -// an interactive control — a label, a count, a brand mark, an icon — gets no -// `.id()` and no `occlude()`, so the drag falls straight through it and the -// row stays grabbable however long its text runs. This is the rule #202 -// established for the "duo" mark. Its converse binds too: anything that *is* a -// control needs an `occlude()` wrapper, or Windows' HTCAPTION claims the press -// before gpui hit-tests and the button never fires. -// 3. **A flexible spacer needs a minimum.** Where a header's contents *do* take -// hit boxes by design — the tab strip, whose chips are draggable for reorder — -// the bare spacer beside them is the only grab region there is, and -// `flex-basis: 0` means it collapses to nothing the moment the row fills up. -// See `tab_strip::GRAB_HANDLE_W`. -// -// What the rule does *not* reach is a row that only reads like a header. Where -// the press already means something else, it keeps that meaning, so these being -// undraggable is a decision rather than a backlog: tab chips (a drag reorders -// them), the SFTP breadcrumb (a navigation control), settings section and -// disclosure headings (typography, and click-to-expand rows inside a scrolling -// form), diff file-card headers (click-to-collapse rows inside a scroll list), -// and the command palette (a transient modal over a dismiss-on-press scrim). -// -// One region stops satisfying the rule near one edge of its size range, and -// that is an accepted limitation rather than an oversight to tidy up later. The -// detail panel's macOS top zone is every-child-occluded (four tab tiles plus the -// corner chrome), and that fixed footprint lands within a couple of pixels of -// `right_panel::MIN_WIDTH` — so the spacer between them is a real handle at any -// comfortable panel width and effectively nothing at the panel's floor. -// -// The row below only partly covers that. The panel's section title -// (`right_panel::panel_title`) is draggable and sits immediately under the top -// zone, but on macOS it draws nothing for a tab that passes no `trailing`, which -// is every tab except the remote SFTP browser. So the adjacent handle is there -// off macOS (where that row is the panel's tab switcher and always drawn) and on -// macOS for SFTP; for Info, Outline, Changes and Files on macOS, a panel dragged -// to its floor has no header of its own to grab. -// -// Left that way by explicit decision: `MIN_WIDTH` stays where it is rather than -// trading a real capability — narrowing the panel — for a grab handle, and no -// control moves off that row either. The window still moves from the caption -// strip and the rail's top zone at every panel width. - -/// Arm-on-press / move-on-first-move window dragging for a row that stands in -/// for the title bar. [`title_bar_drag`] is this plus the double-click-to-zoom -/// half; the settings page and the detail panel's top zone take this alone -/// because their double-click differs. -/// -/// **The arm has to outlive the frame it was set in (#221).** This used to hold -/// `should_move` in an `Rc<Cell<bool>>` built inside the render function, which -/// meant every repaint handed the *next* frame's listeners a fresh, zeroed cell -/// while the press had written to the old one. Any redraw between the press and -/// the first drag event silently disarmed the hold — and the press itself -/// schedules one, because `on_double_click` makes gpui call `window.refresh()` -/// on mouse-down. So a drag only survived if the first move beat the next vsync: -/// ≤16ms at 60Hz, ≤8ms on ProMotion. A mouse press nudges the pointer and often -/// wins that race; a trackpad press is a finger pushing down without translating -/// and almost never does, which is exactly the "success rate is very low, and -/// only with the trackpad" the issue reported. The terminal's cursor blink -/// (`cx.notify()` every 530ms) disarms it on its own even without a press. -/// -/// `window.use_keyed_state` puts the flag in gpui element state instead, which -/// survives across frames — the same place gpui-component's own `TitleBar` keeps -/// it, which is why the ordinary caption strip never had this bug. Keyed rather -/// than `use_state` because one builder serves several call sites: `use_state` -/// derives its id from the *caller's* `CodeLocation`, so two of these rows on -/// screen at once (the rail's top zone plus the code overlay's header is a real -/// combination) would share one arm. `key` must therefore be unique among the -/// rows that can coexist. -/// -/// Releasing outside the row disarms too. With a per-frame cell that never -/// mattered — the flag died with the frame regardless — but a flag that persists -/// would otherwise stay armed after a press that wandered off the row, and then -/// start a window move on a later *hover* over it. pub(crate) fn window_move_gesture( row: gpui::Stateful<gpui::Div>, key: &'static str, @@ -388,9 +173,6 @@ pub(crate) fn window_mark() -> Option<impl IntoElement> { if cfg!(target_os = "macos") { return None; } - // Decoded once and shared: the title bar re-renders on every cursor blink, - // and building a fresh `Image` per frame would re-copy the PNG and miss - // gpui's image cache, which is keyed on the image's identity. static LOGO: std::sync::OnceLock<Arc<gpui::Image>> = std::sync::OnceLock::new(); let logo = LOGO .get_or_init(|| { @@ -403,64 +185,17 @@ pub(crate) fn window_mark() -> Option<impl IntoElement> { Some(img(logo).size(px(WINDOW_MARK_SIZE)).flex_shrink_0()) } -/// One tab: a split-pane tree plus an optional user-assigned name. Settings is -/// no longer a tab — it's a full-window overlay (`Tty7App::settings`), so every -/// tab is a real terminal tab. pub struct Tab { - /// The tab's split-pane tree (one or more terminals). pub pane: Pane, - /// User-set custom name (via "Rename Tab"). `None` → derive the label from - /// the focused terminal's title at render time. pub name: Option<String>, - /// Entity id of the pane that last held focus in this tab. Recorded when we - /// leave the tab (see `remember_active_pane`) and restored on return, so - /// switching away and back keeps the active pane instead of jumping to the - /// first leaf. `None` for a tab never left, or after its focused pane closed - /// — both fall back to `first_leaf()`. last_focused: Option<gpui::EntityId>, - /// `Some` while this tab has the working-tree diff overlay open (clicked - /// from a sidebar row's git line). Per-tab so switching away hides it and - /// switching back restores it; closing the tab drops it. Only the active - /// tab's overlay is rendered. See [`crate::ui::diff_overlay`]. pub(crate) diff_overlay: Option<crate::ui::diff_overlay::DiffOverlayState>, - /// This tab's code panel (file tree + editor overlay): open files, tree - /// roots/expansion, and visibility. Same per-tab contract as - /// `diff_overlay` — switching away hides it, switching back restores it, - /// closing the tab drops it. Shared caches (directory listings, gitignore - /// matchers, watchers) live on [`Tty7App`]. `None` until the panel is - /// first opened in this tab. pub(crate) code: Option<Box<crate::ui::code_editor::TabCode>>, - /// The sidebar group this tab last *definitively* belonged to: the - /// repository home of its first pane's cwd — the main checkout's root, so - /// linked worktrees of one repo share a group (deliberately not the - /// focused pane's cwd — switching focus between splits must not relocate - /// the row), or `None` for outside any repo (the "Scratch" group). Sticky - /// on purpose: it only moves when - /// the git cache has a landed answer for the current cwd - /// ([`GitStatusCache::known_repo_for`](crate::terminal::git_status::GitStatusCache::known_repo_for) - /// returns `Some`), so a cd whose probe is still in flight — or a pane - /// with no cwd reported yet — keeps the row where it was instead of - /// flickering through the Scratch group and back. A `RefCell` because the - /// sidebar refreshes it during render, which only has `&Tab`. pub(crate) sidebar_group: std::cell::RefCell<Option<std::path::PathBuf>>, - /// Which of the two full-column overlays (code panel, diff) was raised last. - /// They deliberately have no fixed precedence: whichever the user just acted - /// on paints on top, so opening a diff over the editor shows the diff, and - /// clicking a file in the tree behind it brings the editor back — the same - /// "click it, it comes forward" rule as window stacking. pub(crate) overlay_top: OverlayTop, - /// This tab's identity in the daemon's machine tree — the id every - /// semantic operation about it carries. Minted here (the daemon keeps a - /// client-minted id, see `ControlRequest::TabCreate`), so the tab can be - /// addressed before its create has round-tripped. A `Cell` because the - /// sync layer re-points it at an existing daemon tab when it recognizes - /// one by its panes (`tree_sync::adopt_tab_ids`), and that pass runs with - /// the same shared borrow every save runs under. pub(crate) tree_id: std::cell::Cell<tty7_core::core::machine::TabId>, } -/// Stacking order for the two overlays that cover the whole column. See -/// [`Tab::overlay_top`]. #[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] pub(crate) enum OverlayTop { #[default] @@ -482,9 +217,6 @@ impl Tab { } } - /// A tab mirroring one the daemon's tree already holds — labels and - /// identity from the tree, the pane views from `pane` (built by the delta - /// application, which attaches or reuses them). pub(crate) fn from_tree(tree: &tty7_core::core::machine::Tab, pane: Pane) -> Self { Self { pane, @@ -500,8 +232,6 @@ impl Tab { } } - /// The pane to focus when this tab becomes active: the last-focused leaf if - /// it still exists, otherwise the first leaf. fn focus_target(&self) -> Option<crate::ui::pane::PaneSlot> { match self.last_focused { Some(id) => self.pane.leaf_matching_or_first(|l| l.entity_id() == id), @@ -509,13 +239,6 @@ impl Tab { } } - /// The pane the right panel's detail should describe. Not simply the - /// focused leaf: opening the panel, the diff overlay or the editor moves - /// focus off the terminal entirely, and `focused_or_first` would then fall - /// back to the *first* pane — so a split's second pane would silently swap - /// the panel's cwd the moment you interacted with the panel. Falling back to - /// `focus_target` uses the pane that held focus when it left instead, which - /// is the one the user still thinks of as active. pub(crate) fn detail_pane( &self, window: &Window, @@ -527,13 +250,6 @@ impl Tab { .and_then(|slot| slot.terminal().cloned()) } - /// The title used to derive the tab label: the pane the tab is working in. - /// Only the *active* tab has a live focused pane, so for an inactive tab - /// (which holds no window focus) we fall back to the pane it last had - /// focused (`focus_target`) rather than always its first leaf — otherwise a - /// background tab's label would snap to its first pane. Without a `window` - /// (e.g. the command palette) the same `focus_target` is the best we have. - /// Empty when there's no terminal or no title yet. pub(crate) fn leaf_title(&self, window: Option<&Window>, cx: &App) -> String { let leaf = match window { Some(window) => self @@ -547,12 +263,6 @@ impl Tab { .unwrap_or_default() } - /// The git snapshot (branch + working-tree diff) of the tab's label-driving - /// terminal — the focused leaf with a `window`, else the first — for the - /// sidebar row's branch line (the branch and change count shown under the - /// title). Read through the shared per-repo cache, so tabs in one work - /// tree always agree. `None` when that leaf isn't inside a git work tree, - /// or before the repo's first probe lands. pub(crate) fn git_status( &self, window: Option<&Window>, @@ -565,10 +275,6 @@ impl Tab { leaf.read(cx).git_status(cx) } - /// The coding agent running in this tab, or `None`. Any leaf counts (a - /// split with a shell on the left and Claude on the right is an agent - /// tab); the first agent leaf in tree order wins. Drives the tab avatar's - /// brand mark. pub(crate) fn agent(&self, cx: &App) -> Option<crate::core::cli_agent::CLIAgent> { self.pane .terminals() @@ -576,12 +282,6 @@ impl Tab { .find_map(|l| l.read(cx).agent()) } - /// The tab's most urgent agent status across its leaves — waiting beats - /// working beats done beats idle — or `None` when no leaf runs an agent. - /// The green `Done` state always shows (a finished turn stays visible until - /// the next one); [`agent_unread_count`](Self::agent_unread_count) then - /// says how many of those finished turns are unread. Drives the avatar dot - /// and the sidebar counts. pub(crate) fn agent_status(&self, cx: &App) -> Option<crate::core::cli_agent::AgentStatus> { use crate::core::cli_agent::AgentStatus; let urgency = |s: AgentStatus| match s { @@ -603,12 +303,6 @@ impl Tab { .max_by_key(|s| urgency(*s)) } - /// How many of the tab's panes hold an *unread* finished turn — a `Done` - /// the user hasn't looked at since. Drives the avatar dot's unread form: - /// the green dot swells into a count badge (a split tab can finish several - /// turns while you're away), and shrinks back to a plain dot once every - /// pane has been seen. Zero when the shown status isn't `Done` — a busier - /// pane (working/waiting) owns the corner until it settles. pub(crate) fn agent_unread_count(&self, cx: &App) -> usize { use crate::core::cli_agent::AgentStatus; if self.agent_status(cx) != Some(AgentStatus::Done) { @@ -626,396 +320,150 @@ impl Tab { } } -/// In-progress inline rename of a tab (double-click a tab label). Holds the -/// gpui-component text input plus the subscriptions that commit it on Enter/Blur. pub(crate) struct Renaming { - /// Index of the tab being renamed, in `Tty7App::tabs`. pub(crate) index: usize, pub(crate) input: Entity<InputState>, _subs: Vec<Subscription>, } -/// In-flight inline rename of the current workspace (the title-bar chip turns -/// into a text field). Mirrors [`Renaming`], but keyed to nothing — there is -/// only ever one current workspace per window. pub(crate) struct WorkspaceRename { pub(crate) input: Entity<InputState>, _subs: Vec<Subscription>, } pub(crate) struct LoopbackForwardPanelState { - /// The pane whose add/edit form is expanded under the Info tab's Forwards - /// band, or `None` while the band is just its list. Per-pane rather than a - /// bare flag so switching panes with a form open doesn't offer the new pane - /// a form half-filled with the old one's values. pub(crate) form_pane_id: Option<u64>, - /// The unified forwards list (Local/Remote/Dynamic, including auto localhost - /// forwards) for the pane the Info tab is showing (WS4). pub(crate) managed: Vec<crate::daemon::protocol::ManagedForward>, - /// Add-forward form state (native-SSH panes only). pub(crate) mf_kind: crate::daemon::protocol::SshForwardKind, pub(crate) mf_bind_host: Entity<InputState>, pub(crate) mf_bind_port: Entity<InputState>, pub(crate) mf_target_host: Entity<InputState>, pub(crate) mf_target_port: Entity<InputState>, pub(crate) mf_description: Entity<InputState>, - /// When editing an existing forward, the id being edited — the form shows - /// Save/Cancel and re-establishes the forward on save. `None` = adding. pub(crate) mf_editing: Option<u64>, } pub struct Tty7App { - /// The open tabs; each owns a split-pane tree and an optional name. pub(crate) tabs: Vec<Tab>, pub(crate) active: usize, - /// Current global font size (px), applied to every pane in every tab. pub(crate) font_size: f32, - /// Current global line-height multiplier, applied to every pane. pub(crate) line_height: f32, - /// Currently-applied font family. Tracked (not just read from config on - /// demand) so the `Config`-global observer can tell a hot-reloaded family - /// change from the far more common no-op re-notify. pub(crate) font_family: String, - /// Currently-applied distinct bold/italic faces (`None` = synthesized), also - /// tracked so the hot-reload observer can diff them like `font_family`. pub(crate) font_family_bold: Option<String>, pub(crate) font_family_italic: Option<String>, - /// Currently-applied OpenType features for terminal fonts. `None` means the - /// terminal-safe default (ligatures disabled). pub(crate) font_features: Option<gpui::FontFeatures>, - /// Currently-applied terminal-emulator defaults. Tracked so hot-reload can - /// push only the alacritty-backed options that actually changed. terminal_cursor_style: ConfigCursorStyle, terminal_scrollback_limit: usize, - /// Keeps the `observe_global::<Config>` subscription alive for the app's - /// lifetime so external edits to `config.json` (swapped in by the watcher in - /// `main.rs`) live-apply font size / line height / family. Never read. _config_watch: Subscription, - /// Keeps the keystroke interceptor alive: any real keypress cancels the - /// held-⌘/Ctrl tab badges (and any pending reveal), so a chord like ⌘C - /// never shows them — only a bare hold does. An *interceptor* (fires - /// pre-dispatch) rather than an observer because the terminal consumes - /// most keys with `stop_propagation`, which suppresses observers. Never read. _keystroke_watch: Subscription, - /// Keeps the window-activation observer alive: any active-status flip also - /// cancels the badges. Deactivating mid-hold (⌘-Tab, Spotlight, a click - /// into another app) sends the modifier *release* to whatever app is key - /// by then — this window never gets that `ModifiersChanged`, so without - /// this the badges stuck on until some later keypress. Never read. _activation_watch: Subscription, - /// Keeps the `observe_global::<GitStatusCache>` subscription alive: a git - /// probe landing (from *any* pane) repaints the sidebar, so every row in - /// the same repo shows the just-refreshed branch/diff line, not a stale - /// per-row copy. Never read. _git_status_watch: Subscription, - /// Keeps the `observe_global::<PaneLivenessCache>` subscription alive: a - /// machine's answer about which panes it still has lands on a background - /// task, so nothing in this window would otherwise redraw the picker row or - /// menu row that asked for it. Never read. _pane_liveness_watch: Subscription, - /// Keeps the window-appearance observer alive: while - /// `Config::theme_follow_system` is on, an OS light/dark flip re-resolves - /// the theme slot and repaints. Never read. _appearance_watch: Subscription, - /// `Some` while the command palette overlay is open; `None` when closed. - /// The view owns its search input, filtered list and keyboard handling and - /// emits a `PaletteEvent`; we build the catalog and run the chosen command. palette: Option<Entity<PaletteView>>, - /// Keeps the open palette's event subscription alive; dropped on close. palette_sub: Option<Subscription>, - /// Stack of recently closed tabs (most recent on top) for Cmd+Shift+T. - /// Stored serialized so each entry carries the panes' cwd + name at close. - /// `pub(crate)` so the home page can surface the top entry as its - /// "reopen what you just closed" hint. pub(crate) closed: Vec<SessionTab>, - /// `Some` while a tab label is being renamed inline; `None` otherwise. pub(crate) renaming: Option<Renaming>, - /// `Some` while the "New Worktree Tab" sheet is open (see - /// `ui::worktree_prompt`); `None` otherwise. pub(crate) worktree_prompt: Option<crate::ui::worktree_prompt::WorktreePrompt>, - /// When `Some`, the active tab renders only this one leaf full-window - /// (Cmd+Shift+Enter maximize). Cleared on any structural / navigation change. pub(crate) maximized: Option<Entity<TerminalView>>, - /// Whether the tab chips currently show their ⌘1…⌘9 switch badges - /// (shown while bare ⌘/Ctrl is held; see `hints::on_modifiers_changed`). pub(crate) mod_hint_badges: bool, - /// Generation counter for the delayed badge reveal: bumped on every - /// modifier transition and keypress so a stale timer can't fire. pub(crate) mod_hint_gen: u64, - /// Generation counter for the keybinding-capture commit timer: bumped on - /// every captured chord, cancel, and start, so a stale pause-to-commit - /// timer can't fire after the sequence changed or capture ended. record_gen: u64, - /// Focus target for the home page (the zero-tab state; see `ui::home`). - /// 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, - /// The shells of the machine **this window is bound to**, listed in the "+" - /// dropdown. Fetched once per machine off the UI thread — empty until that - /// lands (and while a remote machine is unreachable), when the dropdown - /// offers just the default entry. - /// - /// Not "this computer's shells": a remote workspace's window opens its tabs - /// on the far machine, and a menu built here would offer paths that only - /// exist locally. See [`Tty7App::refresh_shells`]. pub(crate) shells: ShellInventory, - /// Which machine [`Self::shells`] describes, so a landing fetch for a - /// machine the window has since left can be discarded, and so the menu knows - /// whether the local config's `shell` override applies to it. pub(crate) shells_host: HostId, - /// Pane-contextual SSH loopback forward UI state. The controls render only - /// over the active SSH pane, but the input/editing state is app-owned so it - /// is not tied to the Settings tab. pub(crate) loopback_panel: LoopbackForwardPanelState, - /// Pane-contextual SFTP file panel (WS5), bound to a focused native-SSH pane. pub(crate) sftp_panel: crate::ui::sftp::SftpPanelState, - /// Right detail panel (info / changes / files) docked beside the terminal. pub(crate) right_panel: crate::ui::right_panel::RightPanelState, - /// Repositories with a `git diff HEAD` probe in flight, keyed by machine - /// *and* working directory — the same path on two hosts is two different - /// work trees. One probe answers everyone: the diff overlay on any number of - /// tabs and the Changes panel all read the same result, instead of each - /// running its own copy of the same invocation and parse. See - /// [`Tty7App::spawn_shared_diff_probe`](crate::ui::app::Tty7App::spawn_shared_diff_probe). pub(crate) diff_probes_inflight: std::collections::HashSet<(crate::ui::host_ops::HostId, std::path::PathBuf)>, - /// Repositories whose in-flight probe was already stale when someone asked - /// again, so it has to be re-run the moment that one lands. - /// - /// Deduping by repo is what makes one `git diff` answer every watcher, but a - /// probe describes the tree at the moment it *started*. A refresh triggered - /// after that — a command finished, an agent turn ended — folds into the - /// running probe and would otherwise be answered with a snapshot already - /// known to be out of date, with nothing left to trigger another look: the - /// overlay re-checks only on a `GitStatusCache` change, and that one has - /// been spent. See - /// [`Tty7App::spawn_shared_diff_probe`](crate::ui::app::Tty7App::spawn_shared_diff_probe). pub(crate) diff_probes_restale: std::collections::HashSet<(crate::ui::host_ops::HostId, std::path::PathBuf)>, - /// Local project file tree (left column of the body). pub(crate) file_tree: crate::ui::file_tree::FileTreeState, - /// Code-editor panel (right column of the body). pub(crate) editor: crate::ui::code_editor::EditorPanelState, - /// Vertical tab sidebar width (px), held in a shared `Cell` so the resize - /// drag's window-level mouse listener can mutate it without the entity handle - /// (mirrors the split divider's `ratio`). Seeded from `Config::sidebar_width` - /// and persisted back when a drag ends. pub(crate) sidebar_width: Rc<Cell<f32>>, - /// Whether the sidebar's resize handle is currently held. pub(crate) sidebar_dragging: Rc<Cell<bool>>, - /// Right detail panel width (px) and drag state, held in shared `Cell`s for - /// exactly the reason `sidebar_width` is — see there. pub(crate) right_panel_width: Rc<Cell<f32>>, pub(crate) right_panel_dragging: Rc<Cell<bool>>, - /// Which chrome this *window* is showing: is the detail panel docked open, - /// which of its tabs is selected, and is the tab rail collapsed. - /// - /// Window-level rather than `Config`, which is a global: with one window the - /// two were indistinguishable, but with several, reading the config meant - /// opening the detail panel in one window opened it in every other one too. - /// A window is a *view* — what it has on screen is its own. The config - /// fields of the same names survive as what a newly opened window starts - /// with, written back on each toggle so a new window (and the next launch) - /// inherits the last thing the user actually chose. pub(crate) right_panel_visible: bool, pub(crate) right_panel_tab: RightPanelTab, pub(crate) sidebar_collapsed: bool, - /// Scroll handle for the sidebar's row list, so activating a tab scrolls its - /// row into view — and so the rail's overlay scrollbar has an offset to - /// track and drag (see [`crate::ui::scrollbar`]). pub(crate) sidebar_scroll: gpui::ScrollHandle, - /// `Some` while a tab / group is being dragged to a new position, in either - /// the strip or the rail: the frozen geometry the live preview reflow is - /// computed against (see [`crate::ui::reorder`]). Shared by `Rc` because the - /// `on_drag` that opens it only gets `&mut App`, not the entity. Cleared on - /// the first frame after gpui ends the drag. pub(crate) reorder: Rc<RefCell<Option<crate::ui::reorder::Reorder>>>, - /// Filter box in the sidebar's top control bar ("Search tabs…"); its text - /// narrows the visible rows by fuzzy-ish substring match on the tab label. pub(crate) sidebar_search: Entity<InputState>, - /// Live filter for the detail panel's Files tab (its own box, so filtering - /// the tree never disturbs the tab list's filter and vice versa). pub(crate) file_search: Entity<InputState>, - /// Re-renders the sidebar on each search keystroke so results narrow live. _sidebar_search_sub: Subscription, _file_search_sub: Subscription, - /// `Some` while the settings page is open. Settings is a full-window overlay - /// (not a tab), so it covers the tab rail / title bar and never clutters the - /// tab list. Holds all the settings widget state + its subscriptions. settings: Option<SettingsState>, - /// In-pane native-SSH auth / host-key sheet state (WS3). Holds the active - /// prompt (keyed to the pane that raised it), its input widgets, and - /// dismissable banners. Empty when no prompt is pending. pub(crate) ssh_prompt: crate::ui::ssh_prompt::SshPromptState, - /// In-pane "confirm close of a live SSH session" state (PRD FR-E3): the close - /// action awaiting confirmation, or `None` when no prompt is up. pub(crate) ssh_close_confirm: Option<SshCloseKind>, - /// Latest window geometry (the restore bounds while fullscreen), kept - /// current by a bounds observer so the quit hook can persist it to - /// `window.json` — at quit time no `&Window` is in reach to ask directly. window_bounds: Bounds<Pixels>, - /// Which persistent workspace this window is showing. The window is the - /// transient view; the workspace is the identity that survives closing it - /// and shows up in the home-page picker. Every `save_session` writes back - /// under this id, so two windows never overwrite each other's tabs. pub(crate) workspace: WorkspaceId, - /// `Some` while the title-bar workspace chip is being renamed inline. - /// Separate from `renaming` (tabs) because the two live in different - /// widgets and can't be in flight at once anyway. pub(crate) workspace_rename: Option<WorkspaceRename>, - /// Last title pushed to the OS window, so the common case (nothing - /// changed) skips the platform call. `RefCell` because the sync runs from - /// `focus_active`, which only takes `&self`. window_title: std::cell::RefCell<String>, - /// The home page's "Connect to Host" flow, or `None` when it isn't running - ///. Lives on the window rather than on the app because a - /// window is what a remote workspace ends up bound to — two windows can be - /// reaching two different machines at once. pub(crate) connect: Option<crate::ui::remote_workspace::ConnectFlow>, - /// The workspace switcher overlay, or `None` when it is closed. pub(crate) switcher: Option<crate::ui::switcher::Switcher>, - /// What each machine's handshake reported, kept past the connect flow so - /// the switcher can show several connected machines at once (the flow only - /// ever holds one). pub(crate) host_snapshots: std::collections::HashMap< crate::ui::host_registry::HostId, crate::ui::switcher::HostSnapshot, >, } -/// Which close action a live-SSH close-confirmation is gating (PRD FR-E3). #[derive(Clone, Copy, PartialEq, Eq)] pub(crate) enum SshCloseKind { - /// Close the whole tab at this index. Tab(usize), - /// Close the focused pane. Pane, } -/// Where a forked agent session lands. The placement is not a preference but a -/// consequence of *where the user asked from* (issue #211): a pane-level ask is -/// spatial, so the pane menu offers the four directions; a tab-level ask is -/// not, so the tab menu — and the bare action behind the palette / menu bar — -/// opens a new tab with no placement question. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub(crate) enum ForkPlacement { NewTab, - /// Split the source pane along `axis`, the fork taking the second slot — - /// or the first when `before`, which is Split Left / Split Up. - Split { - axis: Axis, - before: bool, - }, + Split { axis: Axis, before: bool }, } -/// What the agent-session menu rows need to know about a tab, read at -/// menu-open time (like `tab_cwd`) so enablement can't go stale between render -/// and click. pub(crate) struct TabAgentSession { - /// The fork row's label, or `None` when tty7 has no verified fork command - /// for this agent — then no fork row is offered at all, rather than a - /// disabled one promising a capability that doesn't exist. pub(crate) fork_label: Option<&'static str>, - /// The agent's native session id, absent until its hooks report one. pub(crate) session_id: Option<String>, - /// A remote pane. Forking shells a *local* agent binary, which would branch - /// the wrong machine's session, so the row disables there. pub(crate) remote: bool, } impl TabAgentSession { - /// Whether a fork can actually run right now: the agent has a fork command, - /// tty7 has seen its session id, and the pane is local. pub(crate) fn forkable(&self) -> bool { self.fork_label.is_some() && self.session_id.is_some() && !self.remote } } impl Tty7App { - /// A window on `id`'s workspace — reopening one from the picker — or on a - /// fresh workspace when `id` is `None` (New Workspace) or names a workspace - /// that is no longer on file. pub fn for_workspace( id: Option<WorkspaceId>, window: &mut Window, cx: &mut Context<Self>, ) -> Self { - // Claiming marks the workspace open; the store stays the single - // writer of the view file. let restore = cx.global::<Config>().restore_session; let known = id.is_some_and(|id| WorkspaceStore::all(cx).get(id).is_some()); let workspace = WorkspaceStore::claim(cx, id); - // A workspace's layout lives in its machine's tree, so a restore - // *asks* rather than reads: the window opens empty and - // `hydrate_window_from_tree` rebuilds it the moment the pull answers — - // against the local daemon that is milliseconds, so the empty state is - // effectively one frame; against a remote machine it is however long - // the link takes, which is the shape remote windows always had. A - // remote machine still unreachable when the hydration gives up is - // re-hydrated by the supervisor's reconnect. let is_remote = WorkspaceStore::all(cx) .get(workspace) .is_some_and(|w| w.is_remote()); - // A remote workspace hydrates even with restore off: its panes are - // running sessions on another machine, not a saved layout. let hydrate = known && (restore || is_remote); - // A brand-new workspace takes the first-run path in `with_session` - // (`None`), spawning a single default terminal — what `New Workspace` - // and a first run both want. A known workspace opens on an empty - // session that the hydration fills, or on a fresh shell when the user - // turned restore off. let session = hydrate.then(Session::default); let app = Self::with_session(Some(workspace), session, window, cx); if hydrate { - // No immediate save: the window is deliberately empty, and racing - // the pull with a diff that reads as "close everything" is exactly - // what the informed gate exists to prevent. crate::ui::tree_sync::hydrate_window_from_tree(cx, workspace); } else { - // A local window that skipped hydration shows what the user chose - // (a fresh shell, restore off): its state is the intended layout, - // and its sync may speak for the whole tree. A remote window that - // lands here has *not* seen its machine's tree yet, so it stays - // additive until a hydration informs it. if !is_remote { crate::ui::tree_sync::mark_window_informed(cx, workspace); } - // Persist right away. The leaves just spawned (or reattached) now - // carry daemon pane ids, and nothing else writes them until the - // next *structural* change — so a crash before the user happens to - // open a tab would strand every one of those panes in the daemon. app.save_session(cx); } - // If startup reused a daemon that speaks a different wire protocol - // (an app upgrade while the old service kept running), the sessions - // just restored above are living on that old dialect. Surface the - // keep-or-restart choice now that there's a window to ask in. Self::prompt_daemon_version_mismatch(window, cx); - // The same question for any *remote* server this client already found - // at a different build, and the consent handler that the install path - // asks before writing a binary onto someone else's machine. crate::ui::remote_connect::register(cx); - // Enumerate this computer's WSL distributions once at startup so the - // first switcher open already lists them, rather than filling them in a - // beat later. Backgrounded; a no-op off Windows. crate::ui::remote_connect::sweep_wsl(cx); Self::prompt_remote_daemon_mismatch(window, cx); - // A window that came back on a remote workspace has its last-pulled - // layout but no connection yet. Reconnecting is M6's; this is the seam - // it hooks into, and the reason nothing on the launch path had to learn - // whether a workspace is local. app.reopen_remote_at_startup(cx); app } - /// Ask what to do about a protocol-mismatched daemon that - /// `spawn::ensure_running` deliberately left running (rather than silently - /// killing every persisted session at startup): keep using it — sessions - /// survive, features whose wire shape changed may misbehave — or restart - /// the service clean via the shared - /// [`restart_daemon_confirmed`](Self::restart_daemon_confirmed) path - /// (tabs reopen with fresh shells). Keeping is the default: dismissing - /// the prompt changes nothing. fn prompt_daemon_version_mismatch(window: &mut Window, cx: &mut Context<Self>) { let Some(mismatch) = crate::daemon::spawn::take_mismatched_daemon() else { return; @@ -1038,9 +486,6 @@ impl Tty7App { running in them is terminated." .to_string(), }; - // Phrased as the question it is, like every other prompt in the app — - // this one used to be a bare statement of fact with two verbs under it. - // The version details it used to carry in the title are in the body. let answer = window.prompt( PromptLevel::Warning, "Restart Daemon?", @@ -1049,8 +494,6 @@ impl Tty7App { cx, ); cx.spawn(async move |this, cx| { - // Index 1 == "Restart"; "Keep Sessions" or a dismissed prompt leave - // the old daemon (and every session) untouched. if !matches!(answer.await, Ok(1)) { return; } @@ -1059,26 +502,14 @@ impl Tty7App { .detach(); } - /// The whole constructor behind `new`, with the saved session injected - /// instead of read from disk. The headless tests build the app through - /// this seam (a zero-tab session → the home page, no terminal spawned) - /// so every subscription and window hook runs exactly as in production - /// without touching `~/.config` or a daemon. pub(crate) fn with_session( workspace: Option<WorkspaceId>, session: Option<Session>, window: &mut Window, cx: &mut Context<Self>, ) -> Self { - // Tests build a window without going through the store; give them a - // detached identity rather than requiring the global to be installed. let workspace = workspace.unwrap_or_default(); - // The route every pane this window opens will take. Resolved once, here, - // rather than per pane: the window's machine cannot change under it, and - // a per-pane lookup is a per-pane chance to disagree. `None` for a local - // workspace, which is every window that existed before M5. let pane_ws = crate::ui::remote_workspace::pane_workspace_for(cx, workspace); - // Font size from config (borrow ends before the mutable theme apply). let ( font_size, line_height, @@ -1106,7 +537,6 @@ impl Tty7App { let sftp_panel = crate::ui::sftp::SftpPanelState::new(window, cx); let file_tree = crate::ui::file_tree::FileTreeState::new(window, cx); let editor = crate::ui::code_editor::EditorPanelState::new(window, cx); - // Managed-forward add-form inputs (native-SSH panes). let mf_bind_host = cx.new(|cx| InputState::new(window, cx).default_value("127.0.0.1")); let mf_bind_port = cx.new(|cx| InputState::new(window, cx).placeholder("8080")); let mf_target_host = cx.new(|cx| InputState::new(window, cx).placeholder("127.0.0.1")); @@ -1114,111 +544,52 @@ impl Tty7App { let mf_description = cx.new(|cx| InputState::new(window, cx).placeholder("description")); let sidebar_width = cx.global::<Config>().sidebar_width; let right_panel_width = cx.global::<Config>().right_panel_width; - // The config's copies are this window's *starting* chrome; from here on - // the window owns them (see the fields' doc comment). let right_panel_visible = cx.global::<Config>().right_panel_visible; let right_panel_tab = cx.global::<Config>().right_panel_tab; let sidebar_collapsed = cx.global::<Config>().sidebar_collapsed; - // Live-apply hot-reloaded config: the watcher in `main.rs` swaps the - // `Config` global on every `config.json` change, which fires this. The - // window-aware variant so the reload can re-run `apply_theme` with the - // window (blur flip, traffic-light pinning) and re-sync the Appearance - // opacity slider — the watcher task itself has no window handle. let config_watch = cx.observe_global_in::<Config>(window, |this, window, cx| { this.reload_from_config(window, cx) }); - // Repaint when any pane's git probe lands in the shared cache — the - // sidebar's branch/diff lines read from it, and the probing pane's own - // notify wouldn't re-render rows belonging to *other* panes. The open - // diff overlay rides the same signal: if the landed numbers disagree - // with what it shows, it re-probes the full diff. cx.default_global::<crate::terminal::git_status::GitStatusCache>(); let git_status_watch = cx.observe_global::<crate::terminal::git_status::GitStatusCache>(|this, cx| { this.maybe_refresh_diff_overlay(cx); - // Same trigger, same freshness: the right panel's Changes list is - // the sidebar's `+N −M` expanded, so it re-probes whenever those - // numbers do rather than going stale behind them. this.right_panel_refresh_changes(cx); cx.notify(); }); - // The same shape for pane liveness: the picker and the workspace menu - // read a per-machine cache that is filled off the UI thread, so the - // frame that *asked* is long gone by the time an answer lands and - // nothing else would repaint it. cx.default_global::<crate::terminal::pane_liveness::PaneLivenessCache>(); let pane_liveness_watch = cx .observe_global::<crate::terminal::pane_liveness::PaneLivenessCache>(|_this, cx| { cx.notify(); }); - // Any real keypress means "chord, not a bare hold": cancel the held-⌘ - // tab badges and whatever reveal is pending (see `ui::hints`). let this = cx.weak_entity(); let keystroke_watch = cx.intercept_keystrokes(move |_ev, _window, cx| { let _ = this.update(cx, |this, cx| this.dismiss_mod_hint(cx)); }); - // Losing key status mid-hold (⌘-Tab, Spotlight, a click into another - // app) means the modifier release is delivered elsewhere and never - // reaches this window — the activation flip is the only signal left, - // so treat it like a release. Dismissing on *both* flips also keeps a - // reveal scheduled just before the switch from popping the badges up - // in a window the user already left. let activation_watch = cx.observe_window_activation(window, |this, window, cx| { this.dismiss_mod_hint(cx); - // The panes' link-modifier tracking loses the release the same - // way, and a stale "⌘ held" is worse than missing badges: a - // plain unmodified click would open links. Treat the flip as a - // release; holding ⌘ again re-arms it via `on_modifiers_changed`. this.set_link_modifier(false, cx); - // Coming back is the only cue we get that the working tree may - // have moved while the user was elsewhere: an edit in another - // editor, a `git` command in another app, an agent in another - // window. None of those reach a pane's poll loop, so without this - // the sidebar's `+N −N` would keep showing pre-alt-tab numbers - // until the user happened to run a command in the pane. if window.is_window_active() { - // Whichever window the user last brought forward is the one to - // focus on the next launch — `claim` only ever records the - // *last opened* workspace, which is a different thing. WorkspaceStore::focus(cx, this.workspace); this.refresh_git_status_all(cx); } }); - // Follow OS light/dark flips live: while "sync with system" is on, an - // appearance change re-resolves the theme slot and repaints. While it's - // off the appearance only ever changes because `apply_theme` pinned it - // to the theme — skip, or the pin would re-trigger a redundant apply. let this = cx.weak_entity(); let appearance_watch = window.observe_window_appearance(move |window, cx| { - // This is the only place the OS flip is observed, so cache it here — - // from the *window*, never `cx.window_appearance()`, which would - // re-enter gpui's already-borrowed Linux client and panic. Before the - // early return, so a flip that happens while following is off still - // lands. See `ui::theme::SystemAppearance`. crate::ui::theme::note_system_appearance(window, cx); if !cx.global::<Config>().theme_follow_system { return; } apply_theme(Some(window), cx); let _ = this.update(cx, |this, cx| { - // The editor targets the on-screen theme, and with no global - // override the opacity slider follows it — keep both in step. this.rebuild_theme_editor(window, cx); this.sync_window_opacity_slider(window, cx); cx.notify(); }); }); - // Paint the configured color theme (defaults to a light one) and build - // the menu bar. apply_theme(Some(window), cx); set_menus(cx); - // A session with zero tabs is a real state — the user quit from - // the home page — and restores back to it; only a *missing/unreadable* - // session (first run) falls back to spawning a default terminal. let (tabs, active) = match session { - // First run (no session file): the very first terminal has no - // predecessor to inherit from, so start in the app's current - // directory (None → default behavior). None => match new_terminal( pane_ws.clone(), Some(workspace), @@ -1230,21 +601,13 @@ impl Tty7App { cx, ) { Ok(first) => (vec![Tab::new(Pane::leaf(first))], 0), - // The daemon we just tried to start isn't answering. A window - // with no tabs is a legal state (it shows the home page), and - // far better than taking the launch down over it. Err(e) => { log::error!("first terminal failed to start: {e}"); (Vec::new(), 0) } }, - // A saved session (with tabs, or an empty home-page state): rebuild it - // the same way a daemon restart does. some => tabs_from_session(pane_ws.as_ref(), workspace, some, font_size, window, cx), }; - // Sidebar tab filter. Each keystroke re-renders the (cheap) row list so - // results narrow as you type — the same live-filter wiring the theme - // picker uses. let sidebar_search = cx.new(|cx| InputState::new(window, cx).placeholder("Search tabs…")); let sidebar_search_sub = cx.subscribe_in(&sidebar_search, window, |_this, _i, ev, _w, cx| { @@ -1328,63 +691,22 @@ impl Tty7App { switcher: None, host_snapshots: std::collections::HashMap::new(), }; - // Bring the system tray up (icon + agent menu + poll loop) — but only - // for the *first* window: the tray is one app-wide icon, and letting - // every window register its own would stack N icons in the status bar. - // `register` happens after `open_window` returns, so during the first - // window's construction the registry is still empty. - // - // Skipped in tests: the headless harness has no native status bar to - // register with, and the poll task would just spin against the mocked - // clock. if !cfg!(test) && crate::ui::windows::WindowRegistry::count(cx) == 0 { crate::ui::tray::init(cx); } - // Fill the "+" dropdown from the machine this window is bound to. app.refresh_shells(cx); - // 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 - // user actually left off. The callback gets the live `Tty7App`, reads - // every pane's current cwd, and writes the file synchronously; the empty - // future just satisfies the hook's async signature. The subscription is - // detached to live for the app's lifetime (its weak handle keeps it safe - // after teardown). cx.on_app_quit(|app, cx| { app.save_session(cx); - // Also persist the window's final geometry so the next launch can - // reopen there (`remember_window_size`). Written unconditionally — - // startup gates on the config — so toggling the setting back on - // restores the most recent quit, not some stale pre-toggle state. crate::core::window_state::WindowState::from_bounds(app.window_bounds).save(); async move {} }) .detach(); - // Keep `window_bounds` tracking the live window: moves and resizes both - // fire this observer, and `window_bounds()` reports the *restore* bounds - // while fullscreen, so a fullscreen quit doesn't record a screen-sized - // window for the next normal launch. cx.observe_window_bounds(window, |this, window, _cx| { this.window_bounds = window.window_bounds().get_bounds(); }) .detach(); - // Closing a window *detaches* its workspace: the panes keep running in - // the daemon, and the workspace drops into the home-page picker to be - // reopened later. So closing one of several windows is cheap and needs - // no confirmation — the user can see the others and get this one back. - // - // The last window is different: closing it also quits the app (a - // windowless process left in the Dock no longer responds to being - // clicked — #147), so that one keeps the reassuring prompt by default. - // We veto the immediate close (return `false`), show it, and quit only - // if the user picks "Close"; a one-shot flag lets that post-confirm - // close through instead of looping the prompt. - // - // `confirm_window_close` turns the prompt off for users who have learned - // the model — it is teaching, not protection (⌘Q never asked), so it has - // to be escapable. let close_confirmed = std::rc::Rc::new(std::cell::Cell::new(false)); let weak_app = cx.weak_entity(); window.on_window_should_close(cx, move |window, cx| { @@ -1396,17 +718,12 @@ impl Tty7App { .upgrade() .is_some_and(|app| app.read(cx).tabs.is_empty()); - // Any window but the last, an empty one with nothing to reassure - // about, or a user who has turned the prompt off: detach and go. - // Prompting here would be friction. let confirm = cx.global::<Config>().confirm_window_close; if !last_window || empty || !confirm { if let Some(app) = weak_app.upgrade() { app.update(cx, |app, cx| app.detach_workspace(cx)); } if last_window { - // Deferred onto the next tick so the close itself completes - // first, same as the confirmed path below. cx.spawn(async move |cx| { let _ = cx.update(|cx| cx.quit()); }) @@ -1418,17 +735,6 @@ impl Tty7App { let answer = window.prompt( PromptLevel::Info, "Close Window?", - // What this promises has to match what the next launch does. - // Closing the last window *detaches* its workspace rather than - // ending it: the panes keep running in the daemon, but tty7 - // comes back on the home page with the workspace waiting in the - // picker — it no longer reopens it unasked, so promising it - // would be restored would be a promise the app doesn't keep. - // - // Points at the title bar's workspace menu, not the macOS - // Window menu: there is no menu bar on Windows or Linux, and - // the corner chip is the one place that lists workspaces on - // every platform. Some( "Your sessions keep running in the background. This \ workspace will be waiting on the home page, and in the \ @@ -1441,8 +747,6 @@ impl Tty7App { let close_confirmed = close_confirmed.clone(); let weak_app = weak_app.clone(); cx.spawn(async move |cx| { - // Index 1 == "Close"; index 0 (Cancel) and a dismissed prompt - // both leave the window open. if let Ok(1) = answer.await { close_confirmed.set(true); let _ = cx.update(|cx| { @@ -1461,19 +765,7 @@ impl Tty7App { app } - /// Push this window's structure to its machine's tree (and its geometry to - /// the view file). Called after every structural change — the name - /// predates the tree migration, and it remains the single funnel. pub(crate) fn save_session(&self, cx: &mut App) { - // Tripwire for the write this sync must never make: a pane created - // for one workspace being recorded under another. Each view remembers - // the workspace whose window created it; if that and the id this save - // records under have come apart, the window's tabs and its identity - // are describing two different workspaces — the exact corruption that - // once copied one workspace's whole layout into another's record and - // resumed its agents twice. Shout with everything a bug report needs; - // the save still runs, because refusing it would silently stop - // persisting the user's layout on the strength of one tripped check. for view in self.tabs.iter().flat_map(|tab| tab.pane.terminals()) { let Some(owner) = view.read(cx).owner_workspace() else { continue; @@ -1488,10 +780,6 @@ impl Tty7App { ); } } - // The layout goes nowhere near the view file: the machine that owns it - // hears about the change as the semantic operations it amounts to, - // local and remote alike. What this client persists is only the - // geometry, ridden on the same funnel so reopening lands where we are. WorkspaceStore::record_geometry( cx, self.workspace, @@ -1500,35 +788,13 @@ impl Tty7App { crate::ui::tree_sync::sync_window(self, cx); } - /// This window is going away: capture its final state (a plain `cd` may - /// have moved a pane's cwd with no structural change to trigger a save), - /// mark the workspace closed so the home-page picker lists it, and drop it - /// from the registry so "is this the last window?" stays accurate. - /// - /// A *detach*, not a teardown — the daemon panes keep running and reattach - /// when the workspace is reopened. pub(crate) fn detach_workspace(&self, cx: &mut App) { self.save_session(cx); - // An empty workspace has nothing to come back to, so it is dropped - // outright instead of accumulating as a blank row in the picker — - // every `New Workspace` the user closes without using would leave one. - // - // Unless the emptiness is *this client's* ignorance rather than the - // machine's answer. Every window opens empty and waits for its tree - // pull, so a window opened while the box was asleep and then closed — - // there was nothing in it to work on — would take the entry with it: - // its `RemoteRef` and its geometry, while its panes are still running - // over there. Nothing would reconnect it and nothing would offer it - // again; the only way back is re-adding the machine by hand. let answered = WorkspaceStore::machine_is_connected(cx, self.workspace); if self.tabs.is_empty() && answered && crate::ui::tree_sync::window_is_informed(cx, self.workspace) { - // Same as the picker swap: an empty workspace being dropped takes - // its (empty) tree on the machine with it. Only an informed window - // may say so — one still waiting on its hydration is empty because - // the pull has not answered, not because the workspace is. crate::ui::tree_sync::fire_workspace_op(cx, self.workspace, |ws| { tty7_core::daemon::control::ControlRequest::WorkspaceRemove { workspace: ws } }); @@ -1537,21 +803,10 @@ impl Tty7App { WorkspaceStore::close_window(cx, self.workspace); } crate::ui::windows::WindowRegistry::unregister(cx, self.workspace); - // The window's tree-sync bookkeeping goes with the window; the - // machine's tree itself keeps the workspace, which is the detach. crate::ui::tree_sync::forget(cx, self.workspace); - // The workspace just moved from "on screen" to "detached" — the Window - // menu is the only place that says so. crate::ui::windows::refresh_menu(cx); } - /// The other half: a workspace's forwards belong to the workspace, - /// so stopping it has to end them — nothing else will. A pane's forwards - /// need no equivalent; the daemon drops those with the pane. - /// - /// Best effort and silent. The window is on its way out either way, and a - /// forward that could not be torn down (the connection already dropped) is - /// already gone with the connection that carried it. pub(crate) fn teardown_workspace_forwards(&self, cx: &gpui::App) { let Some(route) = self .tabs @@ -1568,13 +823,6 @@ impl Tty7App { else { return; }; - // Off the UI thread. `teardown` dials the daemon, which resolves the - // workspace's SSH connection and waits for the server to acknowledge a - // `cancel_tcpip_forward` — on a machine that has gone unreachable, which - // is exactly when someone reaches for Stop Workspace, that never comes - // back inside the request timeout. `ForwardRoute::list` is already - // backgrounded for the same reason; this was the one that was not, and - // it ran while the window was being torn down. cx.background_executor() .spawn(async move { let left = route.teardown(); @@ -1585,14 +833,6 @@ impl Tty7App { .detach(); } - /// Stop a workspace — kill its sessions and close its window — confirming - /// first when something is still running. Its layout stays on file, so it - /// can be started again later. - /// - /// Deliberately not called "close": the red traffic light closes a window - /// and only detaches, while this ends the shells. Two actions that sit near - /// each other need two different verbs, or the menu reads as if they were - /// variations on one thing. pub(crate) fn stop_workspace( &mut self, id: WorkspaceId, @@ -1603,7 +843,6 @@ impl Tty7App { cx.notify(); } - /// Delete a workspace: stop it *and* discard the saved layout. pub(crate) fn delete_workspace( &mut self, id: WorkspaceId, @@ -1614,9 +853,6 @@ impl Tty7App { cx.notify(); } - /// Show the workspace in the Window menu's slot `index`. A stale slot (the - /// menu was built before a workspace was stopped) is a no-op rather than an - /// error — the menu is rebuilt right after any such change anyway. pub(crate) fn select_workspace_slot( &mut self, index: usize, @@ -1629,15 +865,6 @@ impl Tty7App { self.reveal_workspace(id, window, cx); } - /// Show `id`'s workspace. - /// - /// One workspace is shown by exactly one window, so this either focuses the - /// window it already has or opens a new one for it — never swaps it into - /// *this* window, which would leave the workspace already here without one. - /// - /// The single exception is a window that is empty (the home page): reusing - /// it beats opening a second window and stranding a blank frame, and there - /// is no workspace to displace. pub(crate) fn reveal_workspace( &mut self, id: WorkspaceId, @@ -1655,12 +882,6 @@ impl Tty7App { } } - /// Swap this window over to `id`'s workspace in place, rebuilding its tabs. - /// - /// This is what the home-page picker does: the window running it is empty - /// (the picker only shows on the home page), so opening a *second* window - /// would strand this blank one. The outgoing workspace is dropped rather - /// than detached for the same reason. pub(crate) fn switch_workspace( &mut self, id: WorkspaceId, @@ -1671,14 +892,7 @@ impl Tty7App { if previous == id { return; } - // Only an *informed* empty window proves the workspace is blank: one - // still waiting on its hydration is empty because the pull has not - // answered, and dropping the workspace then would delete a populated - // tree on the strength of our own ignorance. if self.tabs.is_empty() && crate::ui::tree_sync::window_is_informed(cx, previous) { - // Dropping the blank workspace here, so the machine's tree drops - // its (equally blank) copy — otherwise every visit to the picker - // would leave an empty workspace behind on the daemon. crate::ui::tree_sync::fire_workspace_op(cx, previous, |ws| { tty7_core::daemon::control::ControlRequest::WorkspaceRemove { workspace: ws } }); @@ -1693,25 +907,11 @@ impl Tty7App { let claimed = WorkspaceStore::claim(cx, Some(id)); crate::ui::windows::WindowRegistry::rebind(cx, previous, claimed); - // A pick from the switcher is one of the ways a remote workspace comes - // back, so it owes the supervisor the same call the launch path makes — - // see `RemoteLinks::supervise` for what skipping it leaves on screen. - // The outgoing workspace needs no counterpart: `pump_tick` drops a - // machine the moment its last open workspace goes. crate::ui::remote_workspace::RemoteLinks::supervise(cx, claimed); - // The machine's tree is the layout's only home now, so an explicit - // pick from the switcher always hydrates — restore-off governs what - // *launch* comes back to, not what a deliberate open shows. The window - // swaps to empty and the pull rebuilds it, for the local daemon within - // milliseconds. self.adopt_workspace(claimed, Session::default(), window, cx); crate::ui::tree_sync::hydrate_window_from_tree(cx, claimed); } - /// Take over an *already claimed* workspace: rebuild this window's tabs - /// from `session` and retitle it. Split from [`Self::switch_workspace`] - /// because `ui::windows::close_workspace` gets here having already - /// destroyed the outgoing workspace — there is nothing left to detach. pub(crate) fn adopt_workspace( &mut self, id: WorkspaceId, @@ -1721,11 +921,7 @@ impl Tty7App { ) { let previous_host = self.spawn_host(cx); self.workspace = id; - // The closed-tab stack is per *window* and survives a workspace swap, - // so it is the one thing that could carry a tab across machines. self.rebind_host(previous_host, cx); - // So does the "+" dropdown's shell list — and unlike the closed stack it - // is rebuilt rather than dropped, from the machine now in front of us. self.refresh_shells(cx); let font_size = self.font_size; let pane_ws = self.window_workspace(cx); @@ -1740,17 +936,12 @@ impl Tty7App { self.tabs = tabs; self.active = active; self.maximized = None; - // Same reason as `for_workspace`: capture the reattached/spawned pane - // ids now rather than waiting for a structural change. self.save_session(cx); crate::ui::windows::refresh_menu(cx); self.focus_active(window, cx); cx.notify(); } - /// Reopen the most recently closed tab (Cmd+Shift+T). Rebuilds its pane - /// tree (restoring each terminal's saved cwd), inserts it after the active - /// tab, and focuses it. No-op when the stack is empty. fn reopen_closed_tab(&mut self, window: &mut Window, cx: &mut Context<Self>) { let Some(st) = self.closed.pop() else { return; @@ -1766,14 +957,10 @@ impl Tty7App { window, cx, ) else { - // Nothing came back (an unreachable daemon). Put the entry back so - // the tab is still reopenable once the daemon is up again. window.push_notification("Could not reopen the tab: no terminal started", cx); self.closed.push(st); return; }; - // Leaving the current tab for the reopened one; snapshot its focused - // pane so switching back restores it (same as `activate`). self.remember_active_pane(window, cx); self.maximized = None; let insert_at = self.new_tab_insert_at(cx); @@ -1786,8 +973,6 @@ impl Tty7App { diff_overlay: None, code: None, overlay_top: OverlayTop::default(), - // Keep the group it had when closed — the row reappears where - // it lived instead of flashing through Scratch. sidebar_group: std::cell::RefCell::new(st.sidebar_group), tree_id: std::cell::Cell::new(tty7_core::core::machine::TabId::new()), }, @@ -1798,11 +983,6 @@ impl Tty7App { cx.notify(); } - // ── System tray (`ui::tray`) ──────────────────────────────────────────── - - /// Whether this window hosts the pane with `leaf_id`. The tray's reveal - /// carries a gpui entity id, which is unique app-wide, so this is how a - /// click finds the one window that can act on it. pub(crate) fn owns_leaf(&self, leaf_id: u64) -> bool { self.tabs.iter().any(|t| { t.pane @@ -1812,11 +992,6 @@ impl Tty7App { }) } - /// This window's agent panes, unsorted: brand name, status, and a "where" - /// line (cwd directory name + git branch). Unsorted because the tray is a - /// single icon for the whole app — it concatenates every window's rows and - /// sorts once, most urgent first, so the pane that needs the user tops the - /// menu. pub(crate) fn agent_rows(&self, cx: &App) -> Vec<crate::ui::tray::AgentRow> { use crate::core::cli_agent::AgentStatus; let mut agents = Vec::new(); @@ -1835,8 +1010,6 @@ impl Tty7App { let detail = match (dir, branch) { (Some(dir), Some(branch)) => format!("{dir} @ {branch}"), (Some(dir), None) => dir, - // No cwd yet (pane still spawning) — the agent name alone - // still identifies the row. (None, _) => String::new(), }; agents.push(crate::ui::tray::AgentRow { @@ -1850,8 +1023,6 @@ impl Tty7App { agents } - /// Apply a tray menu click. Runs on the foreground executor with the - /// window in hand (see `tray::init`'s action pump). pub(crate) fn handle_tray_action( &mut self, action: crate::ui::tray::TrayAction, @@ -1859,12 +1030,6 @@ impl Tty7App { cx: &mut Context<Self>, ) { use crate::ui::tray::TrayAction; - // Tray clicks arrive while another app is frontmost — that's the - // tray's whole premise. `activate_window` alone only orders our - // window front within the app (macOS: `makeKeyAndOrderFront:`); the - // *application* must also be activated or the reveal — and any - // window-modal prompt we show next — stays buried behind the app the - // user clicked from. fn surface_window(window: &mut Window, cx: &mut App) { cx.activate(true); window.activate_window(); @@ -1872,9 +1037,6 @@ impl Tty7App { match action { TrayAction::ShowWindow => surface_window(window, cx), TrayAction::RevealPane { leaf_id } => { - // Resolve the leaf against the *live* tree — the menu the user - // clicked may predate a tab close; a vanished pane is a no-op - // (the window still comes forward). let tab_ix = self.tabs.iter().position(|t| { t.pane .leaves() @@ -1883,11 +1045,6 @@ impl Tty7App { }); if let Some(ix) = tab_ix { self.activate(ix, window, cx); - // The reveal must actually show the pane: a sibling leaf - // maximized in this tab would otherwise keep the target - // off-screen while we hand it keyboard focus. The target - // itself staying maximized is fine — it's already the - // visible one. if self .maximized .as_ref() @@ -1917,26 +1074,14 @@ impl Tty7App { } TrayAction::CheckForUpdates => { surface_window(window, cx); - // Same path as the App menu's "Check for Updates…" — the tray - // used to carry its own copy of this, and was for a while the - // only place in the app offering the check at all. self.check_for_updates_now(window, cx); } - // Same as ⌘Q: sessions keep running in the daemon. TrayAction::Quit => cx.quit(), TrayAction::QuitStopSessions => self.quit_stop_sessions(window, cx), } } - /// Tray "Quit and Stop Daemon": confirm, shut the daemon down (which - /// hangs up every shell — the whole point of picking this over plain - /// quit), then quit. The stop runs off the UI thread; like - /// `--stop-daemon` it can take a beat while children get their grace - /// period. fn quit_stop_sessions(&mut self, window: &mut Window, cx: &mut Context<Self>) { - // The prompt is window-modal and the click came from the tray with - // another app frontmost — activate the app AND the window, or the - // user never sees the question. cx.activate(true); window.activate_window(); let answer = window.prompt( @@ -1952,7 +1097,6 @@ impl Tty7App { cx, ); cx.spawn(async move |_this, cx| { - // Index 1 == "Quit and Stop"; Cancel or a dismissed prompt do nothing. if !matches!(answer.await, Ok(1)) { return; } @@ -1963,16 +1107,6 @@ impl Tty7App { .detach(); } - /// The "Restart Daemon…" action: restart whichever daemon serves *this* - /// window. - /// - /// A remote window's shells live in another machine's `tty7-server`, and - /// [`restart_daemon`](Self::restart_daemon) is about this computer's. Running - /// it from a remote window ended every local session in every *other* window - /// and left the machine in front of the user untouched — a destructive button - /// that did nothing the label promised. So the action asks the window which - /// machine it is showing, and the local method keeps meaning the local daemon - /// (which is what the Settings button under "Daemon" says it does). pub(crate) fn restart_window_daemon(&mut self, window: &mut Window, cx: &mut Context<Self>) { let Some(remote) = WorkspaceStore::remote_ref(cx, self.workspace) else { self.restart_daemon(window, cx); @@ -1981,10 +1115,6 @@ impl Tty7App { let target = remote.target.clone(); let label = crate::ui::remote_connect::label_for(&target, cx); if !target.is_ssh() { - // Nothing to restart *over there*: this client starts the server on - // those machines itself. Said rather than silently falling back to - // restarting the local daemon, which would end sessions on a machine - // the user was not looking at. window.push_notification( format!( "tty7 can only restart the server on machines it reaches over SSH. \ @@ -1997,20 +1127,6 @@ impl Tty7App { self.confirm_restart_remote_server(target, label, window, cx); } - /// Restart the persistent background daemon **on this computer**: shut the - /// running one down (which stops every live shell) and bring a fresh one up, - /// then rebuild the tabs from the just-saved session so the layout returns - /// with fresh shells. - /// - /// A general escape hatch for the otherwise invisible, always-on daemon: - /// picking up a macOS permission granted after it started (Full Disk Access - /// and the like only reach it on a fresh process), recovering if it wedges, or - /// just starting from a clean slate — none of which quitting/reopening the GUI - /// achieves, since that leaves the detached daemon untouched. Guarded by a - /// confirmation because it ends running sessions. The shutdown + respawn runs - /// off the UI thread (the daemon hangs up each child with a short grace, so it - /// can take a beat); the tab rebuild hops back to the main thread, where it has - /// the `Window`. pub(crate) fn restart_daemon(&mut self, window: &mut Window, cx: &mut Context<Self>) { let answer = window.prompt( PromptLevel::Warning, @@ -2024,8 +1140,6 @@ impl Tty7App { cx, ); cx.spawn(async move |this, cx| { - // Index 1 == "Restart"; Cancel or a dismissed prompt leave everything - // running untouched. if !matches!(answer.await, Ok(1)) { return; } @@ -2034,16 +1148,8 @@ impl Tty7App { .detach(); } - /// The restart itself, past any confirmation — shared by - /// [`restart_daemon`](Self::restart_daemon)'s prompt and the startup - /// version-mismatch prompt - /// ([`prompt_daemon_version_mismatch`](Self::prompt_daemon_version_mismatch)). fn restart_daemon_confirmed(&mut self, cx: &mut Context<Self>) { cx.spawn(async move |this, cx| { - // Persist the current layout + cwds, then tear the live terminals down - // *before* the daemon dies: dropping each `RemoteTerminal` detaches its - // socket, so no reader thread is mid-read when the daemon exits. The - // window briefly shows the empty home page while the daemon restarts. if this .update_in(cx, |this, _window, cx| { this.save_session(cx); @@ -2056,27 +1162,14 @@ impl Tty7App { { return; } - // Shut the old daemon down and spawn a fresh one off the UI thread. let restarted = cx .background_spawn(async move { crate::daemon::spawn::restart() }) .await; - // Rebuild from the saved session. The fresh daemon has no live panes, - // so every leaf spawns a new shell in its saved cwd and the tab/split - // layout returns exactly as it was. let _ = this.update_in(cx, |this, window, cx| { match &restarted { - // Rebuild from the machine's tree, which survived the - // restart on disk: the fresh daemon force-cleared every - // pane's live flag, so the resync revives each leaf as a - // fresh shell in its recorded cwd (agents resumed) — - // exactly the semantics the old saved-session rebuild - // hand-rolled. The pull waits out the local link coming - // back up to the fresh daemon. Ok(()) => { crate::ui::tree_sync::resync_window_from_tree(cx, this.workspace); } - // The fresh daemon never came up. Stay on the home page and - // leave a breadcrumb rather than crash — the user can retry. Err(e) => { log::error!("restart background service failed, staying on home page: {e}"); } @@ -2088,9 +1181,6 @@ impl Tty7App { .detach(); } - /// Apply `size` (clamped) as the new global font size across every pane. - /// The element re-measures cell geometry next frame, so the grid reflows - /// automatically once each view is notified. fn set_font_size(&mut self, size: f32, cx: &mut Context<Self>) { let size = size.clamp(FONT_SIZE_MIN, FONT_SIZE_MAX); self.font_size = size; @@ -2103,7 +1193,6 @@ impl Tty7App { }); } } - // Persist so the zoom level survives a restart. let cfg = cx.global_mut::<Config>(); cfg.font_size = size; cfg.save(); @@ -2114,17 +1203,10 @@ impl Tty7App { self.set_font_size(self.font_size + delta, cx); } - /// Reset the global font size back to the built-in default. We use the - /// compiled-in default rather than `config.font_size`, because the latter now - /// tracks the live zoom level (persisted on every change), so it no longer - /// serves as a stable reset target. pub(crate) fn reset_font_size(&mut self, cx: &mut Context<Self>) { self.set_font_size(Config::default().font_size, cx); } - /// Apply `mul` (clamped) as the new global line-height multiplier across every - /// pane. Like `set_font_size`, the element re-derives row height next frame, so - /// the grid reflows once each view is notified. fn set_line_height(&mut self, mul: f32, cx: &mut Context<Self>) { let mul = mul.clamp(LINE_HEIGHT_MIN, LINE_HEIGHT_MAX); self.line_height = mul; @@ -2136,7 +1218,6 @@ impl Tty7App { }); } } - // Persist so the spacing survives a restart. let cfg = cx.global_mut::<Config>(); cfg.line_height = mul; cfg.save(); @@ -2147,16 +1228,10 @@ impl Tty7App { self.set_line_height(self.line_height + delta, cx); } - /// Reset the line-height multiplier back to the built-in default (see the note - /// on `reset_font_size`: config now tracks the live value, not a reset target). pub(crate) fn reset_line_height(&mut self, cx: &mut Context<Self>) { self.set_line_height(Config::default().line_height, cx); } - /// Switch the active color theme by id, repaint, and persist the choice so - /// it survives a restart. The theme carries its own dark/light brightness. - /// While the system is being followed, the choice lands in the slot for the - /// *current* OS appearance (the theme visibly on screen changes either way). pub(crate) fn set_preset(&mut self, id: &str, window: &mut Window, cx: &mut Context<Self>) { let dark_now = crate::ui::theme::system_dark(cx); let cfg = cx.global_mut::<Config>(); @@ -2170,9 +1245,6 @@ impl Tty7App { self.after_theme_change(window, cx); } - /// Set the theme for one follow-system slot explicitly (the Light / Dark - /// cards in Settings). Only visibly changes anything when that slot is the - /// one currently on screen; either way the choice is persisted. pub(crate) fn set_slot_preset( &mut self, dark_slot: bool, @@ -2189,12 +1261,6 @@ impl Tty7App { self.after_theme_change(window, cx); } - /// Turn "sync with system appearance" on/off (the Appearance switch). - /// Turning it off never visibly changes the theme: whatever is on screen - /// is adopted as the manual choice. Turning it on seeds the slot matching - /// the manual theme's own brightness with that theme — so the look only - /// changes when the OS is currently in the *other* mode, where switching - /// to that mode's slot is exactly what the feature promises. pub(crate) fn set_theme_follow_system( &mut self, on: bool, @@ -2212,16 +1278,12 @@ impl Tty7App { cfg.theme_preset_light = manual; } } else { - // Resolve while following is still on (the pin is released, so - // this reads the real OS appearance). let effective = crate::ui::theme::effective_preset_id(cx); let cfg = cx.global_mut::<Config>(); cfg.theme_follow_system = false; cfg.theme_preset = effective; } self.after_theme_change(window, cx); - // Re-aim an open picker panel at a slot that exists in the new mode — - // after the apply above, so `system_dark` reads the unpinned OS value. let slot = if on { if crate::ui::theme::system_dark(cx) { crate::ui::settings::ThemeSlot::Dark @@ -2236,23 +1298,15 @@ impl Tty7App { } } - /// The shared tail of every theme-selection change: repaint, persist, and - /// keep the dependent Settings widgets in step. fn after_theme_change(&mut self, window: &mut Window, cx: &mut Context<Self>) { apply_theme(Some(window), cx); set_menus(cx); cx.global::<Config>().save(); - // The editor targets the active theme, so its pickers must track a switch. self.rebuild_theme_editor(window, cx); - // With no global override, the effective opacity follows the theme — keep - // the Appearance slider's thumb on it. self.sync_window_opacity_slider(window, cx); cx.notify(); } - /// Show/hide the theme picker panel beside the Appearance page. Clicking - /// the card whose slot the open panel already targets closes it; clicking - /// another card re-aims the open panel at that slot. pub(crate) fn toggle_theme_panel( &mut self, slot: crate::ui::settings::ThemeSlot, @@ -2269,7 +1323,6 @@ impl Tty7App { } } - /// Close the theme picker panel (its `×`). pub(crate) fn close_theme_panel(&mut self, cx: &mut Context<Self>) { if let Some(s) = self.active_settings_mut() { s.theme_panel_open = false; @@ -2277,8 +1330,6 @@ impl Tty7App { } } - /// Open the user themes folder (`~/.config/tty7/themes`) in the system file - /// browser, creating it first so there's always somewhere to drop a theme. pub(crate) fn open_themes_folder(&self, cx: &mut Context<Self>) { if let Some(dir) = crate::ui::presets::themes_dir() { let _ = std::fs::create_dir_all(&dir); @@ -2286,25 +1337,18 @@ impl Tty7App { } } - /// Duplicate the active theme into an editable YAML file, switch to the copy, - /// and open the color editor on it. This is the entry point for customizing a - /// read-only built-in (or an imported iTerm scheme). pub(crate) fn fork_active_theme(&mut self, window: &mut Window, cx: &mut Context<Self>) { let id = crate::ui::theme::effective_preset_id(cx); let theme = crate::ui::presets::by_id(cx, &id); match crate::ui::presets::fork_to_file(&theme) { Ok(new_id) => { crate::ui::presets::load_registry(cx); - // Switches to the copy (applies + persists + rebuilds the editor). self.set_preset(&new_id, window, cx); } Err(e) => log::warn!("failed to duplicate theme: {e}"), } } - /// Apply one edit to the active (editable) theme: mutate it, write the - /// theme's file, reload the registry, and repaint live. The shared tail of - /// every in-app theme edit (color pickers, opacity slider, blur switch). fn mutate_active_theme( &mut self, mutate: impl FnOnce(&mut crate::ui::presets::Theme), @@ -2326,7 +1370,6 @@ impl Tty7App { cx.notify(); } - /// Apply one color edit to the active (editable) theme. pub(crate) fn edit_active_theme( &mut self, edit: ThemeEdit, @@ -2349,16 +1392,12 @@ impl Tty7App { ); } - /// The window opacity currently in effect: the global config override when - /// set, else the active theme's own value, else fully opaque. pub(crate) fn effective_window_opacity(cx: &App) -> f32 { let config = cx.global::<Config>(); let theme = crate::ui::presets::by_id(cx, &crate::ui::theme::effective_preset_id(cx)); config.window_opacity.or(theme.opacity).unwrap_or(1.0) } - /// Set the global window-opacity override from the Appearance slider. Applies - /// to every theme (persisted in the config, not the theme file). pub(crate) fn set_window_opacity( &mut self, v: f32, @@ -2371,7 +1410,6 @@ impl Tty7App { cx.notify(); } - /// Set the global window-blur override from the Appearance switch. pub(crate) fn set_window_blur( &mut self, on: bool, @@ -2384,8 +1422,6 @@ impl Tty7App { cx.notify(); } - /// Clear both window overrides so opacity/blur follow the active theme again - /// (the Appearance section's "Follow theme" action). pub(crate) fn reset_window_overrides(&mut self, window: &mut Window, cx: &mut Context<Self>) { { let config = cx.global_mut::<Config>(); @@ -2398,9 +1434,6 @@ impl Tty7App { cx.notify(); } - /// Snap the Appearance opacity slider's thumb to the value now in effect. - /// Needed whenever that value changes for a reason other than the user - /// dragging it (theme switch, "Follow theme" reset). pub(crate) fn sync_window_opacity_slider( &mut self, window: &mut Window, @@ -2415,8 +1448,6 @@ impl Tty7App { } } - /// Set the active (editable) theme's background image from a native file - /// picker, keeping the existing image opacity (or the schema default). pub(crate) fn pick_theme_image(&mut self, cx: &mut Context<Self>) { let rx = cx.prompt_for_paths(gpui::PathPromptOptions { files: true, @@ -2437,8 +1468,6 @@ impl Tty7App { window, cx, ); - // The editor gains/loses the image-opacity slider with - // the image itself. this.rebuild_theme_editor(window, cx); }); } @@ -2447,13 +1476,11 @@ impl Tty7App { .detach(); } - /// Remove the active theme's background image. pub(crate) fn remove_theme_image(&mut self, window: &mut Window, cx: &mut Context<Self>) { self.mutate_active_theme(|theme| theme.image = None, window, cx); self.rebuild_theme_editor(window, cx); } - /// Set the active theme's background-image opacity from the editor slider. pub(crate) fn set_theme_image_opacity( &mut self, v: f32, @@ -2471,11 +1498,6 @@ impl Tty7App { ); } - /// (Re)build the settings tab's color-editor pickers for the current active - /// theme. If no settings tab is open or the active theme isn't an editable - /// file, the editor is cleared. Called after every theme switch / duplicate - /// and when opening settings, so the pickers always reflect (and target) the - /// theme currently on screen. pub(crate) fn rebuild_theme_editor(&mut self, window: &mut Window, cx: &mut Context<Self>) { if self.settings.is_none() { return; @@ -2490,7 +1512,6 @@ impl Tty7App { } let neutrals = theme.neutrals(); - // (edit target, row label, current 0xRRGGBB value) for each seed color. let seed_specs: [(ThemeEdit, &str, u32); 5] = [ ( ThemeEdit::Background, @@ -2543,10 +1564,6 @@ impl Tty7App { }) .collect(); - // Background-image opacity slider, present only while the theme has an - // image (choosing/removing one rebuilds the editor). Emits `Change` - // continuously while dragging; each tick writes the theme file and - // repaints, so the mix is live under the thumb. let image_opacity_slider = theme.image.as_ref().map(|img| { let slider = cx.new(|_| { SliderState::new() @@ -2578,9 +1595,6 @@ impl Tty7App { } } - /// Toggle terminal font ligatures through the generic `font_features` - /// config. On enables the common programming-font features; off restores - /// tty7's terminal-safe default (contextual ligatures disabled). pub(crate) fn set_font_ligatures(&mut self, on: bool, cx: &mut Context<Self>) { let features = on.then(|| { crate::core::config::FontFeatures(Arc::new(vec![ @@ -2588,7 +1602,6 @@ impl Tty7App { ("liga".to_string(), 1), ])) }); - // The config holds the gpui-free representation; the views want gpui's. let gpui_features = features .as_ref() .map(crate::core::config::gpui_font_features); @@ -2616,8 +1629,6 @@ impl Tty7App { } } - /// Switch the default cursor shape, update each pane's terminal defaults, - /// and repaint. App-requested DECSCUSR shapes still override this at runtime. pub(crate) fn set_cursor_style(&mut self, style: ConfigCursorStyle, cx: &mut Context<Self>) { self.update_config(cx, |cfg| cfg.cursor_style = style); let cfg = cx.global::<Config>().clone(); @@ -2626,17 +1637,6 @@ impl Tty7App { self.apply_terminal_config_to_panes(&cfg, cx); } - // ── Config setters (Terminal / Window & Tabs / Cursor settings) ───────── - // Each goes through `update_config` (mutate the global, persist, repaint). - // Effect points read the global live (blink task, `poll_foreground`, link - // gates, `new_tab_insert_at`), so there's nothing to push into the panes — - // except cursor blink, which must un-hide a cursor a prior blink cycle may - // have left dark. - - /// Shared tail of every config setter: mutate the global `Config`, persist - /// it, and repaint so the control reflects the new value. Keeping the - /// persist/notify contract here means a future change (e.g. debounced - /// saves) lands in one place. pub(crate) fn update_config( &mut self, cx: &mut Context<Self>, @@ -2656,23 +1656,14 @@ impl Tty7App { self.update_config(cx, |cfg| cfg.ssh_loopback_forward = on); } - /// Global default for native-SSH host-key verification (WS3, FR-S4). A - /// per-profile override still wins where set. pub(crate) fn set_verify_host_keys(&mut self, on: bool, cx: &mut Context<Self>) { self.update_config(cx, |cfg| cfg.verify_host_keys = on); } - /// Global default for confirming before closing a live SSH session (FR-E3). - /// A per-profile `warn_on_close` override still wins where set. pub(crate) fn set_ssh_warn_on_close(&mut self, on: bool, cx: &mut Context<Self>) { self.update_config(cx, |cfg| cfg.ssh_warn_on_close = on); } - /// How a forward on `pane_id` reaches the daemon. - /// - /// Looked up across every tab's leaves rather than off the focused one: the - /// Forwards band tracks the pane the *panel* is showing, which is not - /// necessarily the pane with keyboard focus. pub(crate) fn forward_route(&self, pane_id: u64, cx: &gpui::App) -> ForwardRoute { let workspace = self .tabs @@ -2685,13 +1676,11 @@ impl Tty7App { ForwardRoute { pane_id, workspace } } - /// Refresh the managed (Local/Remote/Dynamic) forwards for `pane_id` (WS4). pub(crate) fn refresh_managed_forwards(&mut self, pane_id: u64, cx: &mut Context<Self>) { self.loopback_panel.managed = self.forward_route(pane_id, cx).list(); cx.notify(); } - /// Pick the kind for the add-forward form (native-SSH panes). pub(crate) fn set_managed_forward_kind( &mut self, kind: crate::daemon::protocol::SshForwardKind, @@ -2701,9 +1690,6 @@ impl Tty7App { cx.notify(); } - /// Establish the add-form's managed forward on `pane_id`'s connection, then - /// clear the form. A blank/invalid bind port is ignored; Dynamic forwards need - /// no target. pub(crate) fn add_managed_forward( &mut self, pane_id: u64, @@ -2749,7 +1735,6 @@ impl Tty7App { .trim() .parse::<u16>() .unwrap_or(0); - // Local/Remote require a target; Dynamic (SOCKS) does not. if kind != SshForwardKind::Dynamic && (target_host.is_empty() || target_port == 0) { return; } @@ -2768,18 +1753,12 @@ impl Tty7App { target_port, description: (!description.is_empty()).then_some(description), }; - // Editing an existing forward = re-establish it: drop the old one first so - // its listener frees the (possibly reused) bind port before the new one binds. let route = self.forward_route(pane_id, cx); if let Some(old_id) = self.loopback_panel.mf_editing.take() { let _ = route.remove(old_id); } self.loopback_panel.managed = route.add(rule); - // The new row *is* the confirmation, so the form folds away rather than - // sitting there re-inviting an add nobody asked for. (Only on the success - // path — every validation failure above returns early with it still open.) self.loopback_panel.form_pane_id = None; - // Reset the value-carrying fields; keep bind host default. for input in [ &self.loopback_panel.mf_bind_port, &self.loopback_panel.mf_target_host, @@ -2791,8 +1770,6 @@ impl Tty7App { cx.notify(); } - /// Load an existing forward's values into the add form for editing - /// (VSCode-style: change the port/target, Save re-establishes it). pub(crate) fn edit_managed_forward( &mut self, forward: crate::daemon::protocol::ManagedForward, @@ -2801,8 +1778,6 @@ impl Tty7App { ) { self.loopback_panel.mf_kind = forward.kind; self.loopback_panel.mf_editing = Some(forward.id); - // Clicking a row is the only way in, and the form is where the values - // land — so expand it on the row's own pane. self.loopback_panel.form_pane_id = Some(forward.pane_id); let target_port = if forward.target_port == 0 { String::new() @@ -2831,7 +1806,6 @@ impl Tty7App { cx.notify(); } - /// Leave edit mode without saving; clear the form back to the add defaults. pub(crate) fn cancel_managed_forward_edit( &mut self, window: &mut Window, @@ -2852,7 +1826,6 @@ impl Tty7App { cx.notify(); } - /// Tear down one managed forward by id (native-SSH panes). pub(crate) fn remove_managed_forward( &mut self, pane_id: u64, @@ -2863,14 +1836,6 @@ impl Tty7App { cx.notify(); } - /// `ShowSshForwards` / the palette's "SSH: Port Forwarding": land on the - /// pane's forwards wherever you were. The band lives on the Info tab, so this - /// opens the panel there and expands the add form — the one entry point that - /// works with the panel closed, which is why it exists at all. - /// - /// A no-op on anything but a connected native-SSH pane: without a connection - /// there is nothing to forward over, and opening an empty form on a local - /// shell would only be a puzzle. pub(crate) fn show_ssh_forwards(&mut self, window: &mut Window, cx: &mut Context<Self>) { let Some((pane_id, _)) = self.active_connected_native_ssh_pane(window, cx) else { return; @@ -2881,9 +1846,6 @@ impl Tty7App { } } - /// The Forwards band's `+`: expand the add form for `pane_id`, or collapse it - /// if it's already this pane's. Collapsing goes through the same reset as - /// Cancel, so a form abandoned mid-edit can't come back still in edit mode. pub(crate) fn toggle_managed_forward_form( &mut self, pane_id: u64, @@ -2899,7 +1861,6 @@ impl Tty7App { self.refresh_managed_forwards(pane_id, cx); } - /// Collapse the add/edit form, clearing it back to the add defaults. pub(crate) fn close_managed_forward_form( &mut self, window: &mut Window, @@ -2909,19 +1870,9 @@ impl Tty7App { self.cancel_managed_forward_edit(window, cx); } - /// Route a typed "SSH: Add Connection…" line to the native engine (PRD §3.1/ - /// §3.3). The input is parsed as best-effort into a transient profile — a - /// `user@host[:port]` target plus the trivially-mappable flags (`-p`, `-i`, - /// `-l`, `-J`, `-o User=`/`-o Port=`). A line that can't be parsed into a host - /// surfaces a diagnosable inline notice rather than silently shelling out. fn open_typed_ssh_connect(&mut self, input: &str, window: &mut Window, cx: &mut Context<Self>) { match parse_ssh_connect_input(input) { Ok(parsed) => { - // `ssh` semantics: a target naming a `~/.ssh/config` alias - // resolves through it, with typed flags overriding the config's - // values. (After parsing, a port of 22 is indistinguishable - // from "not given", so an explicit `-p 22` can't override a - // config port — the one caveat of this overlay.) let (profile, proxy_jump) = match ssh_config::resolve_alias_to_profile(&parsed.profile.host) { Some(resolved) => { @@ -2953,23 +1904,16 @@ impl Tty7App { } } - /// Toggle the startup update check (Settings → About). Takes effect on the - /// next launch — this only persists the preference; it doesn't run or cancel - /// an in-flight check. pub(crate) fn set_check_for_updates(&mut self, on: bool, cx: &mut Context<Self>) { self.update_config(cx, |cfg| cfg.check_for_updates = on); } - /// Toggle inactive-pane dimming. Applies on the next render — `update_config` - /// notifies, and this view's render is what hands the flag to the pane tree. pub(crate) fn set_dim_inactive_panes(&mut self, on: bool, cx: &mut Context<Self>) { self.update_config(cx, |cfg| cfg.dim_inactive_panes = on); } pub(crate) fn set_cursor_blink(&mut self, on: bool, cx: &mut Context<Self>) { self.update_config(cx, |cfg| cfg.cursor_blink = on); - // Turning blink off mid-cycle could leave the cursor in its hidden phase; - // force every pane's cursor back on so it doesn't stick invisible. if !on { for tab in &self.tabs { for leaf in tab.pane.terminals() { @@ -2983,8 +1927,6 @@ impl Tty7App { } pub(crate) fn set_scrollback_limit(&mut self, lines: usize, cx: &mut Context<Self>) { - // Callers pass fixed in-range presets, but clamp anyway so a future caller - // can't smuggle in a degenerate value. self.update_config(cx, |cfg| { cfg.scrollback_limit = lines.clamp(100, crate::core::config::MAX_SCROLLBACK) }); @@ -2998,15 +1940,10 @@ impl Tty7App { self.update_config(cx, |cfg| cfg.new_tab_position = pos); } - /// Set where the tab bar is rendered (Settings → Window & Tabs). Persists the - /// choice; the layout re-derives from the `Config` global on the next render. pub(crate) fn set_tab_bar_position(&mut self, pos: TabBarPosition, cx: &mut Context<Self>) { self.update_config(cx, |cfg| cfg.tab_bar_position = pos); } - /// Set how the vertical tab sidebar arranges its rows (Settings → Window & - /// Tabs): grouped per git repo or one flat list. Persists the choice; the - /// sidebar re-derives from the `Config` global on the next render. pub(crate) fn set_sidebar_grouping( &mut self, grouping: crate::core::config::SidebarGrouping, @@ -3015,16 +1952,10 @@ impl Tty7App { self.update_config(cx, |cfg| cfg.sidebar_grouping = grouping); } - /// Set whether the sidebar's `+N −N` counts open the diff overlay - /// (Settings → Window & Tabs). The counts themselves are unaffected either - /// way — this only governs the click. Persists the choice; the sidebar - /// re-derives from the `Config` global on the next render. pub(crate) fn set_sidebar_diff_preview(&mut self, on: bool, cx: &mut Context<Self>) { self.update_config(cx, |cfg| cfg.sidebar_diff_preview = on); } - /// `ToggleTabSidebar`: flip the tab bar between the horizontal title-bar strip - /// (`Top`) and the vertical left sidebar (`Left`), persisting the choice. pub(crate) fn toggle_tab_sidebar(&mut self, cx: &mut Context<Self>) { let next = match cx.global::<Config>().tab_bar_position { TabBarPosition::Top => TabBarPosition::Left, @@ -3033,14 +1964,9 @@ impl Tty7App { self.set_tab_bar_position(next, cx); } - /// `ToggleLeftPanel` (⌘B): collapse/expand the left rail in place, persisting - /// the choice. In `Top` mode there is no rail to collapse, so this switches to - /// `Left` and shows it — the shortcut always means "give me the sidebar". pub(crate) fn toggle_left_panel(&mut self, cx: &mut Context<Self>) { let (pos, collapsed) = match cx.global::<Config>().tab_bar_position { TabBarPosition::Top => (TabBarPosition::Left, false), - // This window's own collapse state — collapsing one window's rail - // must not collapse every other window's. See `sidebar_collapsed`. TabBarPosition::Left => (TabBarPosition::Left, !self.sidebar_collapsed), }; self.sidebar_collapsed = collapsed; @@ -3051,9 +1977,6 @@ impl Tty7App { cx.notify(); } - /// Whether the left rail is actually on screen: `Left` mode, not collapsed, - /// and at least one tab (the home page has no rail). The layout, the title - /// strip and the collapse button all derive from this one predicate. pub(crate) fn left_panel_open(&self, cx: &gpui::App) -> bool { matches!(cx.global::<Config>().tab_bar_position, TabBarPosition::Left) && !self.sidebar_collapsed @@ -3068,15 +1991,10 @@ impl Tty7App { self.update_config(cx, |cfg| cfg.notify_on_command_finish = mode); } - /// Set the "long command" floor (seconds) a foreground command must exceed - /// to be eligible for a completion notification. Read live where the alert - /// is posted, so nothing needs pushing to open panes. pub(crate) fn set_notify_threshold(&mut self, secs: u64, cx: &mut Context<Self>) { self.update_config(cx, |cfg| cfg.notify_threshold_secs = secs.clamp(1, 3600)); } - /// Switch how the terminal bell is signalled. Read live in each pane's bell - /// handler, so there's nothing to push. pub(crate) fn set_bell_mode( &mut self, mode: crate::core::config::BellMode, @@ -3085,36 +2003,24 @@ impl Tty7App { self.update_config(cx, |cfg| cfg.bell = mode); } - /// Toggle session restore. Takes effect on the next launch (this only - /// persists the preference); the current window is untouched. pub(crate) fn set_restore_session(&mut self, on: bool, cx: &mut Context<Self>) { self.update_config(cx, |cfg| cfg.restore_session = on); } - /// Toggle the system tray icon. The tray's poll loop re-reads the flag - /// every second, so the icon appears/disappears without a restart. pub(crate) fn set_show_tray_icon(&mut self, on: bool, cx: &mut Context<Self>) { self.update_config(cx, |cfg| cfg.show_tray_icon = on); } - /// Toggle the "Close Window?" prompt on the last window. The close handler - /// reads the flag when it fires, so this applies to the very next ⌘W with no - /// restart and nothing to push to open windows. pub(crate) fn set_confirm_window_close(&mut self, on: bool, cx: &mut Context<Self>) { self.update_config(cx, |cfg| cfg.confirm_window_close = on); } - // ── Input / Mouse setters ─────────────────────────────────────────────── - - /// Takes effect on the next keystroke — the terminal reads the flag per - /// key event, so nothing needs pushing to open panes. pub(crate) fn set_macos_option_as_alt(&mut self, on: bool, cx: &mut Context<Self>) { self.update_config(cx, |cfg| cfg.macos_option_as_alt = on); } pub(crate) fn set_mouse_hide_while_typing(&mut self, on: bool, cx: &mut Context<Self>) { self.update_config(cx, |cfg| cfg.mouse_hide_while_typing = on); - // Push the new policy to GPUI right away (same call the hot-reload uses). crate::ui::theme::apply_cursor_hide_mode(cx); } @@ -3122,9 +2028,6 @@ impl Tty7App { self.update_config(cx, |cfg| cfg.focus_follows_mouse = on); } - /// Toggle whether mouse events reach full-screen apps. The gates are cached - /// per view, so this pushes the new value into every open pane (like the - /// font setters) in addition to persisting it. pub(crate) fn set_mouse_reporting(&mut self, on: bool, cx: &mut Context<Self>) { self.update_config(cx, |cfg| cfg.mouse_reporting = on); for tab in &self.tabs { @@ -3175,13 +2078,6 @@ impl Tty7App { self.update_config(cx, |cfg| cfg.remember_window_size = on); } - /// Name the OS window after its workspace, so ⌘` and Mission Control can - /// tell several tty7 windows apart. Reads the *saved* workspace, which - /// every structural change writes just before focus lands here — a title - /// one beat behind is invisible, and it keeps this off the render path. - /// - /// An empty workspace has no subject yet, so it falls back to the app name - /// rather than showing "Untitled". pub(crate) fn sync_window_title(&self, window: &mut Window, cx: &App) { let title = WorkspaceStore::all(cx) .get(self.workspace) @@ -3196,26 +2092,15 @@ impl Tty7App { } pub(crate) fn focus_active(&self, window: &mut Window, cx: &mut App) { - // Focus moves after every structural change, which is exactly when the - // window's subject may have changed too. self.sync_window_title(window, cx); - // While the settings overlay is open it owns focus (so Esc-to-close and - // keybinding capture keep working); tab operations behind it don't steal - // it. `close_settings` refocuses the active terminal on the way out. if let Some(settings) = self.settings.as_ref() { window.focus(&settings.focus_handle, cx); return; } let Some(tab) = self.tabs.get(self.active) else { - // No tabs → the home page is showing; keep something focused so - // keystrokes stay on the window's dispatch path (⌘T etc. must still - // reach the root action handlers). window.focus(&self.home_focus, cx); return; }; - // A tab showing its diff overlay gives the overlay focus (Esc-to-close - // must keep working when switching back to it); `close_diff_overlay` - // re-runs this after clearing the slot to land on the terminal. if let Some(overlay) = tab.diff_overlay.as_ref() { window.focus(&overlay.focus_handle, cx); return; @@ -3226,10 +2111,6 @@ impl Tty7App { } } - /// Snapshot which pane currently holds focus in the active tab into that - /// tab's `last_focused`, so `focus_active` can restore it when we come back. - /// Call this before any transition that moves focus off the active tab - /// (switching tabs, opening a focus-stealing overlay). pub(crate) fn remember_active_pane(&mut self, window: &Window, cx: &App) { let active = self.active; if let Some(tab) = self.tabs.get_mut(active) { @@ -3244,11 +2125,6 @@ impl Tty7App { window.focus(&handle, cx); } - /// Put a finished (or failed) remote pane back into the tree. - /// - /// `slot_id` is the *placeholder's* id, which is what the tree still holds: - /// the terminal that just arrived has an identity of its own and has never - /// been in the tree. fn land_pane( &mut self, slot_id: gpui::EntityId, @@ -3260,21 +2136,11 @@ impl Tty7App { ) { let parts = match parts { Ok(parts) => parts, - // The slot keeps its place and says what went wrong. It does - // not collapse the split under the user, and it does not close the - // tab — both would throw away a layout because a network blinked. Err(reason) => { pending.update(cx, |p, cx| p.fail(reason, cx)); return; } }; - // The pane or its tab may have been closed while the connect was in the - // air. Checked *before* the view is built, because the answer decides - // whether to build one at all — and because the pane on the other - // machine is real and running either way, so a slot that is gone means - // killing it rather than leaking it. Nothing on this client can reach - // it any more, and closing the slot is the user saying they do not want - // it (unlike a quit, where panes are deliberately detached and kept). let still_there = self .tabs .iter() @@ -3288,13 +2154,7 @@ impl Tty7App { kill_pane_off_thread(route, parts.pane_id, cx); return; } - // Whether the user was sitting on this pane while it connected. Read - // before the swap, since the placeholder leaves the tree in it. let was_focused = pending.read(cx).focus_handle.contains_focused(window, cx); - // The saved id was gone and this is a fresh shell in its cwd, so the - // agent that was running in it has to be resumed by hand — the same - // thing a local pane's restore does, deferred to here because only the - // machine could say whether the attach took. let resume = (!parts.restored) .then(|| { let spawn = &pending.read(cx).spawn; @@ -3317,32 +2177,16 @@ impl Tty7App { if was_focused { self.focus_leaf(&slot, window, cx); } - // The pane has a real `pane_id` now, which is what restore matches on. self.save_session(cx); cx.notify(); } - /// Ask every pane in the window to re-probe its git status. Called when the - /// window regains focus: the sidebar shows a git line for *every* tab, not - /// just the active one, so refreshing only the focused pane would leave the - /// rest of the list stale — which is exactly the list the user is scanning - /// right after switching back. - /// - /// Panes sharing a cwd fold into one probe in the shared cache, and the - /// throttle there counts per repo rather than per cwd, so once the cache - /// knows where each pane lives the cost of a window with many panes is - /// bounded by the number of distinct repos — not by the number of - /// subdirectories they happen to sit in, which would be the same full-repo - /// `git diff` asked several times over. fn refresh_git_status_all(&mut self, cx: &mut Context<Self>) { for leaf in self.tabs.iter().flat_map(|tab| tab.pane.terminals()) { leaf.update(cx, |view, cx| view.refresh_git_status_now(cx)); } } - /// Where a freshly opened tab should be inserted, per `new_tab_position`: - /// right after the active tab, or appended at the end. Clamped to the tab - /// count so the zero-tab home state (active 0, no tabs) inserts at 0. fn new_tab_insert_at(&self, cx: &App) -> usize { match cx.global::<Config>().new_tab_position { NewTabPosition::AfterCurrent => (self.active + 1).min(self.tabs.len()), @@ -3354,29 +2198,15 @@ impl Tty7App { 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>, ) { - // A window is one machine. A remote workspace's window must - // not open a shell on *this* computer, so the refusal happens before - // anything is spawned rather than after a local pane is already in the - // tab strip. if !self.guard_local_spawn(window, cx) { return; } - // 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. The new - // tab takes this window's route, so it lands on the same machine the - // source pane's shell is on — which is what `spawnable_cwd` gates on. A - // pane whose shell is somewhere else entirely (a native-SSH or WSL pane - // in an otherwise local window) declines, because an inherited cwd wins - // over every fallback in `pane::initial_working_directory` and would go - // straight to the spawn as a working directory. let cwd = self.tabs.get(self.active).and_then(|t| { t.pane .focused_or_first(window, cx) @@ -3400,8 +2230,6 @@ impl Tty7App { return; } }; - // Leaving the current tab for the new one; snapshot its focused pane - // so switching back restores it (same as `activate`). self.remember_active_pane(window, cx); self.maximized = None; let insert_at = self.new_tab_insert_at(cx); @@ -3412,10 +2240,6 @@ impl Tty7App { cx.notify(); } - /// Open a new tab running a native (russh) SSH session for the resolved - /// `spec` (PRD FR-C1). The caller (`ui::ssh_connect`) has already pulled any - /// keychain secrets into `spec`. Mirrors `new_tab_with_shell` but for the - /// native backend. pub(crate) fn open_native_ssh_tab( &mut self, spec: Box<crate::daemon::protocol::NativeSshSpec>, @@ -3435,8 +2259,6 @@ impl Tty7App { return; } }; - // Leaving the current tab for the new one; snapshot its focused pane - // so switching back restores it (same as `activate`). self.remember_active_pane(window, cx); self.maximized = None; let insert_at = self.new_tab_insert_at(cx); @@ -3448,9 +2270,6 @@ impl Tty7App { cx.notify(); } - /// Respawn a native SSH pane **in place** (same tab / split slot), replacing a - /// dead pane's view with a fresh native connection for `spec` (PRD FR-E4). The - /// daemon re-establishes the profile's preconfigured forwards on connect. pub(crate) fn respawn_native_ssh_in_place( &mut self, dead: &Entity<TerminalView>, @@ -3467,7 +2286,6 @@ impl Tty7App { return; } }; - // Swap the fresh leaf into the dead one's position across every tab. for tab in &mut self.tabs { if tab .pane @@ -3482,11 +2300,7 @@ impl Tty7App { cx.notify(); } - /// Split the focused pane in the active tab, focusing the new terminal. pub(crate) fn split(&mut self, axis: Axis, window: &mut Window, cx: &mut Context<Self>) { - // Capture the target leaf BEFORE creating the new terminal: constructing - // a TerminalView focuses it, which would otherwise make us lose track of - // which pane to split (nested splits would always hit the first leaf). let Some(target) = self .tabs .get(self.active) @@ -3494,20 +2308,10 @@ impl Tty7App { else { return; }; - // 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). Same rule as a new tab: the - // split takes this window's route, so `spawnable_cwd` is the cwd the - // machine it lands on can actually chdir into. (The native-SSH branch - // below has the daemon discard it regardless.) if !self.guard_local_spawn(window, cx) { return; } let cwd = target.read(cx).spawnable_cwd(); - // Splitting a native-SSH pane opens another SSH pane on the same - // connection rather than dropping back to a local shell. Re-resolve the - // persisted (secret-free) spec from its saved profile so keychain - // secrets are re-applied, mirroring the reconnect path. let ssh_spec = target.read(cx).ssh_spec(); let new = if let Some(spec) = ssh_spec { let resolved = crate::ui::ssh_connect::resolve_persisted_ssh_spec(spec, cx); @@ -3552,10 +2356,7 @@ impl Tty7App { } } - /// Close the focused pane. If it was the tab's only pane, close the tab. fn close_pane(&mut self, window: &mut Window, cx: &mut Context<Self>) { - // FR-E3: if the focused pane is a live SSH session flagged warn-on-close, - // raise the in-pane confirm sheet instead of closing outright. if self.ssh_close_confirm.is_none() && self.focused_pane_is_warn_ssh(window, cx) { self.ssh_close_confirm = Some(SshCloseKind::Pane); cx.notify(); @@ -3563,17 +2364,11 @@ impl Tty7App { } self.ssh_close_confirm = None; self.maximized = None; - // Capture the focused leaf before closing: if a split collapses, that - // leaf is destroyed with no reopen path, so we kill its daemon pane. Owned - // clones from `leaves()` end the borrow before the `&mut` close below. let focused = self.tabs.get(self.active).and_then(|tab| { tab.pane .leaves() .into_iter() .find(|l| l.contains_focused(window, cx)) - // A pane still connecting has no daemon pane to kill; the - // spawn that is in the air for it is handled where it lands - // (`land_pane` finds no slot and kills it there). .and_then(|l| l.terminal().cloned()) }); let outcome = match self.tabs.get_mut(self.active) { @@ -3582,15 +2377,9 @@ impl Tty7App { }; match outcome { CloseOutcome::RemoveSelf => { - // The focused leaf *is* the tab's only pane: close the tab, which - // kills its panes itself. self.close_tab(self.active, window, cx); } CloseOutcome::NotFound => { - // No terminal leaf in the active tab holds focus (focus is in the - // rename input / settings / drifted). Only fall back to closing the - // tab when it's a single pane — never silently destroy a multi-pane - // split whose target the user can't see. let single = self .tabs .get(self.active) @@ -3610,13 +2399,6 @@ impl Tty7App { } } - /// Close the pane whose shell just exited on its own (`ChildExited` from - /// the view — `exit`, Ctrl-D, a crashed shell): collapse its split, or - /// close its tab when it was the only pane. Unlike `close_pane` this - /// targets the *emitting* leaf, not the focused one — the exit can happen - /// in a background tab. The daemon pane is killed even though its child is - /// already dead: the daemon still lists it for reattach, and killing is - /// what drops it from the session. fn on_child_exited( &mut self, view: Entity<TerminalView>, @@ -3629,23 +2411,14 @@ impl Tty7App { .iter() .position(|tab| tab.pane.leaves().iter().any(|l| l.entity_id() == id)) else { - return; // already closed (e.g. by the user racing the exit) + return; }; - // A native-SSH pane lingers instead of closing (PRD FR-C2/E4): a failed - // connect's diagnostic must stay readable, and a dropped session's pane - // is the anchor for the in-pane reconnect (`restart_ssh_session`) — - // auto-close would make both unreachable. Only local shells fall through - // to the close below. if view.read(cx).ssh_disconnected() { cx.notify(); return; } match self.tabs[index].pane.close_leaf(view.entity_id()) { - // The exited pane was the tab's only leaf: close the whole tab - // (which snapshots it for reopen and kills its daemon panes). CloseOutcome::RemoveSelf => self.close_tab(index, window, cx), - // Unreachable — containment was just checked — but never close a - // tab we failed to locate the leaf in. CloseOutcome::NotFound => {} CloseOutcome::Collapsed => { kill_pane_off_thread(view.read(cx).pane_route(), view.read(cx).pane_id, cx); @@ -3659,10 +2432,7 @@ impl Tty7App { } } - /// Cycle focus among the panes of the active tab. fn cycle_pane(&mut self, forward: bool, window: &mut Window, cx: &mut Context<Self>) { - // `leaves()` returns owned clones, so the immutable borrow of `self.tabs` - // ends here — letting us mutate `self.maximized` just below. let leaves = match self.tabs.get(self.active) { Some(tab) => tab.pane.leaves(), None => return, @@ -3685,8 +2455,6 @@ impl Tty7App { cx.notify(); } - /// Move focus to the pane adjacent to the focused one in `dir` (tmux - /// directional focus). A no-op when there's no neighbor that way. fn focus_pane_dir(&mut self, dir: Dir, window: &mut Window, cx: &mut Context<Self>) { let Some(target) = self .tabs @@ -3700,9 +2468,6 @@ impl Tty7App { cx.notify(); } - /// Grow/shrink the focused pane along `dir` by one step, adjusting its - /// nearest matching-axis split. Persists the new layout. A no-op when no - /// split matches (e.g. a single-pane tab, or no divider on that axis). fn resize_pane(&mut self, dir: Dir, window: &mut Window, cx: &mut Context<Self>) { let changed = self .tabs @@ -3714,9 +2479,6 @@ impl Tty7App { } } - /// Swap the focused pane with its next / previous sibling in leaf order - /// (tmux `prefix }` / `prefix {`). The terminals trade tree positions; - /// focus rides along with the moved terminal. Needs at least two panes. fn swap_pane(&mut self, forward: bool, window: &mut Window, cx: &mut Context<Self>) { let (from, len) = match self.tabs.get(self.active) { Some(tab) => (tab.pane.focused_index(window, cx), tab.pane.leaves().len()), @@ -3740,8 +2502,6 @@ impl Tty7App { } } - /// Switch to the next / previous tab, wrapping around (tmux `prefix n/p`). - /// A no-op with fewer than two tabs. fn cycle_tab(&mut self, forward: bool, window: &mut Window, cx: &mut Context<Self>) { let n = self.tabs.len(); if n < 2 { @@ -3757,21 +2517,12 @@ impl Tty7App { pub(crate) fn activate(&mut self, index: usize, window: &mut Window, cx: &mut Context<Self>) { if index < self.tabs.len() && index != self.active { - // Remember the pane we're leaving focused so returning to this tab - // restores it instead of jumping to the first leaf. self.remember_active_pane(window, cx); self.maximized = None; self.active = index; - // The incoming tab may have a diff overlay that went stale while - // hidden (its repo changed underneath); re-probe if the status - // cache disagrees with the shown snapshot. self.maybe_refresh_diff_overlay(cx); - // In sidebar mode, pull the newly active row into view (a no-op when - // the strip is horizontal — the handle tracks no painted list then). self.sidebar_scroll.scroll_to_item(index); if self.code_panel_visible() { - // The incoming tab has its own panel open: refresh its roots - // (pane cwds may have changed) and keep focus on the panel. self.file_tree_refresh_roots(window, cx); self.file_tree.focus_handle.focus(window, cx); } else { @@ -3782,10 +2533,6 @@ impl Tty7App { } } - /// Toggle maximize on the active tab's focused pane (Cmd+Shift+Enter). When a - /// pane is maximized the tab renders only that leaf full-window; toggling again - /// (or any structural change) restores the split layout. A no-op when the - /// active tab has a single pane (nothing to maximize). fn toggle_maximize(&mut self, window: &mut Window, cx: &mut Context<Self>) { if self.maximized.is_some() { self.maximized = None; @@ -3809,13 +2556,9 @@ impl Tty7App { } pub(crate) fn close_tab(&mut self, index: usize, window: &mut Window, cx: &mut Context<Self>) { - // Closing the last tab is allowed: zero tabs is the home page (see - // `ui::home`), and `focus_active`/`render` both handle it. if index >= self.tabs.len() { return; } - // FR-E3: confirm before closing a tab that holds a live warn-on-close SSH - // session (unless this call is the confirmation itself). let already_confirming = self.ssh_close_confirm == Some(SshCloseKind::Tab(index)); if !already_confirming && self.tab_has_warn_ssh(index, cx) { self.ssh_close_confirm = Some(SshCloseKind::Tab(index)); @@ -3824,32 +2567,18 @@ impl Tty7App { } self.ssh_close_confirm = None; self.maximized = None; - // A rename in progress stores a fixed tab index; removing a tab shifts - // indices and would let the pending edit commit onto the wrong tab. Drop it. self.renaming = None; - // Capture the tab's cwd *before* its panes are killed (the daemon can't - // report it afterwards): if it sat in a tty7-managed worktree, the - // cleanup offer below needs it. let worktree_cwd = self.tab_host_cwd(index, window, cx); - // Snapshot the tab (layout + each pane's current cwd + name) onto the - // recently-closed stack so Cmd+Shift+T can bring it back. let snapshot = tab_to_session(&self.tabs[index], cx); self.closed.push(snapshot); if self.closed.len() > MAX_CLOSED_TABS { self.closed.remove(0); } - // Explicitly closing a tab kills its daemon panes (matching the old - // in-process behavior: closing ends the shells). This is distinct from - // *quitting* the app, where panes are detached and kept alive so the - // next launch can re-attach. Reopen-closed-tab then spawns fresh in the - // saved cwd, just like before the daemon split. for leaf in self.tabs[index].pane.terminals() { kill_pane_off_thread(leaf.read(cx).pane_route(), leaf.read(cx).pane_id, cx); } self.tabs.remove(index); if self.tabs.is_empty() { - // Home page: keep `active` at a stable 0 (every access goes through - // `tabs.get`, which yields None until a tab exists again). self.active = 0; } else if self.active >= self.tabs.len() { self.active = self.tabs.len() - 1; @@ -3859,31 +2588,15 @@ impl Tty7App { self.focus_active(window, cx); self.save_session(cx); cx.notify(); - // The tab is gone; if it lived in a tty7-managed worktree, offer to - // clean the checkout up rather than letting them pile up silently. self.offer_worktree_cleanup(worktree_cwd, cx); } - /// After closing a tab that sat in a tty7-managed worktree (see - /// [`crate::core::worktree::managed`]), offer to remove the checkout: a - /// clean worktree gets a plain keep/remove prompt; one with uncommitted - /// changes defaults to keeping and makes discarding explicit. Removal also - /// deletes the branch when it carries no unmerged commits (`branch -d`). - /// No offer while any surviving pane still has its cwd inside the checkout - /// (new tabs inherit the current cwd, so shared worktrees are common) — - /// removal would yank the directory out from under a live shell. - /// Detection, the dirty probe, and removal all run off the UI thread. fn offer_worktree_cleanup( &mut self, cwd: Option<(crate::ui::host_ops::SharedHost, std::path::PathBuf)>, cx: &mut Context<Self>, ) { let Some((host, cwd)) = cwd else { return }; - // Every leaf of every surviving tab, not just focused panes — a shell - // tucked away in a split occupies the worktree all the same. Only panes - // on the *same* host count: this list is what stops a worktree being - // removed out from under a live shell, and a cwd on another machine can - // neither occupy this worktree nor be compared against it meaningfully. let id = host.id(); let open_cwds: Vec<std::path::PathBuf> = self .tabs @@ -3894,10 +2607,6 @@ impl Tty7App { (view.host_id() == id).then(|| view.host_cwd())? }) .collect(); - // Two host round trips with a *user decision* between them, so it is two - // `HostOps` calls rather than one long background block: resolve what - // was closed, ask, then remove. Nothing here touches the host from the - // UI thread, and the prompt is awaited between the two. let remove_host = host.clone(); crate::ui::host_ops::HostOps::run( host, @@ -3963,11 +2672,6 @@ impl Tty7App { ); } - /// Close every tab except `index` ("Close Other Tabs"). Iterates from the - /// end so removals never shift an index still to visit. Tabs holding a live - /// warn-on-close SSH session are skipped outright — the per-tab confirm - /// sheet is keyed by index, which a bulk close would immediately - /// invalidate — so they simply survive the sweep. pub(crate) fn close_other_tabs( &mut self, index: usize, @@ -3985,9 +2689,6 @@ impl Tty7App { } } - /// Close every tab after `index` ("Close Tabs to the Right" / "Close Tabs - /// Below" in the sidebar). Same end-first iteration and warn-SSH skip as - /// [`close_other_tabs`](Self::close_other_tabs). pub(crate) fn close_tabs_right_of( &mut self, index: usize, @@ -4002,12 +2703,6 @@ impl Tty7App { } } - /// "Mark as Unread" (tab context menu): re-flag every finished (`Done`) - /// agent turn in the tab as unread, so the avatar's green dot swells back - /// into its count badge until the user next looks at those panes. The - /// active tab's focus target is told the dismissed menu is about to hand - /// focus back to it, so that focus-in doesn't immediately re-read the mark - /// (see `TerminalView::mark_agent_result_unread`). pub(crate) fn mark_tab_unread(&mut self, index: usize, cx: &mut Context<Self>) { use crate::core::cli_agent::AgentStatus; let Some(tab) = self.tabs.get(index) else { @@ -4027,9 +2722,6 @@ impl Tty7App { cx.notify(); } - /// The cwd of the tab's label-driving terminal (focused leaf, else first) — - /// what the tab context menu's "Copy Working Directory" copies and "New - /// Worktree Tab" derives the repo from. pub(crate) fn tab_cwd( &self, index: usize, @@ -4043,24 +2735,12 @@ impl Tty7App { .and_then(|leaf| leaf.read(cx).cwd()) } - /// Copy the active tab's working directory to the clipboard — the - /// `CopyWorkingDirectory` action behind the File menu, the palette, and the - /// tab context menu's row of the same name. A no-op when the pane has yet to - /// report a cwd, which is also when the context-menu row renders disabled. pub(crate) fn copy_active_cwd(&mut self, window: &Window, cx: &mut Context<Self>) { if let Some(cwd) = self.tab_cwd(self.active, window, cx) { cx.write_to_clipboard(gpui::ClipboardItem::new_string(cwd.display().to_string())); } } - /// What the tab's agent-session menu rows ("Fork Session" and "Copy Session - /// ID") need, together with the pane they were read from, or `None` when - /// the tab's label-driving pane runs no coding agent — then neither row is - /// offered. Reads the same leaf `tab_cwd` does, so all three rows agree on - /// which pane a tab-level action means. The pane comes back with the state - /// because the fork row has to *act* on it: by click time the popup menu - /// holds focus and sits outside every terminal's focus path, so resolving - /// the pane again there would fall back to the tab's first leaf. pub(crate) fn tab_agent_session( &self, index: usize, @@ -4078,9 +2758,6 @@ impl Tty7App { Some((leaf, session)) } - /// "Copy Session ID": put the agent's native session id on the clipboard — - /// the id `codex resume` / `claude --resume` take. A no-op when no agent - /// has reported one, which is also when the menu row renders disabled. pub(crate) fn copy_agent_session_id( &mut self, index: usize, @@ -4095,30 +2772,6 @@ impl Tty7App { } } - /// "Fork Session" (issue #211): branch the agent session running in - /// `source` — a leaf of tab `index` — into a second, independent one, - /// landing it per `placement`. - /// - /// The source pane is the caller's to resolve, because *when* it is - /// resolved differs by surface. The action paths (palette, menu bar, - /// keybinding, pane right-click menu) dispatch through the terminal's own - /// `action_context`, so the focused leaf read at dispatch time is the pane - /// the user is pointing at — [`fork_active_pane_session`](Self::fork_active_pane_session) - /// does that for them. The tab / sidebar context menu can't: by the time - /// its row is clicked the popup holds focus, so it captures the pane its - /// row was labelled for at menu-open time and hands it in here. - /// - /// The fork itself is entirely the agent's own — tty7 spawns a pane and - /// types the agent's fork command into it (`codex fork <id>`, `claude - /// --resume <id> --fork-session`, …), exactly as session restore types a - /// resume command. tty7 never reads or writes the agent's transcript files, - /// so a change to their on-disk format costs at most a visible shell error - /// in the new pane. - /// - /// Every reason a fork can't happen surfaces as a notification rather than - /// a silent no-op: the menu rows disable themselves for the same reasons, - /// but the action is also reachable from the palette, the menu bar and a - /// bound key, where there is no row to grey out. pub(crate) fn fork_agent_session( &mut self, index: usize, @@ -4131,18 +2784,10 @@ impl Tty7App { return; }; - // A split acts on the *active* tab's focused pane, so bring the - // right-clicked tab forward first — a no-op when it already is, and the - // same order the context menu's own Split rows use. Done before the - // terminal is created, since constructing one steals focus. if matches!(placement, ForkPlacement::Split { .. }) { self.activate(index, window, cx); } - // The fork inherits the source pane's directory and shell pick, like - // every other tty7 spawn. Deliberately *not* passed to the agent as a - // `--cd`: Codex has its own resume/fork cwd preference and this must - // not override the setting the user chose there. let (cwd, shell) = { let view = source.read(cx); (view.local_cwd(), view.shell_spec()) @@ -4164,13 +2809,6 @@ impl Tty7App { return; } }; - // Same hand-off session restore uses: the bytes queue in the PTY until - // the (still starting) shell reads them. - // - // A slot that is still connecting has no terminal to hand the command - // to. Forking gates on a *local* pane, so this is unreachable in - // practice — but say so rather than placing a pane that silently never - // forks. let Some(terminal) = new.terminal() else { log::error!("fork spawn produced a pane that is still connecting"); window.push_notification("Could not fork: the pane is still connecting", cx); @@ -4203,13 +2841,6 @@ impl Tty7App { cx.notify(); } - /// Fork the active tab's focused pane (else its first) per `placement` — - /// every surface that dispatches an action rather than clicking a captured - /// menu row. Those all route through the terminal's `action_context`, so - /// the focused leaf resolved here is the pane the user is pointing at. - /// Silently a no-op for a tab with no terminal; every other reason a fork - /// can't run is a notification from - /// [`agent_fork_command`](Self::agent_fork_command). pub(crate) fn fork_active_pane_session( &mut self, placement: ForkPlacement, @@ -4226,9 +2857,6 @@ impl Tty7App { self.fork_agent_session(self.active, source, placement, window, cx); } - /// Fork the active tab's focused pane into a split beside it — the pane - /// right-click menu's placement pick, and what a bound key means (the - /// focused pane is the one the user is pointing at). pub(crate) fn fork_focused_pane_session( &mut self, axis: Axis, @@ -4239,8 +2867,6 @@ impl Tty7App { self.fork_active_pane_session(ForkPlacement::Split { axis, before }, window, cx); } - /// The fork command to type into a new pane for `source`'s agent session, - /// or `None` after telling the user why there isn't one. fn agent_fork_command( &self, source: &Entity<TerminalView>, @@ -4278,10 +2904,6 @@ impl Tty7App { window.push_notification(format!("{name}'s session id isn't a plain token"), cx); return None; }; - // Agents fork from the *persisted* transcript, so a turn still in - // flight is simply absent from the fork (Codex documents that an - // in-progress turn cannot even be a fork point). Harmless — the parent - // is untouched — but the user must not have to discover it. if session.status == AgentStatus::Working { window.push_notification( format!("{name} is mid-turn — the fork won't include the turn in flight"), @@ -4291,22 +2913,11 @@ impl Tty7App { Some(cmd) } - /// An explicit "check now", from the App menu or the tray. Forced, so it - /// works even with the startup check turned off — "I asked" outranks "don't - /// ask on my behalf" — and it opens About, where the result lands. pub(crate) fn check_for_updates_now(&mut self, window: &mut Window, cx: &mut Context<Self>) { crate::core::update::spawn_check_forced(cx); self.open_settings_section(SettingsSection::About, window, cx); } - /// [`tab_cwd`](Self::tab_cwd) paired with the host that can answer for it — - /// for the worktree operations, which run `git` on the machine the - /// repository is actually on. `None` when the tab's pane has no cwd its own - /// host could be asked about (see - /// [`TerminalView::host_cwd`](crate::terminal::view::TerminalView::host_cwd)). - /// - /// "Copy Working Directory" deliberately keeps using `tab_cwd`: copying a - /// remote pane's remote path is exactly what the user wants there. fn tab_host_cwd( &self, index: usize, @@ -4318,17 +2929,6 @@ impl Tty7App { Some((view.host(cx)?, view.host_cwd()?)) } - /// Whether tab `index` sits inside a git repository — what gates the - /// context menu's "New Worktree Tab" entry. - /// - /// Read from the shared [`GitStatusCache`](crate::terminal::git_status::GitStatusCache) - /// rather than probed: menus are built synchronously on the UI thread, and - /// asking a host is a blocking call that on a remote machine is a round - /// trip. The cache already holds this answer — the pane's git line is - /// derived from the same probe — so the entry appears exactly when the - /// branch line does. `Some(None)` is a probe that answered "not a repo"; - /// `None` is "no probe has landed yet", which reads as no entry rather than - /// as an entry that would immediately fail. pub(crate) fn tab_is_in_repo(&self, index: usize, window: &Window, cx: &App) -> bool { let Some(leaf) = self .tabs @@ -4338,12 +2938,6 @@ impl Tty7App { return false; }; let view = leaf.read(cx); - // `git_status_cwd`, not the live `host_cwd`: the cache is *keyed* by - // the cwd the last probe was launched for, so asking it about the - // pane's current foreground cwd would miss for the whole window - // between a `cd` and the next probe landing — and the entry would - // vanish from the menu exactly when the user just navigated into a - // repository. let Some(cwd) = view.git_status_cwd() else { return false; }; @@ -4353,11 +2947,6 @@ impl Tty7App { .is_some() } - /// "New Worktree Tab": probe the repository containing the tab's cwd for - /// defaults (a fresh generated name, the current branch as start point) on - /// the background executor, then open the confirmation sheet - /// (`ui::worktree_prompt`) where name/branch/base can be edited before - /// anything is created. Failure to probe lands as a notification. pub(crate) fn new_worktree_tab( &mut self, index: usize, @@ -4368,9 +2957,6 @@ impl Tty7App { window.push_notification("This tab has no working directory yet", cx); return; }; - // The sheet keeps the host the defaults were probed from, so the create - // it eventually submits cannot end up on a different machine than the - // branch list it was filled from. let sheet_host = host.clone(); let probe_cwd = cwd.clone(); crate::ui::host_ops::HostOps::run_in( @@ -4385,10 +2971,6 @@ impl Tty7App { ); } - /// Open the tab for a just-created worktree: a default-shell terminal in - /// the worktree directory, with the tab pre-named after its branch so a - /// strip of parallel worktrees stays tellable-apart. Mirrors - /// `new_tab_with_shell`, minus the cwd inheritance (the cwd *is* the point). pub(crate) fn open_worktree_tab( &mut self, wt: crate::core::worktree::NewWorktree, @@ -4424,17 +3006,10 @@ impl Tty7App { cx.notify(); } - /// Rearrange the whole tab vector into `order` (old indices in their new - /// order) — the single path by which a drag-reorder lands, used by the - /// sidebar where a single visual move can imply a larger permutation - /// (relocating a tab inside its group without disturbing the group order, - /// or moving an entire group). Keeps the same tab active and re-persists. pub(crate) fn apply_tab_order(&mut self, order: &[usize], cx: &mut Context<Self>) { if order.len() != self.tabs.len() || order.iter().enumerate().all(|(i, &o)| i == o) { return; } - // Reordering shifts indices: a rename pending on a fixed one would - // commit onto the wrong tab. self.renaming = None; let was_active = self.active; let mut slots: Vec<Option<Tab>> = std::mem::take(&mut self.tabs) @@ -4447,8 +3022,6 @@ impl Tty7App { cx.notify(); } - /// Begin an inline rename of the tab at `index`: spawn a focused text input - /// pre-filled with the current label, committing on Enter or blur. pub(crate) fn start_rename( &mut self, index: usize, @@ -4477,8 +3050,6 @@ impl Tty7App { cx.notify(); } - /// Turn the title-bar workspace chip into a text field, seeded with the - /// current name. Committing on Enter or blur mirrors the tab rename. pub(crate) fn start_workspace_rename(&mut self, window: &mut Window, cx: &mut Context<Self>) { let current = crate::ui::machine_mirror::display_name_for(cx, self.workspace).unwrap_or_default(); @@ -4498,9 +3069,6 @@ impl Tty7App { cx.notify(); } - /// Commit the workspace rename. An empty value clears the custom name, so - /// the chip falls back to the derived repo name — the same "clear to - /// revert" contract the tab rename has. pub(crate) fn commit_workspace_rename(&mut self, window: &mut Window, cx: &mut Context<Self>) { let Some(rename) = self.workspace_rename.take() else { return; @@ -4514,10 +3082,6 @@ impl Tty7App { cx.notify(); } - /// Commit the in-progress rename: a non-empty value becomes the tab's custom - /// name; an empty value clears it (reverting to the title-derived label). - /// Taking `renaming` first makes the focus change below re-entrancy-safe (the - /// input's resulting Blur finds no active rename and returns). fn commit_rename(&mut self, window: &mut Window, cx: &mut Context<Self>) { let Some(renaming) = self.renaming.take() else { return; @@ -4532,13 +3096,7 @@ impl Tty7App { cx.notify(); } - // ----- Command palette ------------------------------------------------- - - /// Build the full command catalog: the static commands plus one - /// "Switch to Tab: …" entry per open tab (label matches the tab strip). fn palette_commands(&self, cx: &App) -> Vec<Command> { - // This window's own chrome state, not the config's copy of it — see - // `ChromeState`. let mut commands = Command::base_commands( cx, ChromeState { @@ -4547,8 +3105,6 @@ impl Tty7App { }, ); - // Saved SSH profiles, ordered by frecency then name (PRD FR-P3). Each row - // connects (natively) on Enter and edits on ⌘⏎ / →. let cfg = cx.global::<Config>(); let now = crate::core::config::unix_now(); let mut profiles: Vec<&crate::core::ssh_profile::SshProfile> = @@ -4582,16 +3138,7 @@ impl Tty7App { ); } - // Saved profiles are the palette's *only* SSH listing: `~/.ssh/config` - // hosts appear here after Settings → SSH → "Import from ~/.ssh/config" - // turns them into profiles, never as a parallel live-discovered source - // (two lists of the same hosts with different behaviors confused more - // than it helped). Typing an alias into "SSH: Add Connection…" still - // resolves it against ssh_config on the spot. - for (i, tab) in self.tabs.iter().enumerate() { - // Skip the active tab — "switch to the tab you're already on" is a - // no-op that only pads the list. if i == self.active { continue; } @@ -4607,15 +3154,11 @@ impl Tty7App { commands } - /// Open the palette if closed, or close it if already open (Cmd+P toggles). fn toggle_palette(&mut self, window: &mut Window, cx: &mut Context<Self>) { if self.palette.is_some() { self.close_palette(window, cx); return; } - // Build the catalog and hand it to a fresh palette view; it owns the - // search input, filtering and keyboard nav, and emits a `PaletteEvent` - // when the user confirms or dismisses. let commands = self.palette_commands(cx); let view = cx.new(|cx| PaletteView::new(commands, window, cx)); self.palette_sub = Some(cx.subscribe_in(&view, window, Self::on_palette_event)); @@ -4623,7 +3166,6 @@ impl Tty7App { cx.notify(); } - /// Run the confirmed command (or just close on dismiss) for the open palette. fn on_palette_event( &mut self, _view: &Entity<PaletteView>, @@ -4641,7 +3183,6 @@ impl Tty7App { } } - /// Close the palette and hand focus back to the active terminal. pub(crate) fn close_palette(&mut self, window: &mut Window, cx: &mut Context<Self>) { self.palette = None; self.palette_sub = None; @@ -4649,17 +3190,12 @@ impl Tty7App { cx.notify(); } - /// The focused terminal of the active tab, for palette commands that act on - /// the pane rather than the shell. The palette has already closed by the - /// time these run, so focus is back where the user left it. fn focused_leaf(&self, window: &Window, cx: &App) -> Option<Entity<TerminalView>> { self.tabs .get(self.active) .and_then(|t| t.pane.focused_or_first(window, cx)) } - /// Record that a palette command was run, for the palette's Recent band. - /// Only commands with a stable id are tracked (see `CommandKind::id`). fn bump_command_frecency(&mut self, kind: &CommandKind, cx: &mut Context<Self>) { let Some(id) = kind.id() else { return }; self.update_config(cx, |cfg| { @@ -4669,7 +3205,6 @@ impl Tty7App { }); } - /// Run a palette command by dispatching to the matching tab/pane operation. fn run_command(&mut self, kind: CommandKind, window: &mut Window, cx: &mut Context<Self>) { use CommandKind::*; self.bump_command_frecency(&kind, cx); @@ -4703,8 +3238,6 @@ impl Tty7App { ToggleRightPanel => self.toggle_right_panel(cx), ShowRightPanel(tab) => self.set_right_panel_tab(tab, cx), ResetFontSize => self.reset_font_size(cx), - // Pane-scoped commands act on the terminal the closing palette just - // handed focus back to. FindInTerminal => { if let Some(leaf) = self.focused_leaf(window, cx) { leaf.update(cx, |view, cx| view.open_search(window, cx)); @@ -4727,8 +3260,6 @@ impl Tty7App { } CopyText => { if let Some(leaf) = self.focused_leaf(window, cx) { - // `false`: a menu/palette copy leaves the highlight up. Only - // the dual-purpose ⌃C chord has to consume the selection. leaf.update(cx, |view, cx| { view.copy_contextual(false, cx); }); @@ -4793,18 +3324,11 @@ impl Tty7App { OpenSshProfiles => self.open_settings_section(SettingsSection::Ssh, window, cx), SendSelectionToAgent => self.send_selection_to_agent(window, cx), SendGitDiffToAgent => self.send_git_diff_to_agent(window, cx), - // Handled inside `PaletteView` (opens a sub-list); these never emit a - // `Confirm` for this variant, so they never reach here. OpenThemePicker | OpenSshConnectInput => {} ActivateTab(i) => self.activate(i, window, cx), } } - // ----- Agent context feed (palette: "Agent: …") ------------------------- - - /// The pane the agent-feed commands deliver to: the first leaf running a - /// recognized coding agent, preferring the active tab, then any tab. `None` - /// when no agent runs anywhere. pub(crate) fn agent_target_leaf(&self, cx: &App) -> Option<Entity<TerminalView>> { let runs_agent = |leaf: &Entity<TerminalView>| leaf.read(cx).agent().is_some(); if let Some(tab) = self.tabs.get(self.active) @@ -4820,8 +3344,6 @@ impl Tty7App { .find(runs_agent) } - /// Deliver `prompt` into the agent pane's PTY and bring that pane's tab to - /// the front so the user sees the turn start. Toasts when no agent runs. fn deliver_agent_prompt(&mut self, prompt: &str, window: &mut Window, cx: &mut Context<Self>) { let Some(target) = self.agent_target_leaf(cx) else { crate::terminal::notify_desktop( @@ -4840,8 +3362,6 @@ impl Tty7App { } } - /// "Agent: Send Selection" — the focused pane's selection, phrased as a - /// prompt, into the running agent's pane (the context-feed idea). fn send_selection_to_agent(&mut self, window: &mut Window, cx: &mut Context<Self>) { let source = self .tabs @@ -4866,16 +3386,11 @@ impl Tty7App { } } - /// "Agent: Send Git Diff for Review" — the focused pane's repo diff - /// (unstaged + staged), phrased as a review prompt, into the agent's pane. fn send_git_diff_to_agent(&mut self, window: &mut Window, cx: &mut Context<Self>) { let pane = self .tabs .get(self.active) .and_then(|t| t.pane.focused_or_first(window, cx)); - // The pane's own host answers, so a pane whose repository lives on - // another machine gets *its* diff rather than "no known directory". - // What is still refused is a cwd no host in this process can answer for. let target = pane.and_then(|view| { let view = view.read(cx); Some((view.host(cx)?, view.host_cwd()?)) @@ -4884,21 +3399,11 @@ impl Tty7App { crate::terminal::notify_desktop(Some("tty7"), "This pane has no known directory."); return; }; - // Off the UI thread, unlike before: two `git diff` runs against a big - // repository froze the window for as long as they took, and against a - // remote host they would be two round trips. This is the one visible - // change — the menu item now returns immediately and the prompt is - // delivered when the diff lands. crate::ui::host_ops::HostOps::run_in( host, window, cx, move |h| { - // Unstaged + staged, concatenated — "everything not yet - // committed", which is what a review pass wants. A failed - // invocation contributes an empty string, exactly as it did - // when this shelled out directly: a diff that cannot be read is - // reported as "no uncommitted changes", not as an error. let run = |args: &[&str]| { h.git(&cwd, args) .ok() @@ -4921,18 +3426,11 @@ impl Tty7App { ); } - // ----- Settings tab (Cmd+,) ------------------------------------------- - - /// Toggle the settings overlay (Cmd+,). If it's already open, close it; - /// otherwise assemble its widget state (each control pre-filled from config, - /// with its subscriptions pushed onto `subs`) and focus the page. fn toggle_settings(&mut self, window: &mut Window, cx: &mut Context<Self>) { if self.settings.is_some() { self.close_settings(window, cx); return; } - // Settings is about to steal focus; snapshot the active pane so closing - // it lands back on the same terminal rather than the tab's first leaf. self.remember_active_pane(window, cx); let focus_handle = cx.focus_handle(); let mut subs = Vec::new(); @@ -4943,8 +3441,6 @@ impl Tty7App { let link_file_command_input = self.build_link_file_command_input(&mut subs, window, cx); let scroll_slider = self.build_scroll_slider(&mut subs, window, cx); let window_opacity_slider = self.build_window_opacity_slider(&mut subs, window, cx); - // Live filter for the theme picker panel; each keystroke re-renders the - // (already-cheap) list so results narrow as you type. let theme_search = cx.new(|cx| InputState::new(window, cx).placeholder("Search themes…")); subs.push( cx.subscribe_in(&theme_search, window, |_this, _i, ev, _w, cx| { @@ -4953,8 +3449,6 @@ impl Tty7App { } }), ); - // Live filter for the nav-header settings search; each keystroke re-renders - // the (cheap) nav rail so the result list narrows as you type. let settings_search = cx.new(|cx| InputState::new(window, cx).placeholder("Search settings…")); subs.push( @@ -4966,8 +3460,6 @@ impl Tty7App { }), ); - // Live filter for the SSH section's host list; each keystroke re-renders - // the master column so the list narrows as you type. let ssh_filter = cx.new(|cx| InputState::new(window, cx).placeholder("Filter hosts…")); subs.push( cx.subscribe_in(&ssh_filter, window, |_this, _i, ev, _w, cx| { @@ -4977,8 +3469,6 @@ impl Tty7App { }), ); - // The SSH empty state's quick-connect box. Its Connect button enables only - // on a parsable target, so each keystroke re-renders the pane. let ssh_quick_connect = cx.new(|cx| InputState::new(window, cx).placeholder("user@host or user@host:port")); subs.push( @@ -5019,9 +3509,6 @@ impl Tty7App { agent_hooks_note: None, _subs: subs, }); - // Land the caret in the search box so Settings opens ready to type/filter - // (a blinking cursor), rather than on the inert page root. Escape still - // closes — the root's key handler is an ancestor of the focused input. let search_focus = self .settings .as_ref() @@ -5030,13 +3517,11 @@ impl Tty7App { Some(handle) => window.focus(&handle, cx), None => window.focus(&focus_handle, cx), } - // Build the color editor if we opened straight onto an editable theme. self.rebuild_theme_editor(window, cx); self.ensure_agent_hooks_loaded(cx); cx.notify(); } - /// Primary / bold / italic font-family pickers, seeded from config. fn build_font_selects( &mut self, subs: &mut Vec<Subscription>, @@ -5051,10 +3536,6 @@ impl Tty7App { let family = cfg.font_family.clone(); let font_bold = cfg.font_family_bold.clone(); let font_italic = cfg.font_family_italic.clone(); - // Every font the OS reports is selectable — we don't get to decide - // that for the user. The picker's dropdown just caps its own height - // (see `menu_max_h` in settings.rs) so browsing the full list doesn't - // dump it all on screen at once; search still reaches everything. let mut font_names = cx.text_system().all_font_names(); if !font_names.contains(&family) { font_names.push(family.clone()); @@ -5073,9 +3554,6 @@ impl Tty7App { ) .searchable(true) }); - // Bold / italic pickers share the font list but prepend a "Default" row - // (the `FONT_DEFAULT_LABEL` sentinel) so the user can clear a distinct - // face back to synthesized emphasis. let build_alt_font_select = |value: &Option<String>, names: &[String], window: &mut Window, @@ -5129,7 +3607,6 @@ impl Tty7App { (font_select, font_bold_select, font_italic_select) } - /// Shell program/args and working-directory inputs, committing on Enter/blur. fn build_shell_inputs( &mut self, subs: &mut Vec<Subscription>, @@ -5137,8 +3614,6 @@ impl Tty7App { cx: &mut Context<Self>, ) -> (Entity<InputState>, Entity<InputState>, Entity<InputState>) { let cfg = cx.global::<Config>(); - // Pre-fill the shell inputs from config; an unset `shell` leaves them - // empty so the placeholders advertise the platform default. let (shell_program, shell_args) = match &cfg.shell { Some(s) => (s.program.clone(), s.args.join(" ")), None => (String::new(), String::new()), @@ -5192,7 +3667,6 @@ impl Tty7App { (shell_program_input, shell_args_input, wd_path_input) } - /// File-open command template input (Links section), committing on Enter/blur. fn build_link_file_command_input( &mut self, subs: &mut Vec<Subscription>, @@ -5219,8 +3693,6 @@ impl Tty7App { input } - /// Persist the file-open command template from the Links settings input. An - /// empty value clears the override (falls back to the built-in open). fn commit_link_file_command(&mut self, cx: &mut Context<Self>) { let Some(command) = self.active_settings().map(|s| { s.link_file_command_input @@ -5238,16 +3710,13 @@ impl Tty7App { }; let cfg = cx.global_mut::<Config>(); if cfg.link_file_command == command { - return; // no change — avoid a redundant disk write on every Blur + return; } cfg.link_file_command = command; cfg.save(); cx.notify(); } - /// Window-opacity slider for the Appearance page (20%–100%). Emits `Change` - /// continuously as the user drags; each tick sets the global override and - /// repaints, so the translucency is live under the thumb. fn build_window_opacity_slider( &mut self, subs: &mut Vec<Subscription>, @@ -5272,8 +3741,6 @@ impl Tty7App { slider } - /// Mouse-scroll multiplier slider (0.5×–5×). Emits `Change` continuously as - /// the user drags; each writes + persists the multiplier. fn build_scroll_slider( &mut self, subs: &mut Vec<Subscription>, @@ -5300,8 +3767,6 @@ impl Tty7App { scroll_slider } - /// Close the settings overlay (Esc inside the panel, or Cmd+, again), - /// dropping its widget state and returning focus to the active terminal. pub(crate) fn close_settings(&mut self, window: &mut Window, cx: &mut Context<Self>) { if self.settings.take().is_some() { self.focus_active(window, cx); @@ -5309,9 +3774,6 @@ impl Tty7App { } } - /// Open Settings focused on `section`, opening the overlay if it's closed. - /// Unlike `toggle_settings`, this never closes an already-open Settings — the - /// entry points that jump to a specific section (e.g. SSH profiles) use it. pub(crate) fn open_settings_section( &mut self, section: SettingsSection, @@ -5324,8 +3786,6 @@ impl Tty7App { self.select_settings_section(section, cx); } - /// Open Settings → SSH with `id`'s profile loaded into the inline edit form - /// (the ⌘⏎ / Edit affordance on a saved profile). pub(crate) fn open_ssh_profile_in_settings( &mut self, id: uuid::Uuid, @@ -5344,8 +3804,6 @@ impl Tty7App { } } - /// Open Settings → SSH with a new profile seeded from a QuickConnect target - /// ("save as profile"), ready to edit and save. pub(crate) fn open_ssh_profile_new_from_target( &mut self, target: String, @@ -5367,7 +3825,6 @@ impl Tty7App { self.ssh_form_load(&profile, window, cx); } - /// Apply the picked font family live to every terminal and persist it. fn commit_font_family(&mut self, family: String, cx: &mut Context<Self>) { self.font_family = family.clone(); for tab in &self.tabs { @@ -5382,9 +3839,6 @@ impl Tty7App { cx.notify(); } - /// Apply a distinct bold or italic face (or clear it back to synthesized - /// emphasis when the `FONT_DEFAULT_LABEL` sentinel is picked) live to every - /// pane, and persist it. `bold == true` targets the bold face, else italic. fn commit_font_family_emphasis(&mut self, bold: bool, name: String, cx: &mut Context<Self>) { let family = (name != crate::ui::settings::FONT_DEFAULT_LABEL).then_some(name); for tab in &self.tabs { @@ -5414,24 +3868,7 @@ impl Tty7App { cx.notify(); } - /// Re-apply hot-reloaded config to every live pane. Wired to - /// `observe_global::<Config>`, so an external edit to `config.json` — picked - /// up by the watcher in `main.rs`, which swaps the `Config` global — flows to - /// the on-screen terminals without a restart. This complements `apply_theme` - /// (which already handles the color side) by covering the font knobs that - /// live on `Tty7App`/the panes: size, line height, and family. - /// - /// Each field is diffed against the currently-applied value and skipped when - /// unchanged. That keeps this a no-op for the much more frequent case where - /// *our own* code mutated the global (every font setter and `set_preset` - /// writes it), and — because we never write the global or `save()` from here - /// — closes the save → watch → reload loop that would otherwise oscillate. fn reload_from_config(&mut self, window: &mut Window, cx: &mut Context<Self>) { - // Re-apply the theme with the window in hand: the watcher task calls - // `apply_theme(None)` (colors/palette), but the window-bound effects — the - // Transparent↔Blurred background flip and traffic-light re-pinning — only - // happen here. Also keeps the Appearance opacity slider's thumb on a value - // that was hand-edited in `config.json` or the theme file. apply_theme(Some(window), cx); self.sync_window_opacity_slider(window, cx); let config = cx.global::<Config>().clone(); @@ -5453,8 +3890,6 @@ impl Tty7App { .map(crate::core::config::gpui_font_features), ) }; - // Keep the runtime sidebar width in step with the config (an external - // edit to `config.json`, or our own drag-end persist which re-fires this). self.sidebar_width.set(cx.global::<Config>().sidebar_width); self.right_panel_width .set(cx.global::<Config>().right_panel_width); @@ -5521,9 +3956,6 @@ impl Tty7App { } } } - // Mouse-reporting is cached per view (the gates run without a `cx`), so a - // hot-reload must push it into every open pane. Diffed per leaf so an - // unrelated config edit doesn't churn panes that already agree. let report_mouse = cx.global::<Config>().mouse_reporting; for tab in &self.tabs { for leaf in tab.pane.terminals() { @@ -5538,11 +3970,6 @@ impl Tty7App { cx.notify(); } - /// Persist the shell program + args from the settings inputs. An empty - /// program clears the override (`shell: None`), so the daemon falls back to - /// the platform default. Only newly spawned panes pick this up — the daemon - /// reads `config.json` fresh on each PTY spawn — so running shells are - /// untouched. There's nothing to apply live here; we just save. fn commit_shell(&mut self, cx: &mut Context<Self>) { let Some(settings) = self.active_settings() else { return; @@ -5567,15 +3994,13 @@ impl Tty7App { }; let cfg = cx.global_mut::<Config>(); if cfg.shell == shell { - return; // no change — avoid a redundant disk write on every Blur + return; } cfg.shell = shell; cfg.save(); cx.notify(); } - /// Change the working-directory strategy. Only affects newly spawned panes - /// (the daemon reads `config.json` fresh per spawn), like the shell setting. pub(crate) fn set_working_directory_strategy( &mut self, strategy: crate::core::config::WdStrategy, @@ -5590,9 +4015,6 @@ impl Tty7App { cx.notify(); } - /// Persist the custom working-directory path from the settings input. Only - /// used when the strategy is `Custom`, but stored regardless so switching back - /// restores it. fn commit_working_directory_path(&mut self, cx: &mut Context<Self>) { let Some(path) = self .active_settings() @@ -5609,10 +4031,6 @@ impl Tty7App { cx.notify(); } - /// The active tab's settings state, if it is the settings tab. - /// The open settings page's state, if the overlay is showing. The single - /// accessor every settings widget/handler reads, so the rest of the settings - /// code is agnostic to where the state lives. pub(crate) fn active_settings(&self) -> Option<&SettingsState> { self.settings.as_ref() } @@ -5621,31 +4039,18 @@ impl Tty7App { self.settings.as_mut() } - /// The status-dot colour for a tab whose representative pane is an SSH - /// session (PRD FR-E2), as an RGB value from the same hardcoded semantic - /// palette as [`AgentStatus::dot_rgb`] — not the theme's UI tokens, which - /// in this app are soft neutral fills (accent is the list-selection grey) - /// and read as no state at all. Native panes are phase-coloured - /// (connecting = amber, connected = green, failed/disconnected = red); a - /// foreground `ssh` typed into a shell gets a plain neutral dot. `None` - /// for non-SSH tabs (no dot). - /// - /// [`AgentStatus::dot_rgb`]: crate::core::cli_agent::AgentStatus::dot_rgb pub(crate) fn tab_ssh_dot(&self, tab: &Tab, cx: &App) -> Option<u32> { use crate::daemon::protocol::SshPhase; let leaf = tab.pane.first_leaf()?; - // No dot for a pane still connecting: the SSH phase it would report is - // the *pane's* SSH, and it has none yet. let v = leaf.terminal()?.read(cx); if let Some(phase) = v.ssh_phase() { - // Native pane. let rgb = if v.ssh_disconnected() { - 0xEF4444 // red: link lost + 0xEF4444 } else { match phase { - SshPhase::Connecting | SshPhase::Authenticating => 0xF59E0B, // amber: in flight - SshPhase::Connected => 0x22C55E, // green: link up - SshPhase::Failed { .. } => 0xEF4444, // red: never made it + SshPhase::Connecting | SshPhase::Authenticating => 0xF59E0B, + SshPhase::Connected => 0x22C55E, + SshPhase::Failed { .. } => 0xEF4444, } }; Some(rgb) @@ -5653,18 +4058,12 @@ impl Tty7App { .remote_context() .is_some_and(|r| r.kind != crate::daemon::protocol::RemoteKind::Wsl) { - // A foreground `ssh` typed into a shell: a plain neutral dot. The - // kind check matters: a WSL pane also carries a `RemoteContext` (so - // its cwd is treated as foreign — see `local_cwd`), but it is not an - // SSH session and this dot means "SSH". Some(0x9CA3AF) } else { None } } - /// Whether `leaf` is a live, connected native-SSH pane whose effective - /// warn-on-close is on (per-profile override, else the global toggle). fn leaf_is_warn_ssh(&self, leaf: &Entity<TerminalView>, cx: &App) -> bool { use crate::daemon::protocol::SshPhase; let v = leaf.read(cx); @@ -5682,7 +4081,6 @@ impl Tty7App { per_profile.unwrap_or(cfg.ssh_warn_on_close) } - /// Whether the tab at `index` holds any live warn-on-close SSH pane (FR-E3). pub(crate) fn tab_has_warn_ssh(&self, index: usize, cx: &App) -> bool { self.tabs .get(index) @@ -5695,7 +4093,6 @@ impl Tty7App { .unwrap_or(false) } - /// Whether the focused pane is a live warn-on-close SSH pane (FR-E3). pub(crate) fn focused_pane_is_warn_ssh(&self, window: &Window, cx: &App) -> bool { self.tabs .get(self.active) @@ -5704,7 +4101,6 @@ impl Tty7App { .unwrap_or(false) } - /// Proceed with a pending SSH-close after confirmation (FR-E3). pub(crate) fn confirm_ssh_close(&mut self, window: &mut Window, cx: &mut Context<Self>) { match self.ssh_close_confirm { Some(SshCloseKind::Tab(i)) => self.close_tab(i, window, cx), @@ -5713,17 +4109,11 @@ impl Tty7App { } } - /// Dismiss the SSH-close confirmation, leaving the session open (FR-E3). pub(crate) fn cancel_ssh_close(&mut self, cx: &mut Context<Self>) { self.ssh_close_confirm = None; cx.notify(); } - /// The focused pane when it is an SSH session of either kind. - /// - /// Not every pane carrying a `RemoteContext` is one: a WSL pane has one too, - /// so that its cwd is treated as foreign (see `TerminalView::local_cwd`), - /// and it must not reach anything SSH-shaped from here. pub(crate) fn active_ssh_pane( &self, window: &Window, @@ -5739,10 +4129,6 @@ impl Tty7App { (remote.kind != crate::daemon::protocol::RemoteKind::Wsl).then_some((pane.pane_id, remote)) } - /// The focused pane when it is a *connected native* SSH session — the gate for - /// the pane's tunnel / SFTP action buttons (top-right of the terminal body). - /// `None` for a foreground `ssh`, a still-connecting native pane, or a non-SSH - /// pane, so those never grow the action buttons. pub(crate) fn active_connected_native_ssh_pane( &self, window: &Window, @@ -5761,7 +4147,6 @@ impl Tty7App { matches!(leaf.read(cx).ssh_phase(), Some(SshPhase::Connected)).then_some((pane_id, remote)) } - /// Select a sidebar section in the settings page (no-op when it's closed). pub(crate) fn select_settings_section( &mut self, target: SettingsSection, @@ -5769,11 +4154,7 @@ impl Tty7App { ) { if let Some(s) = self.settings.as_mut() { s.section = target; - // Leaving the Keybindings page abandons any in-progress capture, so - // the interceptor doesn't keep swallowing keys off-screen. s.recording = None; - // Entering Agents re-reads the hook install states, so edits made - // behind the panel's back (another tty7, a hand edit) show up. if target == SettingsSection::Agents { s.agent_hooks_states = crate::ui::settings::AgentHooksView::Loading; } @@ -5782,11 +4163,6 @@ impl Tty7App { cx.notify(); } - /// Read the Agents page's rows, but only when that is the page on screen. - /// - /// Gated on the section because the read is a config file per agent — on a - /// remote machine, a round trip per agent — and opening Settings on - /// Appearance has no business paying for six of those. fn ensure_agent_hooks_loaded(&mut self, cx: &mut Context<Self>) { if self .active_settings() @@ -5796,13 +4172,6 @@ impl Tty7App { } } - /// The machines the Agents section offers: this computer, then every remote - /// machine this process is connected to right now. - /// - /// Only connected ones, because a hook install *is* a write to that - /// machine's disk — there is nothing to offer without a link. The ones that - /// are configured but offline are named under the picker instead of being - /// silently dropped from it. pub(crate) fn agent_hooks_machines( &self, cx: &mut App, @@ -5812,9 +4181,6 @@ impl Tty7App { host: crate::ui::host_ops::HostId::LOCAL, label: "This Computer".to_string(), }]; - // The label is the name the user gave the box; `HostId` alone is a - // hash. `available_hosts` is the same lookup the workspace switcher - // does for exactly this reason. let configured = crate::ui::remote_connect::available_hosts(cx); for id in crate::ui::host_registry::HostRegistry::ids(cx) { if id.is_local() { @@ -5830,14 +4196,6 @@ impl Tty7App { out } - /// How many machines the Agents picker cannot offer because nothing is - /// connected to them. - /// - /// Saved SSH profiles only — not the `~/.ssh/config` aliases - /// [`available_hosts`](crate::ui::remote_connect::available_hosts) also - /// returns. A config with fifty `Host` blocks is normal and most of them are - /// git transports that could never host a workspace; counting those would - /// turn a helpful footnote into "50 machines aren't connected". pub(crate) fn agent_hooks_offline_count(&self, cx: &mut App) -> usize { let connected = crate::ui::host_registry::HostRegistry::ids(cx); cx.global::<Config>() @@ -5850,7 +4208,6 @@ impl Tty7App { .count() } - /// Point the Agents section at another machine and read its state. pub(crate) fn select_agent_hooks_host( &mut self, host: crate::ui::host_ops::HostId, @@ -5861,7 +4218,6 @@ impl Tty7App { return; } s.agent_hooks_host = host; - // The note belonged to the machine we just left. s.agent_hooks_note = None; s.agent_hooks_states = crate::ui::settings::AgentHooksView::Loading; } @@ -5869,13 +4225,6 @@ impl Tty7App { cx.notify(); } - /// Read every hook-capable agent's install state off the selected machine, - /// in the background, and land the rows when they arrive. - /// - /// Background because a `Host` call blocks and on a remote machine that is a - /// round trip *per agent* — six of them, on a link that may be an ocean - /// wide. Doing it inline is the window freeze this codebase has already - /// fixed twice. fn load_agent_hooks_states(&mut self, cx: &mut Context<Self>) { use crate::core::agent_hooks::{HookAgent, HookTarget}; use crate::ui::settings::{AgentHookRow, AgentHooksView}; @@ -5936,20 +4285,11 @@ impl Tty7App { ); } - /// What the Agents section says when the machine it is pointed at has no - /// live connection. One string, because the picker's footnote and the - /// rows' resting state have to agree. const AGENT_HOOKS_OFFLINE: &'static str = concat!( "Not connected to this machine, so its agent config can't be read or ", "written. Open a workspace on it and come back." ); - /// The host object and remote home for the machine the Agents section is - /// pointed at, or `None` when it is a remote that is no longer connected. - /// - /// `None` for the home means "this computer" — the local target reads its - /// own environment, which is the one place `$CLAUDE_CONFIG_DIR` and - /// `$XDG_CONFIG_HOME` are ours to honor. fn agent_hooks_link( &self, host_id: crate::ui::host_ops::HostId, @@ -5966,9 +4306,6 @@ impl Tty7App { Some((host, Some(home))) } - /// Settings → Agents: install (or rewrite in place) one agent's hooks on the - /// selected machine, then fold the outcome back into the panel — status row - /// + note line. pub(crate) fn settings_install_agent_hooks( &mut self, agent: crate::core::agent_hooks::HookAgent, @@ -5977,7 +4314,6 @@ impl Tty7App { self.run_agent_hooks_action(agent, true, cx); } - /// Settings → Agents: remove one agent's tty7 hooks (user hooks survive). pub(crate) fn settings_uninstall_agent_hooks( &mut self, agent: crate::core::agent_hooks::HookAgent, @@ -5986,14 +4322,6 @@ impl Tty7App { self.run_agent_hooks_action(agent, false, cx); } - /// Install or uninstall one agent's hooks on the selected machine, then - /// re-read that machine's states — the ground truth, whatever the action - /// just did — and surface the action's own summary or error as the note - /// under its row. - /// - /// Writing is a `Host` call too, so it takes the same background trip as the - /// read: an install into `~/.claude/settings.json` on a remote box is a read - /// and a write over the control connection. fn run_agent_hooks_action( &mut self, agent: crate::core::agent_hooks::HookAgent, @@ -6047,11 +4375,6 @@ impl Tty7App { ); } - /// Keep the settings selection on a section that has search hits: if the - /// query changed and the current section no longer matches, jump to the - /// best-matching one so the shown page always reflects the search. A section - /// that still has matches is left alone, so the user's own click isn't yanked - /// away as they keep typing. pub(crate) fn autoselect_settings_search(&mut self, cx: &mut Context<Self>) { let Some(settings) = self.settings.as_ref() else { return; @@ -6068,26 +4391,16 @@ impl Tty7App { } } - // ----- Keybindings editing (Settings → Keybindings) -------------------- - - /// Begin capturing a new shortcut for `action`: install a keystroke - /// interceptor that swallows the next keypress and records it, and stash it - /// on the settings state so it stays active only while recording. Any prior - /// capture is replaced. pub(crate) fn start_recording_key( &mut self, action: String, _window: &mut Window, cx: &mut Context<Self>, ) { - // The interceptor fires app-wide *before* keymap dispatch, so a chord - // like ⌘T is captured here instead of opening a new tab. It runs until - // the returned `Subscription` is dropped (capture done / Esc / cancel). let this = cx.weak_entity(); let intercept = cx.intercept_keystrokes(move |ev, _window, cx| { let keystroke = ev.keystroke.clone(); let _ = this.update(cx, |this, cx| this.on_record_key(&keystroke, cx)); - // Keep the key from also triggering an action / reaching a surface. cx.stop_propagation(); }); self.record_gen = self.record_gen.wrapping_add(1); @@ -6102,11 +4415,6 @@ impl Tty7App { cx.notify(); } - /// Handle a keystroke captured during recording. Esc cancels. Backspace - /// removes the last captured chord, or — with nothing captured yet — resets - /// the action to its default. Any other key appends a chord and (re)starts - /// the pause-to-commit timer, so single chords and sequences (e.g. the tmux - /// preset's `ctrl-b x`) are recorded the same way. fn on_record_key(&mut self, keystroke: &gpui::Keystroke, cx: &mut Context<Self>) { let Some((action, has_chords)) = self .active_settings() @@ -6122,7 +4430,6 @@ impl Tty7App { } "backspace" | "delete" => { if has_chords { - // Edit the sequence: drop the last chord and keep capturing. if let Some(r) = self .active_settings_mut() .and_then(|s| s.recording.as_mut()) @@ -6136,7 +4443,6 @@ impl Tty7App { if still_has { self.schedule_recording_commit(cx); } else { - // Nothing left to commit; wait for a fresh keypress. self.record_gen = self.record_gen.wrapping_add(1); } cx.notify(); @@ -6148,8 +4454,6 @@ impl Tty7App { } _ => {} } - // A lone modifier press (⌘ held, no key yet) has nothing to bind — keep - // waiting for a real key. let Some(spec) = crate::ui::keymap::spec_from_keystroke(keystroke) else { return; }; @@ -6163,9 +4467,6 @@ impl Tty7App { cx.notify(); } - /// (Re)arm the pause-to-commit timer: after a short quiet window with no new - /// chord, the captured sequence is committed. Bumping `record_gen` first - /// invalidates any earlier timer, so only the latest keypress's timer fires. fn schedule_recording_commit(&mut self, cx: &mut Context<Self>) { self.record_gen = self.record_gen.wrapping_add(1); let generation = self.record_gen; @@ -6180,8 +4481,6 @@ impl Tty7App { .detach(); } - /// Commit the captured chords (joined into a sequence spec) as the action's - /// override. A no-op if capture ended or nothing was captured. fn commit_recording(&mut self, cx: &mut Context<Self>) { let Some((action, chords)) = self .active_settings() @@ -6195,8 +4494,6 @@ impl Tty7App { self.assign_keybinding(action, chords.join(" "), cx); } - /// Drop the active capture (interceptor released, any pending commit timer - /// invalidated) without changing anything. fn stop_recording(&mut self, cx: &mut Context<Self>) { self.record_gen = self.record_gen.wrapping_add(1); if let Some(s) = self.active_settings_mut() { @@ -6205,15 +4502,7 @@ impl Tty7App { cx.notify(); } - /// Assign `spec` to `action`. If another action already owns that keystroke, - /// unbind it (last-writer-wins would otherwise be order-dependent) and note - /// the takeover so the user can undo it with a reset. fn assign_keybinding(&mut self, action: String, spec: String, cx: &mut Context<Self>) { - // Find the current owner of this exact keystroke, if it isn't `action`. - // The extra chords installed alongside the table (an action can ship a - // second default it has no row for) count as owners too — miss them and - // the new binding would quietly take a chord off an action whose - // Settings row still advertises its first one. let displaced = crate::ui::keymap::effective_bindings(cx) .into_iter() .chain(crate::ui::keymap::extra_bindings(cx)) @@ -6228,8 +4517,6 @@ impl Tty7App { }); self.update_config(cx, |cfg| { if let Some(other) = &displaced { - // Explicit empty override = "unbound" (distinct from a reset, - // which would restore that action's default and re-conflict). cfg.keybindings.insert(other.clone(), String::new()); } cfg.keybindings.insert(action, spec); @@ -6241,8 +4528,6 @@ impl Tty7App { cx.notify(); } - /// Reset one action to its built-in default (drop its override) and - /// re-install the keymap. pub(crate) fn reset_keybinding(&mut self, action: String, cx: &mut Context<Self>) { self.update_config(cx, |cfg| { cfg.keybindings.remove(&action); @@ -6255,7 +4540,6 @@ impl Tty7App { cx.notify(); } - /// Clear every keybinding override, restoring the full default table. pub(crate) fn restore_default_keybindings(&mut self, cx: &mut Context<Self>) { self.update_config(cx, |cfg| cfg.keybindings.clear()); crate::ui::keymap::rebind(cx); @@ -6266,8 +4550,6 @@ impl Tty7App { cx.notify(); } - /// Switch the keybinding preset ("default" / "tmux") and re-install the - /// keymap so the change is live immediately. pub(crate) fn set_keybinding_preset(&mut self, preset: &str, cx: &mut Context<Self>) { let preset = preset.to_string(); self.update_config(cx, |cfg| cfg.keybinding_preset = preset); @@ -6279,8 +4561,6 @@ impl Tty7App { cx.notify(); } - /// Set the tmux preset's prefix chord (e.g. `ctrl-b` / `ctrl-a`) and - /// re-install the keymap. pub(crate) fn set_keybinding_prefix(&mut self, prefix: &str, cx: &mut Context<Self>) { let prefix = prefix.to_string(); self.update_config(cx, |cfg| cfg.prefix = prefix); @@ -6288,11 +4568,6 @@ impl Tty7App { cx.notify(); } - /// Open `config.json` with the OS default handler (Settings → Keybindings). - /// A fresh install may never have saved yet, so write the current config - /// first — the button must not point at a missing file. - // The "Open config file" button was temporarily pulled from the UI; keep the - // handler around so re-enabling it is a one-line change in `settings.rs`. #[allow(dead_code)] pub(crate) fn open_config_file(&self, cx: &Context<Self>) { let Some(path) = crate::core::config::config_path("config.json") else { @@ -6313,48 +4588,20 @@ impl Tty7App { } } - /// Open the GitHub Releases page in the browser — the "Download" action of - /// the Settings → About update prompt. Deliberately hand-off, not - /// self-update: the newest build is one click away on the web. Delegates to - /// `core::update` so the settings button and the update modal share it. pub(crate) fn open_releases_page(&self) { crate::core::update::open_releases_page(); } } -/// A per-thread count of how many times the window has drawn, for the -/// render-idle tests (issue #243). -/// -/// A window that has settled draws once and stops. Anything that notifies from -/// the paint path turns that into an unbounded loop — the window never reaches -/// render idle, and on a compositor that presents every frame the panel visibly -/// flickers. The seam is testable headlessly because gpui's test build redraws -/// dirty windows inside `flush_effects`: a paint that notifies simply keeps that -/// loop running, so counting draws across a quiet interval answers the question -/// without a real window. -/// -/// The limit of that: `window.request_animation_frame()` does not drive frames -/// under the test platform. It registers a next-frame callback that only a real -/// platform window's frame callback drains, so `cx.notify()` is the only thing -/// this counts. A repaint loop driven by an animation frame would run right past -/// this probe and needs a different instrument. -/// -/// Thread-local rather than a global: `#[gpui::test]` cases run in parallel on -/// their own threads, and gpui drives each one's foreground work on the thread -/// that owns its `TestAppContext`. #[cfg(test)] pub(crate) mod render_probe { use std::cell::Cell; thread_local! { static DRAWS: Cell<u64> = const { Cell::new(0) }; - /// When set, the draw that exceeds it panics instead of spinning. A - /// repaint loop lives entirely inside one `flush_effects` call, so - /// without this a regression hangs the suite rather than failing it. - static BUDGET: Cell<Option<u64>> = const { Cell::new(None) }; + static BUDGET: Cell<Option<u64>> = const { Cell::new(None) }; } - /// One window draw. Called from [`Tty7App::render`]. pub(crate) fn record() { let n = DRAWS.get() + 1; DRAWS.set(n); @@ -6369,31 +4616,17 @@ pub(crate) mod render_probe { } } - /// Start counting from zero, failing the test if `budget` draws pass. pub(crate) fn arm(budget: u64) { DRAWS.set(0); BUDGET.set(Some(budget)); } - /// Draws since [`arm`]. pub(crate) fn draws() -> u64 { DRAWS.get() } } impl Tty7App { - /// The status strip, on a window that has tabs. - /// - /// `ui::home` draws the same line on an *empty* remote window; this is the - /// one that matters, because the rule — a window that loses its machine - /// keeps showing what it had — only means anything when there is something - /// to keep showing. Both read the same - /// [`RemoteStatus::strip_message`](crate::ui::remote_workspace::RemoteStatus::strip_message), - /// so the two surfaces cannot word it differently. - /// - /// Top-centre, deliberately away from the bottom notice: one says what is - /// happening to the connection, the other what it means for the keyboard, - /// and stacking them would read as one long apology. fn render_remote_workspace_strip(&self, cx: &mut Context<Self>) -> Option<gpui::AnyElement> { if self.tabs.is_empty() { return None; @@ -6447,17 +4680,6 @@ impl Tty7App { ) } - /// The bottom line: 未连接 — 输入暂不生效. - /// - /// It exists because the degrade is otherwise invisible. Everything a - /// disconnected window *can* still do — scroll, select, copy, ⌘F — works - /// exactly as before, so the only observable difference is that typing - /// stops doing anything, and a terminal that silently ignores keystrokes is - /// indistinguishable from one that has hung. - /// - /// Not a place to offer buffering (D6): the notice says input has no effect - /// because it has none, and a "queued" variant of this line would be a - /// promise to replay keystrokes into a screen that has moved on. fn render_remote_input_notice(&self, cx: &mut Context<Self>) -> Option<gpui::AnyElement> { if self.tabs.is_empty() { return None; @@ -6493,24 +4715,9 @@ impl Tty7App { } } -/// Which SSH connection a forward is established on: the pane's own, or — for a -/// remote workspace — the **workspace's** (M7). -/// -/// The same shape and the same reason as -/// [`SftpRoute`](crate::ui::sftp::SftpRoute): resolved on the UI thread from the -/// pane entity, then used wherever the request actually goes out. Both arms end -/// at the same `SshManager` on the local daemon; only the owner differs, and the -/// owner is what decides the lifetime — a pane's forwards die with the pane, a -/// workspace's outlive every pane in it. -/// -/// **List, add and remove all take the same arm.** That is the property worth -/// protecting: a route that listed over the workspace and added over the pane -/// would produce a band you can read but not write, which is strictly worse than -/// the empty band a remote workspace showed before this existed. #[derive(Clone, Debug, Default)] pub(crate) struct ForwardRoute { pane_id: u64, - /// `None` is the pane arm — an SSH pane, or a plain local shell. workspace: Option<crate::terminal::PaneWorkspace>, } @@ -6526,12 +4733,6 @@ impl ForwardRoute { ) } - /// A workspace reply, unwrapped to the forward list it should carry. - /// - /// A daemon that answers something else — most often `Error("workspace is - /// not connected …")` — yields an empty list and a log line rather than a - /// panic or a stale list: the band showing nothing is the truthful rendering - /// of "this workspace's connection is gone". fn forwards( reply: anyhow::Result<crate::daemon::protocol::DaemonMsg>, ) -> Vec<crate::daemon::protocol::ManagedForward> { @@ -6568,12 +4769,6 @@ impl ForwardRoute { Self::forwards(crate::terminal::RemoteTerminal::on_workspace(req)) } - /// Drop every forward the workspace owns (they outlive the - /// panes, so something has to end them when the workspace does). - /// - /// A no-op on the pane arm, and that is correct rather than a gap: a pane's - /// forwards die with the pane through the daemon's own - /// `teardown_pane_forwards`, so there is nothing here to duplicate. pub(crate) fn teardown(&self) -> Vec<crate::daemon::protocol::ManagedForward> { let Some(req) = self.workspace_op(crate::daemon::protocol::WorkspaceOp::TeardownForwards) else { @@ -6596,65 +4791,30 @@ impl Render for Tty7App { fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { #[cfg(test)] render_probe::record(); - // `TTY7_PROFILE`: time the whole window build and, via the aggregated - // call rate, expose whether it is rebuilding on real changes or in a - // notify loop. A settled window should report no calls at all; anything - // above zero on a window nobody is touching is the shape of issue #243, - // and this is how a report of it can be checked on the machine that sees - // it rather than inferred from the code. let prof = crate::ui::perf::enabled().then(std::time::Instant::now); - // A live drag-reorder commits when the drag *ends*, which in gpui means - // the mouse was released (nothing else clears an active drag): the first - // frame without one retires the preview and applies the order it was - // last showing. Deliberately not an `on_drop` handler — those only fire - // when the pointer is over that particular element at release, so a - // release a hair outside the rail or the strip would silently lose the - // move. What you were looking at is what you get, wherever you let go. - // One place at the root covers every surface that can start a drag. if cx.has_active_drag() { - // Still dragging: forget last frame's answer so only what this - // frame actually draws can be committed (see `clear_pending`). crate::ui::reorder::clear_pending(&self.reorder); } else if let Some(order) = crate::ui::reorder::take_pending(&self.reorder) { self.apply_tab_order(&order, cx); } - // While a tab or group is in hand, the cursor is a closed hand for the - // whole window. It has to be set on the *drag* rather than styled on - // the element: gpui overrides every hovered element's cursor with the - // active drag's for the duration, and that override is `None` — a plain - // arrow — unless something fills it in. Set once per drag (it forces a - // refresh, so re-setting it every frame would spin). if self.reorder.borrow().is_some() && cx.active_drag_cursor_style() != Some(gpui::CursorStyle::ClosedHand) { cx.set_active_drag_cursor_style(gpui::CursorStyle::ClosedHand, window); } - // Vertical-tab mode: the sidebar owns the tab list, so the title-bar strip - // drops its chips (keeping only "+"/"⋯"). Gated on having tabs — the - // zero-tab home page keeps the full-width horizontal layout, so an empty - // rail never appears. let vertical = matches!(cx.global::<Config>().tab_bar_position, TabBarPosition::Left) && !self.tabs.is_empty(); - // The rail can be collapsed away without leaving `Left` mode. When it is, - // the layout below has no left column, so the title strip takes over the - // rail's jobs: it reserves the traffic lights and carries the sidebar's - // own controls (new tab + expand) at its left edge. let rail = vertical && !self.sidebar_collapsed; let strip = self.tab_strip(!vertical, window, cx); let sidebar = rail.then(|| self.tab_sidebar(window, cx)); - // Native-SSH status strip / reconnect notice for the focused pane (E1/E4). let ssh_status = self .tabs .get(self.active) .and_then(|t| t.pane.focused_or_first(window, cx)) .and_then(|leaf| self.render_ssh_status_strip(&leaf, cx)); - // Render the active tab's pane tree. let body = match self.tabs.get(self.active) { - // Zero tabs: the window's own face — the home page (see `ui::home`). None => self.render_home(cx).into_any_element(), Some(active_tab) => { - // If a pane is maximized and it belongs to the active tab, render - // just that leaf full-window; otherwise the normal split layout. let maximized = self.maximized.as_ref().filter(|leaf| { active_tab .pane @@ -6669,8 +4829,6 @@ impl Render for Tty7App { .child(leaf.clone()) .into_any_element(), None => { - // Fading the unfocused panes only says anything once the - // tab is actually split, and the user can turn it off. let dim_inactive = active_tab.pane.leaves().len() > 1 && cx.global::<Config>().dim_inactive_panes; active_tab.pane.render(dim_inactive, window, cx) @@ -6679,13 +4837,7 @@ impl Render for Tty7App { } }; - // The title strip (a transparent unified title bar carrying `strip`) and - // the terminal body area — shared by both layouts. let title_bar = TitleBar::new() - // Taller than the stock 34px bar so the tabs read substantial and - // roomy instead of cramped. `.h(..)` lands in the component's - // `refine_style`, applied after its own `.h(TITLE_BAR_HEIGHT)`, so - // this override wins. .h(px(TITLE_BAR_HEIGHT)) .bg(cx.theme().transparent) .border_color(cx.theme().transparent) @@ -6695,56 +4847,27 @@ impl Render for Tty7App { .relative() .overflow_hidden() .child(body) - // Nothing of the SSH tooling floats over the terminal any more: port - // forwarding is a band on the detail panel's Info tab, the remote file - // browser is its Files tab, and transfers are the panel's footer. That - // also gives the ⌘F find bar the top-right slot back — it used to have - // to fight the tunnel/SFTP icons for it. - // - // In-pane native-SSH auth / host-key sheet (WS3), shown over the pane - // that raised the prompt. .when_some(self.render_ssh_prompt_overlay(window, cx), |this, el| { this.child(el) }) - // Native-SSH status strip / reconnect notice (E1/E4). .when_some(ssh_status, |this, el| this.child(el)) - // The remote *workspace*'s own state. A sibling of the - // SSH pane strip rather than a merge: that one is about one pane's - // ssh process, this is about the machine the whole window is on, and - // a window can legitimately show both. .when_some(self.render_remote_workspace_strip(cx), |this, el| { this.child(el) }) .when_some(self.render_remote_input_notice(cx), |this, el| { this.child(el) }) - // Live-SSH close-confirmation sheet (E3). .when_some(self.render_ssh_close_confirm_overlay(cx), |this, el| { this.child(el) }) - // "New Worktree Tab" confirmation sheet (from the tab context menu). .when_some(self.render_worktree_prompt_overlay(cx), |this, el| { this.child(el) }); - // Working-tree diff overlay — mounted on the *column*, not on - // `body_area`, so it covers the title strip too and reads as one - // surface the way the code overlay does. Like that overlay it stops at - // the rail and the right panel (both are siblings), which is the point: - // the sidebar's git lines stay clickable to switch repo, and the - // Changes list stays put so you can walk down it file by file. let diff_overlay = self.render_diff_overlay(window, cx); - // Code panel: an immersive overlay ([file tree | editor], IDE-style) - // covering the title strip *and* the terminal — the whole column right - // of the tab sidebar — so nothing of the terminal chrome distracts. - // The terminal underneath keeps its size (no PTY resize/reflow), and - // the sidebar stays visible: switching tabs re-roots the tree. let code_overlay = self.render_code_overlay(window, cx); - // The two column overlays, ordered so the one the user last acted on is - // the later child and therefore paints on top. Neither outranks the - // other by construction. let overlays: Vec<gpui::AnyElement> = { let mut pair = vec![ (OverlayTop::Diff, diff_overlay), @@ -6760,57 +4883,19 @@ impl Render for Tty7App { pair.into_iter().filter_map(|(_, el)| el).collect() }; - // The layout. The rail (vertical mode) is a full-height *left column* that - // reaches the very top of the window — the traffic lights sit on its - // surface — with the title strip and terminal stacked in the right column. - // That way the rail surface has no seam with the title bar and reads as one - // continuous panel. - // - // The right detail panel does the same on macOS: a full-height column - // carrying its own title-bar-height top zone (tab row + the window's corner - // chrome), so its surface runs unbroken from the very top of the window. - // - // Off macOS it can't. The window controls (─ ▢ ✕) are laid out by the title - // bar itself, at *its* right end, so a full-height panel beside the bar - // strands them mid-window with the panel's grey to their right. There the - // bar spans the panel too — reaching the real top-right corner, where - // Windows and Linux users expect the controls — and the panel hangs below - // it, VS Code style. The panel's tab row then sits on the panel (no longer - // in the caption row), and the corner chrome stays in the strip. let right_panel = self.render_right_panel(window, cx); let panel_below_title_bar = right_panel.is_some() && !cfg!(target_os = "macos"); - // Which host the bar goes to: the terminal column's first child, or the - // spanning row above [terminal | panel]. let (column_title_bar, spanning_title_bar) = if panel_below_title_bar { (None, Some(title_bar)) } else { (Some(title_bar), None) }; - // And where the overlays hang. Normally on the terminal column, which they - // fill: the bar is that column's first child, so an `inset_0` overlay - // covers it and the overlay's own header row lands *on* the caption line — - // which is what both headers are drawn for (title-bar height, the bar's - // insets, a full-size chrome tile for their one control). - // - // With the bar hoisted, a column-anchored overlay starts 40px down and its - // header sits one row too low: level with the panel's tab row instead of - // with the caption. So it hangs on the row that owns the bar instead, - // inset from the right by the panel's width — covering the bar's band over - // the terminal column (which carries nothing there but the drag region, or - // the rail's controls while it's collapsed: exactly what an overlay covers - // with the panel closed) and stopping short of the panel, so the ─ ▢ ✕ - // group and the corner chrome keep their own surface and their clicks. let (column_overlays, hoisted_overlays) = if panel_below_title_bar { (Vec::new(), overlays) } else { (overlays, Vec::new()) }; let panel_px = self.right_panel_px(window, cx); - // The terminal column, and the anchor for both overlays: they fill it — - // and, since the panel is a sibling rather than a child, stop short of the - // panel for free. With the bar spanning above, they stop short of it too, - // which keeps the native controls clickable while an overlay is open and - // lines the overlay's own header row up with the panel's tab row. let terminal_column = div() .flex_1() .min_w_0() @@ -6841,23 +4926,8 @@ impl Render for Tty7App { .min_w_0() .flex() .flex_col() - // The containing block for the hoisted overlays below. .relative() .child( - // The bar's own band over the panel, painted in the panel's - // surface so the column still reads as one continuous - // sidebar from the very top of the window — the rail's - // trick, kept now that the tab row moved off the caption - // line. Without it the panel's grey started 40px down and - // the corner tore into two colours. - // - // A sibling *under* the transparent bar rather than padding - // inside it: the ─ ▢ ✕ group is the bar's own last child, so - // nothing laid out in the bar can get behind the controls, - // and only a layer below can carry a surface under them. - // Same width and left border as the panel, both read from - // `right_panel_px`, so the edge stays in register through a - // resize drag. div() .relative() .flex_none() @@ -6875,10 +4945,6 @@ impl Render for Tty7App { .child(bar), ) .child(panel_row) - // Last child, so they paint over both the bar and the column. - // Each overlay is `absolute().inset_0()` against this wrapper, - // which is the only thing that has to know where the panel - // starts. .children(hoisted_overlays.into_iter().map(|overlay| { div() .absolute() @@ -6893,32 +4959,16 @@ impl Render for Tty7App { }) .into_any_element(); - // The real window background paint: gradient-aware and opacity-carrying - // (see `theme::window_background`), plus the theme's optional background - // image. Falls back to the component theme's solid before the first - // `apply_theme` has published the global. let (window_bg, bg_image) = match cx.try_global::<crate::ui::presets::ActiveBackground>() { Some(bg) => (window_background(bg), bg.image.clone()), None => (cx.theme().background.into(), None), }; - // Settings is a full-window overlay (not a tab): it covers the tab rail, - // title strip, and terminal so it never crowds the tab list. `occlude` - // blocks input to the elements behind it. It fills the window edge to - // edge — its own nav sidebar reserves the title-bar zone internally (so - // that rail reaches the top like the tab rail), rather than insetting the - // whole page here. let settings_overlay = self.settings.is_some().then(|| { div() .absolute() .inset_0() .occlude() - // Same gradient-aware paint as the root, so a gradient theme's - // settings page doesn't snap to a flat color. A translucent - // theme's alpha rides along, letting the background image show - // through here too. This second layer compounds the alpha over - // the root's paint — deliberate: the overlay must occlude the - // terminal behind it to stay readable. .bg(window_bg) .child(self.render_settings(window, cx)) }); @@ -6975,14 +5025,9 @@ impl Render for Tty7App { this.delete_workspace(id, window, cx); })) .on_action(cx.listener(|_this, _: &NewWorkspace, _window, cx| { - // A fresh workspace, not a copy of this one: the daemon gives - // each pane a single subscriber, so a second window onto the - // same panes would steal this window's output. crate::ui::windows::open(cx, None); })) .on_action(cx.listener(|this, _: &CloseActiveTab, window, cx| { - // With focus in the editor panel, ⌘W closes the active file - // tab instead of the terminal pane/tab. if !this.editor_close_active_if_focused(window, cx) { this.close_pane(window, cx) } @@ -7124,10 +5169,6 @@ impl Render for Tty7App { .on_action(cx.listener(|this, _: &EditorSave, window, cx| { this.editor_save_active(window, cx) })) - // Quit lives on the same element-tree action path as every other Cmd - // shortcut above, so a focused terminal routes `cmd-q` here rather - // than relying solely on the global handler (which the keystroke - // doesn't reach while focus is deep in the terminal view). .on_action(cx.listener(|_, _: &Quit, _, cx| cx.quit())) .on_action(cx.listener(|this, _: &OpenSshProfiles, window, cx| { this.open_settings_section(SettingsSection::Ssh, window, cx) @@ -7135,9 +5176,6 @@ impl Render for Tty7App { .on_action(cx.listener(|this, _: &RestartSshSession, window, cx| { this.restart_ssh_session(window, cx) })) - // Tab operations that used to be reachable only by right-clicking a - // chip. Each targets the active tab, so the menu bar / palette / - // keyboard all mean "this tab" without a click to say which. .on_action(cx.listener(|this, _: &RenameTab, window, cx| { this.start_rename(this.active, window, cx) })) @@ -7156,9 +5194,6 @@ impl Render for Tty7App { .on_action(cx.listener(|this, _: &MarkTabUnread, _window, cx| { this.mark_tab_unread(this.active, cx) })) - // Fork: the bare action has no pane the user pointed at, so it - // opens a new tab; the four directional ones come from the pane - // right-click menu, where the ask *was* spatial. .on_action(cx.listener(|this, _: &ForkAgentSession, window, cx| { this.fork_active_pane_session(ForkPlacement::NewTab, window, cx) })) @@ -7177,9 +5212,6 @@ impl Render for Tty7App { .on_action(cx.listener(|this, _: &CopyAgentSessionId, window, cx| { this.copy_agent_session_id(this.active, window, cx) })) - // Settings destinations that deserve their own way in: Help → - // Keyboard Shortcuts and the App menu's About both used to require - // opening Settings and then hunting for the section. .on_action(cx.listener(|this, _: &ShowKeyboardShortcuts, window, cx| { this.open_settings_section(SettingsSection::Keybindings, window, cx) })) @@ -7189,8 +5221,6 @@ impl Render for Tty7App { .on_action(cx.listener(|this, _: &CheckForUpdates, window, cx| { this.check_for_updates_now(window, cx) })) - // Standard macOS App / Window menu items. gpui exposes the platform - // calls but ships no actions for them. .on_action(cx.listener(|_, _: &HideApp, _window, cx| cx.hide())) .on_action(cx.listener(|_, _: &HideOthers, _window, cx| cx.hide_other_apps())) .on_action(cx.listener(|_, _: &ShowAll, _window, cx| cx.unhide_other_apps())) @@ -7198,17 +5228,11 @@ impl Render for Tty7App { cx.listener(|_, _: &MinimizeWindow, window, _cx| window.minimize_window()), ) .on_action(cx.listener(|_, _: &ZoomWindow, window, _cx| window.zoom_window())) - // Help destinations. Opened in the default browser; a failure here is - // not worth interrupting the user over, so it is logged, not toasted. .on_action( cx.listener(|_, _: &OpenDocumentation, _window, cx| cx.open_url(DOCS_URL)), ) .on_action(cx.listener(|_, _: &OpenDiscord, _window, cx| cx.open_url(DISCORD_URL))) .on_action(cx.listener(|_, _: &ReportIssue, _window, cx| cx.open_url(ISSUES_URL))) - // The theme's background image, composited over the background fill - // at its own opacity and under all content. Absolute, so it doesn't - // participate in the flex column; the wrapper clips the Cover - // overflow (gpui's `img` paints the fitted bounds unclipped). .when_some(bg_image, |this, image| { this.child( div() @@ -7224,16 +5248,9 @@ impl Render for Tty7App { ) }) .child(main_layout) - // Settings overlay, above the tabs/terminal when open. .when_some(settings_overlay, |this, overlay| this.child(overlay)) - // The workspace switcher, in the same layer as the palette: they - // answer two different questions and are never open at once. .children(self.render_switcher(cx)) - // Command palette overlay, layered above everything when open. .when_some(self.palette.clone(), |this, palette| this.child(palette)) - // Toast layer for `window.push_notification` (worktree/SSH errors). - // gpui-component's Root only *stores* the list; the root view must - // render the layer — without this child every toast was invisible. .children(gpui_component::Root::render_notification_layer(window, cx)); if let Some(start) = prof { @@ -7243,26 +5260,15 @@ impl Render for Tty7App { } } -/// Convert a live `Tab` (pane tree + name) into its serializable mirror. fn tab_to_session(tab: &Tab, cx: &App) -> SessionTab { SessionTab { name: tab.name.clone(), pane: pane_to_session(&tab.pane, cx), sidebar_group: tab.sidebar_group.borrow().clone(), - // Deliberately not the live tab's tree id. This snapshot outlives the - // daemon tab it mirrors (the closed-tab stack, the session file), and - // rebuilding from it is a *new* tab everywhere it matters. tree_id: None, } } -/// The command that puts a saved coding-agent conversation back, or `None` when -/// there is nothing to resume (no agent, no captured session id, the agent opts -/// out of sessions, or the user turned the feature off). -/// -/// Shared by the two places that learn a pane came back as a bare shell: session -/// restore, for a local leaf whose daemon already said the pane was gone, and -/// [`Tty7App::land_pane`], for a remote one where only the machine could say. fn agent_resume_command( agent: &Option<crate::core::cli_agent::CLIAgent>, session_id: Option<&str>, @@ -7273,12 +5279,6 @@ fn agent_resume_command( return None; } let agent = agent.as_ref()?; - // Said out loud, because it is the one step of the restore nobody can - // reconstruct afterwards: the pane comes back as a bare shell either way, - // and whether that is "no id was ever captured" (hooks not installed on - // that machine, or its record lost them) or "the agent declined to resume" - // is the whole diagnosis. A leaf that ran no agent at all is the ordinary - // case and says nothing. let Some(session_id) = session_id else { log::info!( "{}'s pane had no captured session id; it comes back as a plain shell", @@ -7289,26 +5289,14 @@ fn agent_resume_command( agent.resume_command(session_id, launch_argv) } -/// Convert a live `Pane` tree into its serializable mirror, reading each -/// leaf's current cwd and each split's axis + ratio. Used when saving. fn pane_to_session(pane: &Pane, cx: &App) -> SessionPane { match pane { - // A pane still connecting has no terminal to interrogate — but it does - // know which pane it is trying to re-attach to, and that is the whole - // of what restore needs. Quitting mid-connect therefore comes back to - // the same pane rather than dropping it from the layout. Pane::Leaf(PaneSlot::Connecting(pending)) => { let spawn = &pending.read(cx).spawn; SessionPane::Leaf { cwd: spawn.working_directory.clone(), pane_id: spawn.restore_pane, ssh_spec: None, - // Written back out rather than blanked. A remote pane spends - // its first seconds here, and any save landing in that window - // used to drop the agent this leaf was running — after which - // ending the workspace's sessions left nothing to resume from. - // The pane cannot be interrogated yet, but what it is being - // rebuilt *from* is right here. agent: spawn.agent, agent_session_id: spawn.agent_session_id.clone(), agent_launch_argv: spawn.agent_launch_argv.clone(), @@ -7317,26 +5305,9 @@ fn pane_to_session(pane: &Pane, cx: &App) -> SessionPane { Pane::Leaf(PaneSlot::Ready(view)) => { let view = view.read(cx); SessionPane::Leaf { - // A restored pane whose daemon pane is gone respawns through - // `new_terminal(workspace, …)` — on the *same* machine, since - // restore carries the window's workspace — so a remote - // workspace's cwd is right to keep. What must not be kept is a - // native-SSH or WSL pane's: those come back on the default - // *local* shell (a shell pick isn't persisted), which cannot - // chdir into the other machine's path. Native-SSH panes - // reconnect from `ssh_spec` and the daemon discards the cwd for - // them anyway (`server::SpawnNativeSsh`). cwd: view.spawnable_cwd(), pane_id: Some(view.pane_id), - // Persist the secret-free native-SSH spec so a *dead* pane can be - // reconnected on restore (FR-E4/C2); `None` for local panes. A - // live pane reattaches by `pane_id` and never needs this. ssh_spec: view.ssh_spec(), - // The running agent + its native session id (when its hooks - // reported one), so a pane the daemon loses can resume the - // agent conversation instead of just reopening a shell. The - // observed launch argv rides along so the resume command keeps - // the user's flags (`--dangerously-skip-permissions`, …). agent: view.agent(), agent_session_id: view.agent_session().and_then(|s| s.session_id), agent_launch_argv: view.agent_session().and_then(|s| s.launch_argv), @@ -7353,8 +5324,6 @@ fn pane_to_session(pane: &Pane, cx: &App) -> SessionPane { a: Box::new(pane_to_session(a, cx)), b: Box::new(pane_to_session(b, cx)), }, - // A transient `Empty` should never be persisted; mirror it as a bare - // leaf so restore still yields a usable terminal. Pane::Empty => SessionPane::Leaf { cwd: None, pane_id: None, @@ -7366,30 +5335,9 @@ fn pane_to_session(pane: &Pane, cx: &App) -> SessionPane { } } -/// The pane ids currently alive **on `route`'s machine**, each with the -/// workspace that owns it (when the daemon knows — `None` for panes spawned by -/// builds/daemons that predate `pane-owner`). Used by `session_to_pane` to -/// decide per leaf whether to re-`attach` or `spawn`. Computed once per restore -/// from that daemon's `List`; empty (→ all-fresh) when it is unreachable. -/// -/// There is deliberately no unrouted sibling. Pane ids are **per daemon**: -/// asking this machine's daemon which of a remote workspace's ids are alive -/// answers about whatever local panes happen to hold those numbers. At restore -/// that is not a cosmetic error — a leaf would `Attach` to a stranger's pane and -/// put their shell on screen — and the display sites that used to take the -/// unrouted answer now go through -/// [`pane_liveness`](crate::terminal::pane_liveness), which cannot spell the -/// question without naming a machine. pub(crate) fn alive_panes_on( route: &crate::terminal::PaneRoute, ) -> std::collections::HashMap<u64, Option<String>> { - // **Local routes only.** This is a *blocking* `List`, and every caller is on - // the UI thread — which is fine against a socket on this machine and is a - // multi-second window freeze against one that has to open an SSH channel - // first. A remote workspace answers the same question per pane instead, in - // the background half of its spawn: `start_pane_spawn` tries the attach and - // falls back to a fresh pane when the id is gone, which is exactly what - // this set was being consulted for. if !matches!(route, crate::terminal::PaneRoute::Local) { return std::collections::HashMap::new(); } @@ -7400,16 +5348,6 @@ pub(crate) fn alive_panes_on( .collect() } -/// Whether `owner`'s window may re-attach the live pane `id` — the ownership -/// gate on session restore. -/// -/// The failure this closes: two workspace records claiming one pane id (a -/// corrupted layout store), or a stale id landing on an unrelated pane after -/// the numbers were reused. Before the daemon knew owners, both cases attached -/// — one workspace's window silently picked up another's shell, which is how -/// `work`'s seven tabs once ended up duplicated into `personal`. A pane with no -/// recorded owner (older daemon, legacy spawn) stays attachable by anyone — -/// that is today's behavior, not a new risk. fn pane_attachable( alive: &std::collections::HashMap<u64, Option<String>>, id: u64, @@ -7431,11 +5369,6 @@ fn pane_attachable( } } -/// Rebuild the tab list from a persisted `Session`, re-attaching to still-live -/// daemon panes where possible and spawning fresh shells otherwise. An absent or -/// empty session yields no tabs (the home page). Shared by first-launch restore -/// (`Tty7App::for_workspace`) and the daemon-restart rebuild (`restart_daemon`), so the two -/// stay in lockstep. fn tabs_from_session( workspace: Option<&crate::terminal::PaneWorkspace>, owner: WorkspaceId, @@ -7447,13 +5380,9 @@ fn tabs_from_session( let Some(session) = session.filter(|s| !s.tabs.is_empty()) else { return (Vec::new(), 0); }; - // Ask *this workspace's* daemon once which panes are still alive, so leaves - // re-attach to surviving shells instead of all spawning fresh. let alive = alive_panes_on(&crate::terminal::PaneRoute::for_workspace(workspace)); let mut tabs: Vec<Tab> = Vec::with_capacity(session.tabs.len()); for st in &session.tabs { - // A tab whose every leaf failed to come back has nothing to show; drop - // it rather than restore an empty frame (or, worse, abort the launch). let Some(pane) = session_to_pane(workspace, owner, &st.pane, &alive, font_size, window, cx) else { log::error!("dropping a restored tab: no pane in it could be started"); @@ -7466,50 +5395,21 @@ fn tabs_from_session( diff_overlay: None, code: None, overlay_top: OverlayTop::default(), - // Seed the sticky group from the saved session so the sidebar - // renders grouped on the first frame; the first landed probe - // corrects it if the tab's repo changed while we were gone. sidebar_group: std::cell::RefCell::new(st.sidebar_group.clone()), - // A session lowered from the machine's tree names its daemon tabs; - // keeping those ids is what stops the first save from closing and - // recreating every one of them. tree_id: std::cell::Cell::new( st.tree_id .unwrap_or_else(tty7_core::core::machine::TabId::new), ), }); } - // Clamp the saved active index into the rebuilt range (which can be empty - // when nothing restored). let active = session.active.min(tabs.len().saturating_sub(1)); (tabs, active) } -/// Whether a restored leaf's saved `pane_id` names a pane in the same daemon -/// the caller read its `alive` set from — the window's daemon. -/// -/// Pane ids are unique only *within* a daemon, so the question is not academic: -/// looking one up in the wrong set is how a saved id silently matches somebody -/// else's live pane and the restore attaches to it. -/// -/// A native-SSH leaf is the one case where a pane does not live in its window's -/// daemon. Its russh session is spawned by **this client's** daemon however the -/// window is bound, so in a remote workspace it belongs to a different machine -/// than every other leaf around it — and its id must not be matched against the -/// remote's pane list. It reconnects from its saved spec instead, which is what -/// it does for any id that is no longer live. fn leaf_shares_the_window_daemon(window_is_remote: bool, leaf_is_native_ssh: bool) -> bool { !(window_is_remote && leaf_is_native_ssh) } -/// Rebuild a live `Pane` tree from a saved `SessionPane`. A leaf whose saved -/// `pane_id` is still alive in the daemon re-`attach`es (process + scrollback -/// intact); otherwise it spawns a fresh shell in the saved cwd. `alive` is the -/// daemon's current pane set, computed once by the caller. -/// -/// `None` when nothing under this node could be started (an unreachable -/// daemon): restore drops what it can't rebuild instead of leaving `Empty` -/// nodes — which every tree operation ignores — in a live tab. fn session_to_pane( workspace: Option<&crate::terminal::PaneWorkspace>, owner: WorkspaceId, @@ -7528,38 +5428,21 @@ fn session_to_pane( agent_session_id, agent_launch_argv, } => { - // 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. - // - // …and `alive` is *one* daemon's pane set, so a leaf whose pane - // lives in a different one must not be looked up in it. let same_daemon = leaf_shares_the_window_daemon(workspace.is_some(), ssh_spec.is_some()); let restore = match workspace.is_some() { - // A remote leaf keeps its id unconditionally: `alive` is empty - // for a remote route by construction (see `alive_panes_on`), and - // the attempt to attach happens off the UI thread, where a dead - // id costs one failed round trip and falls back to a spawn. true => (*pane_id).filter(|_| same_daemon), false => (*pane_id).filter(|id| same_daemon && pane_attachable(alive, *id, owner)), }; - // A *dead* native-SSH leaf (spec persisted, pane no longer alive) - // reconnects rather than dropping back to a local shell (FR-C2/E4): - // re-resolve secrets from the profile when it names one, else reuse - // the secret-free spec and let the auth sheets prompt. if restore.is_none() { if let Some(spec) = ssh_spec.clone() { let resolved = crate::ui::ssh_connect::resolve_persisted_ssh_spec(spec, cx); match new_terminal_native(font_size, cwd.clone(), resolved, window, cx) { Ok(view) => return Some(Pane::leaf(PaneSlot::Ready(view))), - // Keep restore alive: fall through to a local shell in - // this slot rather than aborting startup. Err(e) => log::error!("restoring native SSH pane failed: {e}"), } } } - // A shell pick isn't persisted in the session, so a stale pane that - // must respawn comes back on the default shell. let view = match new_terminal( workspace.cloned(), Some(owner), @@ -7576,20 +5459,7 @@ fn session_to_pane( return None; } }; - // A pane that could NOT re-attach lost its running agent with the - // daemon; when we captured that agent's native session id, hand - // the fresh shell its resume command so the conversation picks up - // where it left off (cmux's auto-resume, config-gated). The bytes - // sit in the PTY input queue until the shell reads its first - // command — same mechanism as tmux send-keys at spawn. match &view { - // A local leaf already knows the answer, and the *view* is what - // knows it. Not `restore.is_none()`: `alive` is read once at the - // top of the restore, so a pane that exits between that `List` - // and this attach fails into a fresh shell inside - // `spawn_shell_terminal_in` — which used to land here as - // "restore.is_some(), so it kept its agent", leaving an empty - // shell and a conversation nobody resumed. PaneSlot::Ready(terminal) if !terminal.read(cx).restored() => { if let Some(cmd) = agent_resume_command( agent, @@ -7601,11 +5471,6 @@ fn session_to_pane( } } PaneSlot::Ready(_) => {} - // A remote leaf does not know yet whether its id was still - // good — the attach is happening on a background thread. The - // agent travels with the attempt and `land_pane` decides. This - // is also what keeps a save landing mid-connect from erasing - // it (see `pane_to_session`). PaneSlot::Connecting(pending) => { pending.update(cx, |pending, _| { pending.spawn.agent = *agent; @@ -7621,8 +5486,6 @@ fn session_to_pane( SessionAxis::Horizontal => Axis::Horizontal, SessionAxis::Vertical => Axis::Vertical, }; - // One side failing collapses the split onto the survivor, exactly - // as closing that pane by hand would. match ( session_to_pane(workspace, owner, a, alive, font_size, window, cx), session_to_pane(workspace, owner, b, alive, font_size, window, cx), @@ -7635,22 +5498,6 @@ fn session_to_pane( } } -/// Build a shell-backed terminal view, wiring the per-pane subscriptions every -/// pane needs. Fallible: the daemon can refuse the spawn (it died, it's -/// wedged, the shell doesn't exist), and every caller here runs inside a gpui -/// input callback, where a panic can't unwind and would abort the app instead -/// of surfacing the failure. Report it, don't `expect` it. -/// -/// `workspace` is **the switch that makes a window remote**: it picks the route -/// the pane's daemon connection takes and, through `ShellParts`, binds the view -/// to the same machine so everything pane-addressed afterwards (`Kill`, the -/// restore `List`, a reconnect's `Attach`) goes back to it. `None` is a local -/// pane, byte-for-byte what it always was. -/// `owner` is the workspace whose window this pane is being created for — -/// recorded daemon-side at spawn (so restore can tell whose pane is whose) and -/// stamped on the view (so `save_session` can shout if a window's tabs and its -/// identity ever come apart). `None` only for callers that genuinely have no -/// workspace (tests). pub(crate) fn new_terminal( workspace: Option<crate::terminal::PaneWorkspace>, owner: Option<WorkspaceId>, @@ -7661,14 +5508,6 @@ pub(crate) fn new_terminal( window: &mut Window, cx: &mut Context<Tty7App>, ) -> anyhow::Result<PaneSlot> { - // The fork this whole `PaneSlot` business exists for. `PaneRoute` is the - // same value that decides whether the connection carries a route header at - // all, so "does this pane talk to another computer" is asked once, here. - // - // A local pane keeps the synchronous path — not for lack of generality, but - // because it is ready in well under a millisecond and routing it through a - // placeholder would paint one frame of a spinner on every ⌘T. See - // `ui::pending_pane` for the whole argument. if matches!( crate::terminal::PaneRoute::for_workspace(workspace.as_ref()), crate::terminal::PaneRoute::Local @@ -7685,24 +5524,17 @@ pub(crate) fn new_terminal( ))); } - // Everything else waits on another machine, so it waits *in the tree*. let spawn = crate::ui::pending_pane::PendingSpawn { workspace, working_directory, restore_pane, shell, - // Filled in by session restore, the only caller with an agent session - // to bring back (see `session_to_pane`). A brand-new tab or split has - // no conversation behind it. agent: None, agent_session_id: None, agent_launch_argv: None, owner, font_size, }; - // The machine as the user knows it. `RemoteTarget`'s `Display` is the same - // label the switcher's rows carry, so "Connecting to gpu-01…" names the row - // that was clicked. let machine = spawn .workspace .as_ref() @@ -7721,8 +5553,6 @@ pub(crate) fn new_terminal( Ok(PaneSlot::Connecting(pending)) } -/// Run (or re-run) the blocking half of a pending pane's spawn, off the UI -/// thread, and land the result back in the tree. fn start_pane_spawn( pending: Entity<crate::ui::pending_pane::PendingPane>, window: &mut Window, @@ -7735,15 +5565,6 @@ fn start_pane_spawn( let parts = cx .background_executor() .spawn(async move { - // Restore, on a machine nobody asked "which panes are still - // alive?" — because asking is itself a routed round trip and - // the UI thread is where that question used to be asked from - // (`alive_panes_on`). Trying the attach *is* the question, and a - // failed one falls back to a fresh pane inside - // `spawn_shell_terminal_in`: an id that is gone is the ordinary - // case after the workspace's sessions were ended, and a pane the - // user cannot get back is not worth a slot that only offers - // "Try Again". TerminalView::spawn_shell_terminal_in( spawn.workspace.clone(), spawn.working_directory.clone(), @@ -7751,10 +5572,6 @@ fn start_pane_spawn( spawn.shell.clone(), spawn.owner, ) - // Flattened to a string here rather than carried as an - // `anyhow::Error`: the chain is not `Send` across this await in - // a form worth keeping, and what the pane shows is the rendered - // message anyway. .map_err(|e| format!("{e:#}")) }) .await; @@ -7765,10 +5582,6 @@ fn start_pane_spawn( .detach(); } -/// Wire the per-pane subscriptions every pane needs around a freshly built -/// terminal. Shared by the synchronous local path and the async remote one, so -/// a pane that arrived late is wired identically to one that was there from the -/// first frame. fn build_terminal_view( parts: crate::terminal::view::ShellParts, font_size: f32, @@ -7777,22 +5590,13 @@ fn build_terminal_view( ) -> Entity<TerminalView> { let view = cx.new(|cx| { let mut view = TerminalView::from_shell_parts(parts, window, cx); - // Inherit the current global font size so new panes match existing ones. view.font_size = px(font_size); view }); - // A pane whose shell exits on its own (`exit`, Ctrl-D, a crash) closes - // itself, like every other terminal. This is the single place all panes - // are built — new tab, split, session restore — so the subscription - // covers them all; restore even cleans up panes that died while no - // client was attached (the daemon replays their exit on reattach). cx.subscribe_in(&view, window, |app, view, _: &ChildExited, window, cx| { app.on_child_exited(view.clone(), window, cx); }) .detach(); - // The pane's agent started (or replaced) a conversation. Persist it: the - // session id is what a later restore resumes from, and nothing else was - // making the window save between the id arriving and the user acting. cx.subscribe_in( &view, window, @@ -7801,9 +5605,6 @@ fn build_terminal_view( }, ) .detach(); - // Native-SSH auth/host-key prompts raised by this pane → in-pane sheet. Same - // single build site as ChildExited, so every pane (new tab, split, restore) - // is covered. cx.subscribe_in( &view, window, @@ -7816,26 +5617,12 @@ fn build_terminal_view( view } -/// Kill a daemon pane without blocking the window. -/// -/// `kill_pane_on` opens a connection down the pane's route and writes one -/// frame — which against a *remote* route means the daemon opening an SSH -/// channel first, so doing it inline froze the window every time a remote pane -/// or tab was closed. Fire-and-forget by nature (a missing daemon means there -/// is nothing to kill anyway), so there is nothing to wait for and nothing to -/// report. fn kill_pane_off_thread(route: crate::terminal::PaneRoute, pane_id: u64, cx: &mut App) { cx.background_executor() .spawn(async move { crate::terminal::RemoteTerminal::kill_pane_on(&route, pane_id) }) .detach(); } -/// Re-render the app whenever `view` takes focus. Nothing else does this: a -/// pane owns its own focus handle, so clicking between splits notifies the -/// *pane*, not us, and any chrome that describes "the active pane" — the right -/// panel's Info and Changes tabs — would keep showing the pane you left until -/// some unrelated notify happened to repaint. Focus changes are user-paced, so -/// the extra frames are free. fn watch_pane_focus(view: &Entity<TerminalView>, window: &mut Window, cx: &mut Context<Tty7App>) { let handle = view.read(cx).focus_handle.clone(); let app = cx.weak_entity(); @@ -7848,12 +5635,6 @@ fn watch_pane_focus(view: &Entity<TerminalView>, window: &mut Window, cx: &mut C .detach(); } -/// Build a native (russh) SSH terminal view for `spec`, wiring the same -/// per-pane subscriptions (`ChildExited`, `AuthPromptReady`) as [`new_terminal`] -/// so it participates in auto-close and the in-pane auth sheets. Mirrors -/// `new_terminal` but takes the resolved connect spec instead of a shell. -/// Errors (daemon down/stale, spawn refused) are returned, never panicked — -/// callers surface them and keep the app alive. pub(crate) fn new_terminal_native( font_size: f32, working_directory: Option<std::path::PathBuf>, @@ -7915,20 +5696,11 @@ pub(crate) fn parse_ssh_option_words(input: &str) -> Result<Vec<String>, ()> { Ok(words) } -/// The data a typed "SSH: Add Connection…" line resolves to: a transient profile -/// plus the raw `ProxyJump` target (from `-J`), ready for -/// [`crate::ui::ssh_connect::native_spec_from_transient_profile`]. pub(crate) struct ParsedSshConnect { pub profile: crate::core::ssh_profile::SshProfile, pub proxy_jump: Option<String>, } -/// Parse a typed connect line (`[ssh] [flags] user@host[:port]`) into native -/// connect data (PRD §3.1). Only the trivially-mappable flags are honored — `-p`, -/// `-l`, `-i` (repeatable), `-J`, and `-o User=`/`-o Port=`/`-o ProxyJump=`; other -/// options are ignored (best-effort). A remote command, a `--` separator, an -/// unbalanced quote, or a missing/invalid host is an `Err(reason)` surfaced as an -/// inline notice — never a silent shell-out. Returns the user-facing reason string. pub(crate) fn parse_ssh_connect_input(input: &str) -> Result<ParsedSshConnect, String> { use crate::core::ssh_profile::{SshProfile, parse_quick_connect}; @@ -7951,8 +5723,6 @@ pub(crate) fn parse_ssh_connect_input(input: &str) -> Result<ParsedSshConnect, S return Err("Remote commands aren't supported here".to_string()); } if let Some((flag, attached)) = ssh_short_flag(&word) { - // Consume the value (attached `-p2222` form or the next word) when the - // flag takes one. let value = if ssh_option_takes_value(flag) { if !attached.is_empty() { attached @@ -7980,11 +5750,9 @@ pub(crate) fn parse_ssh_connect_input(input: &str) -> Result<ParsedSshConnect, S 'i' => identities.push(value), 'J' => jump = Some(value), 'o' => apply_ssh_o_option(&value, &mut user, &mut port, &mut jump)?, - // Any other flag (value already consumed if it took one) is ignored. _ => {} } } else if word.starts_with('-') { - // A long option (`--foo`) or bare `-`: not something we map. return Err(format!("Unsupported option \u{201c}{word}\u{201d}")); } else if target.is_none() { target = Some(word); @@ -8000,9 +5768,7 @@ pub(crate) fn parse_ssh_connect_input(input: &str) -> Result<ParsedSshConnect, S let mut profile = SshProfile::new(qc.host.clone()); profile.host = qc.host; - // Explicit `-p` / `-o Port=` wins over a `:port` on the target, else default 22. profile.port = port.or(qc.port).unwrap_or(22); - // Explicit `-l` / `-o User=` wins over `user@` on the target. if let Some(user) = user.or(qc.user) { profile.user = user; } @@ -8014,9 +5780,6 @@ pub(crate) fn parse_ssh_connect_input(input: &str) -> Result<ParsedSshConnect, S }) } -/// Split a short-option word into `(flag, attached_value)` — `-p2222` → `('p', -/// "2222")`, `-J` → `('J', "")`. `None` for a non-option, `--`/long option, or a -/// bare `-`. fn ssh_short_flag(word: &str) -> Option<(char, String)> { let rest = word.strip_prefix('-')?; if rest.is_empty() || rest.starts_with('-') { @@ -8027,8 +5790,6 @@ fn ssh_short_flag(word: &str) -> Option<(char, String)> { Some((flag, chars.as_str().to_string())) } -/// Apply the trivially-mappable `-o Name=Value` options (`User`/`Port`/ -/// `ProxyJump`); anything else is ignored (best-effort). fn apply_ssh_o_option( value: &str, user: &mut Option<String>, @@ -8054,13 +5815,6 @@ fn apply_ssh_o_option( Ok(()) } -/// The window-drag gesture behind every stand-in title bar, exercised against -/// the real [`title_bar_drag`] rather than a replica of it. -/// -/// `PlatformWindow::start_window_move` is `unimplemented!()` on gpui's test -/// platform, which makes a panic a reliable "the window would have started -/// moving" detector — hence the `#[should_panic]` on the tests that assert a -/// drag *does* start. The tests that assert one does *not* start simply return. #[cfg(test)] mod window_drag_tests { use gpui::{ @@ -8071,8 +5825,6 @@ mod window_drag_tests { use std::cell::Cell; use std::rc::Rc; - /// A minimal window whose whole surface is one real `title_bar_drag` row, so - /// nothing else in the tree can explain a result. struct Host; impl Render for Host { fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { @@ -8085,13 +5837,6 @@ mod window_drag_tests { } } - /// A resize handle's geometry over a stand-in title bar. The two real - /// handles (`tab_sidebar`, `right_panel`) are absolute, 8px wide, `h_full` - /// and centred on a panel edge, so their top band lies over a - /// `WindowControlArea::Drag` row; standing either of them up needs a whole - /// `Tty7App` window, so this reproduces the geometry instead. `occluded` - /// mirrors the real handles; `false` is the control that shows the harness - /// detects the hijack when the blocking hitbox is missing. struct HandleOverRow { occluded: bool, } @@ -8104,8 +5849,6 @@ mod window_drag_tests { .w(px(8.)) .h_full() .cursor_col_resize() - // What each real handle does on press: arm its own drag and ask - // for a repaint. .on_mouse_down(MouseButton::Left, |_, window, _| window.refresh()); let handle = if self.occluded { handle.occlude() @@ -8125,10 +5868,6 @@ mod window_drag_tests { } } - /// The pattern `title_bar_drag` used to carry: a `should_move` cell built - /// inside `render`, so every frame hands the next one a fresh, zeroed cell. - /// Kept as a control — it is what makes the assertions above it meaningful, - /// by showing the harness detects the bug when the bug is present. struct PerFrameCellHost; impl Render for PerFrameCellHost { fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement { @@ -8187,7 +5926,6 @@ mod window_drag_tests { point(at.x + px(12.), at.y + px(3.)) } - /// Press, let the window redraw — in production the next vsync — then move. fn press_repaint_move(vcx: &mut VisualTestContext, at: Point<Pixels>) { vcx.update(|window, cx| { window.dispatch_event(moved(at, false), cx); @@ -8201,13 +5939,6 @@ mod window_drag_tests { vcx.run_until_parked(); } - /// **The regression this file exists for (#221).** A repaint between the - /// press and the first drag event must not disarm the hold. It used to: the - /// arm lived in an `Rc<Cell<bool>>` rebuilt by every `render`, and the press - /// itself schedules a repaint (the row's `on_double_click` makes gpui call - /// `window.refresh()` on mouse-down), so a trackpad press — which starts - /// sliding tens of milliseconds later, well past the next vsync — almost - /// never survived to reach `start_window_move`. #[gpui::test] #[should_panic(expected = "not implemented")] fn the_arm_survives_a_repaint_between_press_and_move(cx: &mut TestAppContext) { @@ -8216,10 +5947,6 @@ mod window_drag_tests { press_repaint_move(&mut vcx, ON_ROW); } - /// The control for the test above: with the old per-frame cell, the identical - /// event sequence loses the arm and never reaches `start_window_move`. If this - /// one ever starts panicking, the harness has stopped being able to tell the - /// bug from the fix and the test above proves nothing. #[gpui::test] fn a_per_frame_cell_loses_the_arm_to_the_same_repaint(cx: &mut TestAppContext) { let window = cx.add_window(|_, _| PerFrameCellHost); @@ -8227,8 +5954,6 @@ mod window_drag_tests { press_repaint_move(&mut vcx, ON_ROW); } - /// The gesture still defers to an actual move, which is what keeps a plain - /// click — and the double-click that zooms the window — intact. #[gpui::test] fn a_press_alone_does_not_move_the_window(cx: &mut TestAppContext) { let window = cx.add_window(|_, _| Host); @@ -8241,10 +5966,6 @@ mod window_drag_tests { vcx.run_until_parked(); } - /// The other half of giving the arm a longer life: it now has to be *cleared* - /// explicitly. A press that is released and then followed by a plain hover - /// must not start a window move — with the old per-frame cell the frame - /// boundary did this for free. #[gpui::test] fn a_release_disarms_so_a_later_hover_does_not_drag(cx: &mut TestAppContext) { let window = cx.add_window(|_, _| Host); @@ -8262,13 +5983,6 @@ mod window_drag_tests { vcx.run_until_parked(); } - /// A panel resize handle must keep its drag. Its hit area runs the panel's - /// full height, so the top 40px sit on a stand-in caption row — and gpui's - /// hit test walks *past* a non-blocking hitbox, so without `occlude()` the - /// press arms the row's window move as well as the resize, and the first - /// drag event moves the window instead of resizing the panel. A durable arm - /// makes that reliable rather than a race: the handle's own `window.refresh()` - /// used to destroy the per-frame cell before the move could land. #[gpui::test] fn a_press_on_a_resize_handle_does_not_move_the_window(cx: &mut TestAppContext) { let window = cx.add_window(|_, _| HandleOverRow { occluded: true }); @@ -8276,10 +5990,6 @@ mod window_drag_tests { press_repaint_move(&mut vcx, ON_ROW); } - /// The control for the test above: the same geometry with a plain hitbox - /// hands the press to both the handle and the caption row underneath, and - /// the window moves. If this stops panicking, the test above is passing for - /// some reason other than the `occlude()` it exists to pin. #[gpui::test] #[should_panic(expected = "not implemented")] fn a_handle_without_a_blocking_hitbox_hands_the_press_to_the_row(cx: &mut TestAppContext) { @@ -8288,10 +5998,6 @@ mod window_drag_tests { press_repaint_move(&mut vcx, ON_ROW); } - /// Two stand-in rows on screen at once — the rail's top zone plus an overlay - /// header is a real combination — must not share one arm. This is why the - /// builder takes an explicit `key` instead of `window.use_state`, whose id - /// comes from the *caller's* `CodeLocation` and would be identical for both. #[gpui::test] fn two_rows_on_screen_keep_separate_arms(cx: &mut TestAppContext) { struct TwoRows; @@ -8316,8 +6022,6 @@ mod window_drag_tests { let window = cx.add_window(|_, _| TwoRows); let mut vcx = VisualTestContext::from_window(window.into(), cx); - // Arm the upper row, then hover the lower one without a button held. - // A shared arm would fire here; separate arms leave the lower row cold. vcx.update(|window, cx| { window.dispatch_event(moved(point(px(300.), px(20.)), false), cx); window.dispatch_event(down(point(px(300.), px(20.))), cx); @@ -8338,12 +6042,6 @@ mod tests { parse_ssh_option_words, }; - /// The restore-side ownership gate. A pane owned by another workspace must - /// read as unattachable even while alive — attaching is how one - /// workspace's saved ids once silently picked up another's shells (and - /// their running agents). A pane with no recorded owner stays attachable - /// by anyone: that is the pre-`pane-owner` behavior, and refusing it would - /// orphan every pane a legacy daemon is holding. #[test] fn restore_only_attaches_panes_the_workspace_owns_or_nobody_claims() { let ours = crate::core::session::WorkspaceId::new(); @@ -8371,27 +6069,14 @@ mod tests { ); } - /// A remote window's saved layout can hold a native-SSH pane, whose russh - /// session runs in *this* client's daemon rather than the machine's. Its - /// saved id must not be matched against the remote's pane list: the two - /// daemons number panes independently, so `1` over there is a different - /// pane, and restoring it would swap the user's SSH tab for whatever the - /// remote happens to be running. #[test] fn a_native_ssh_leaf_in_a_remote_window_is_not_looked_up_in_the_remote_daemon() { assert!(!leaf_shares_the_window_daemon(true, true)); - // Everything else is the window's own daemon: a shell in a remote - // window is a pane over there, and in a local window both kinds are - // panes here. assert!(leaf_shares_the_window_daemon(true, false)); assert!(leaf_shares_the_window_daemon(false, true)); assert!(leaf_shares_the_window_daemon(false, false)); } - // The single gate every fork surface consults. All three conditions have to - // hold: an agent with a verified fork command, a session id the hooks have - // reported, and a local pane — a remote one would shell the *local* agent - // and branch the wrong machine's session. #[test] fn a_fork_needs_a_command_an_id_and_a_local_pane() { let session = |fork_label, session_id: Option<&str>, remote| TabAgentSession { @@ -8414,12 +6099,6 @@ mod tests { ); } - /// **A pane that is still connecting keeps the agent it is being rebuilt - /// from.** Every remote pane spends its first seconds in that state, and a - /// save landing in the window — a focus change, a resize, the window - /// closing — used to write `agent: null` over the record. Ending that - /// workspace's sessions afterwards left nothing to resume *from*, which is - /// what made the resume look like it worked only sometimes. #[gpui::test] fn a_connecting_pane_saves_the_agent_it_is_rebuilding(cx: &mut gpui::TestAppContext) { use crate::core::cli_agent::CLIAgent; @@ -8479,7 +6158,6 @@ mod tests { #[test] fn parses_typed_connect_into_native_profile() { - // Bare `user@host:port` (optional `ssh` prefix) → transient profile. let p = parse_ssh_connect_input("ssh deploy@10.0.0.5:2222").unwrap(); assert_eq!(p.profile.host, "10.0.0.5"); assert_eq!(p.profile.user, "deploy"); @@ -8489,7 +6167,6 @@ mod tests { #[test] fn parses_typed_connect_flags_and_jump() { - // Options before and after the target; `-p`/`-l`/`-i`/`-J` all map. let p = parse_ssh_connect_input("ssh -p 2222 -l dev -i ~/.ssh/id_ed25519 -J 'jump host' host") .unwrap(); @@ -8502,16 +6179,13 @@ mod tests { ); assert_eq!(p.proxy_jump.as_deref(), Some("jump host")); - // Attached short-flag form (`-p2222`) and `-o User=`/`-o Port=`. let p = parse_ssh_connect_input("host -p2222 -o User=deploy -o Port=2200").unwrap(); assert_eq!(p.profile.user, "deploy"); - // `-o Port=` wins over an earlier `-p` (last write wins in the -o pass). assert_eq!(p.profile.port, 2200); } #[test] fn explicit_flags_override_target_userhost() { - // `-l` / `-p` override the `user@host:port` on the target. let p = parse_ssh_connect_input("ssh me@host:22 -l other -p 2200").unwrap(); assert_eq!(p.profile.user, "other"); assert_eq!(p.profile.port, 2200); @@ -8519,19 +6193,14 @@ mod tests { #[test] fn rejects_bad_typed_connect_lines() { - // No host at all. assert!(parse_ssh_connect_input("ssh -p 2222").is_err()); - // A remote command or `--` separator is not a connect line. assert!(parse_ssh_connect_input("ssh dev uptime").is_err()); assert!(parse_ssh_connect_input("ssh -- dev").is_err()); - // Unbalanced quote. assert!(parse_ssh_connect_input("ssh 'host").is_err()); - // Invalid port. assert!(parse_ssh_connect_input("ssh host -p 0").is_err()); } } -/// The shared headless App + Window the UI-level gpui tests drive. #[cfg(test)] pub(crate) mod test_window { use crate::core::config::Config; @@ -8540,28 +6209,14 @@ pub(crate) mod test_window { use gpui::{AppContext, Entity, TestAppContext, VisualTestContext}; pub(crate) fn harness(cx: &mut TestAppContext) -> (Entity<Tty7App>, VisualTestContext) { - // Every keybinding edit a caller makes goes through `update_config`, - // which ends in `Config::save()` — a *full* overwrite of `config.json` - // at whatever path the config dir resolves to. Unpinned, that is the - // developer's real `~/.config/tty7/config.json`, so running those tests - // silently reset the user's entire config to `Config::default()` plus - // the shortcut recorded there. The test-only `Config::save` now panics - // rather than allow that; pin a scratch dir so it doesn't have to. crate::core::config::pin_test_config_dir(); - // The pause-to-commit is a real `smol::Timer` (off the deterministic - // executor), so waiting on it parks the test thread. cx.executor().allow_parking(); cx.update(|cx| { gpui_component::init(cx); cx.set_global(Config::default()); crate::ui::keymap::init(cx); }); - // Wrap the app in a `gpui_component::Root` exactly like `main.rs` does: - // the settings overlay's search box (and other gpui-component widgets) - // reach for `Root` on the window, which panics if the window's first - // layer isn't one. `Root::view()` hands the typed app entity back so the - // tests still drive `Tty7App` directly. let window = cx.add_window(|window, cx| { let app = cx.new(|cx| Tty7App::with_session(None, Some(Session::default()), window, cx)); @@ -8584,14 +6239,6 @@ pub(crate) mod test_window { (app, vcx) } - /// [`harness`] plus one open tab, for the tests that need the window to have - /// something to show. The pane is a [`quiet_test_pane`], and the returned - /// stream must stay alive for as long as it: dropping it closes the socket - /// and the pane retires. - /// - /// Cursor blink is turned off here. It is a real 530ms `cx.notify()` on a - /// focused pane and would otherwise be the only thing the render-idle - /// measurements ever counted. #[cfg(unix)] pub(crate) fn harness_with_pane( cx: &mut TestAppContext, @@ -8622,10 +6269,6 @@ pub(crate) mod test_window { } } -/// A native-SSH split inside a *remote* workspace's window runs in this -/// client's daemon and is deliberately absent from the remote machine's tree — -/// so a tree-driven tab rebuild has no leaf for it, and has to keep its view -/// anyway or a running local session is orphaned with nothing on screen. #[cfg(all(test, unix))] mod ssh_rebuild_gpui_tests { use super::test_window::harness_with_pane; @@ -8638,12 +6281,8 @@ mod ssh_rebuild_gpui_tests { #[gpui::test] fn a_tree_rebuild_keeps_the_native_ssh_split_a_remote_tab_holds(cx: &mut TestAppContext) { - // A window with one tab holding remote pane 1 (as far as the window is - // concerned; the socketpair plays the daemon). let (app, mut vcx, _remote_pane_stream) = harness_with_pane(cx); - // Bind the window to a remote workspace and split a native-SSH pane - // into the tab — the state a remote window with a local SSH split has. let remote = WindowView::on_remote(RemoteRef::new( RemoteTarget::Alias { alias: "build-box".into(), @@ -8671,9 +6310,6 @@ mod ssh_rebuild_gpui_tests { stream }); - // Another client of the remote machine restructured the tab. The - // delta's tree names only the remote pane — the SSH leaf was never in - // that tree to be named. let applied = app.update_in(&mut vcx, |app, window, cx| { let tab = TreeTab { id: app.tabs[0].tree_id.get(), @@ -8715,12 +6351,6 @@ mod ssh_rebuild_gpui_tests { }); } - /// A remote window's tab that is native-SSH through and through is - /// unrepresentable in the machine's tree **forever** — so it must be - /// invisible to the diff, not *held*. Held means "spawns are landing, - /// wait"; a tab that can never land would make every diff return before - /// the ordering and active-tab passes, freezing tab order and activation - /// sync for the whole window for as long as the tab exists. #[gpui::test] fn a_pure_native_ssh_tab_is_invisible_to_the_tree_not_held(cx: &mut TestAppContext) { let (app, mut vcx, _remote_pane_stream) = harness_with_pane(cx); @@ -8741,7 +6371,6 @@ mod ssh_rebuild_gpui_tests { }, ); app.workspace = remote_id; - // A second tab holding only a native-SSH pane. let (ssh_view, stream) = crate::terminal::view::quiet_test_ssh_pane(2, window, cx); app.tabs .push(super::Tab::new(Pane::leaf(PaneSlot::Ready(ssh_view)))); @@ -8772,7 +6401,6 @@ mod keybinding_gpui_tests { use crate::ui::settings::SettingsSection; use gpui::{Entity, TestAppContext, VisualTestContext}; - /// Open Settings → Keybindings and begin capturing `action`. fn begin_capture(app: &Entity<Tty7App>, vcx: &mut VisualTestContext, action: &str) { let action = action.to_string(); app.update_in(vcx, |app, window, cx| { @@ -8782,8 +6410,6 @@ mod keybinding_gpui_tests { }); } - /// Poll (bounded) until `action` has the expected override in config — the - /// commit fires on a real ~650ms timer. fn wait_for_binding(vcx: &mut VisualTestContext, action: &str, expected: &str) { let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); loop { @@ -8800,15 +6426,10 @@ mod keybinding_gpui_tests { } } - // End-to-end: open Settings → Keybindings, capture a shortcut for New Tab, - // and confirm the recorded keystroke is normalized, persisted to config, and - // the capture ends. This drives the real interceptor path installed by - // `start_recording_key`, not just the pure helpers. #[gpui::test] fn recording_a_shortcut_writes_the_override_and_ends_capture(cx: &mut TestAppContext) { let (app, mut vcx) = harness(cx); begin_capture(&app, &mut vcx, "NewTab"); - // The platform-primary modifier normalizes to `secondary` on write. vcx.simulate_keystrokes("secondary-shift-n"); wait_for_binding(&mut vcx, "NewTab", "secondary-shift-n"); @@ -8822,24 +6443,15 @@ mod keybinding_gpui_tests { ); } - // A two-chord sequence (the tmux-style `ctrl-b x`) records as one binding. #[gpui::test] fn recording_a_two_chord_sequence_writes_the_full_spec(cx: &mut TestAppContext) { let (app, mut vcx) = harness(cx); begin_capture(&app, &mut vcx, "CloseActiveTab"); - // Two chords in quick succession, then the pause commits the sequence. - // `secondary-b` is used (not a bare `ctrl-b`) so the recorded spec is - // identical on macOS and elsewhere — the primary modifier normalizes to - // `secondary` either way. vcx.simulate_keystrokes("secondary-b"); vcx.simulate_keystrokes("x"); wait_for_binding(&mut vcx, "CloseActiveTab", "secondary-b x"); } - // Taking a chord an action holds only as an *extra* default (Alt+Enter, the - // second one Insert Newline ships without a table row of its own) displaces - // it like any other owner: the chord stops inserting newlines either way, so - // the unset has to be written and the takeover said out loud. #[gpui::test] fn recording_an_extra_default_chord_displaces_its_owner(cx: &mut TestAppContext) { let (app, mut vcx) = harness(cx); @@ -8858,7 +6470,6 @@ mod keybinding_gpui_tests { ); } - // Esc during capture cancels without touching config. #[gpui::test] fn escape_cancels_capture_without_writing(cx: &mut TestAppContext) { let (app, mut vcx) = harness(cx); @@ -8879,8 +6490,6 @@ mod keybinding_gpui_tests { } } -/// The "+" dropdown is a property of the window's *machine* — the whole of -/// [`Tty7App::refresh_shells`]'s reason to exist. #[cfg(test)] mod shell_menu_gpui_tests { use crate::core::config::Config; @@ -8891,19 +6500,12 @@ mod shell_menu_gpui_tests { use gpui::{AppContext, Entity, TestAppContext, VisualTestContext}; fn harness(cx: &mut TestAppContext) -> (Entity<Tty7App>, VisualTestContext) { - // The window's construction persists a session; without this it would - // write the developer's real one. crate::core::config::pin_test_config_dir(); - // The shell probe runs on `HostOps`' own thread pool, off gpui's - // executor, so waiting for it parks the test thread. cx.executor().allow_parking(); cx.update(|cx| { gpui_component::init(cx); cx.set_global(Config::default()); crate::ui::keymap::init(cx); - // Switching workspaces rebinds the window's registry entry; the - // headless harness opens windows directly, so nothing else installs - // it. Empty is the truth here — this window was never registered. crate::ui::windows::WindowRegistry::init(cx); }); let window = cx.add_window(|window, cx| { @@ -8924,8 +6526,6 @@ mod shell_menu_gpui_tests { (app, vcx) } - /// Pump both executors until `done`, or give up. The probe crosses back from - /// a real thread pool, so `run_until_parked` alone has nothing to wait on. fn pump_until( app: &Entity<Tty7App>, vcx: &mut VisualTestContext, @@ -8944,7 +6544,6 @@ mod shell_menu_gpui_tests { } } - /// A local window fills its menu from this computer, as it always has. #[gpui::test] fn a_local_window_lists_this_computers_shells(cx: &mut TestAppContext) { let (app, mut vcx) = harness(cx); @@ -8961,12 +6560,6 @@ mod shell_menu_gpui_tests { }); } - /// **The bug this exists to stop.** A window bound to a machine that isn't - /// answering offers *nothing* rather than this computer's shells: every one - /// of those rows would spawn a path that only exists here (`/bin/zsh` on a - /// box whose zsh is `/usr/bin/zsh`), and the pane would come up as a spawn - /// failure. The empty list falls back to the plain "New Tab" entry, which - /// the far end resolves with its own default shell. #[gpui::test] fn an_unreachable_remote_window_offers_no_local_shells(cx: &mut TestAppContext) { let (app, mut vcx) = harness(cx); @@ -8975,7 +6568,6 @@ mod shell_menu_gpui_tests { "the local probe never landed" ); - // A workspace on a machine nothing in this process has connected to. let remote = WindowView::on_remote(RemoteRef::new( RemoteTarget::Alias { alias: "build-box".into(), diff --git a/src/ui/assets.rs b/src/ui/assets.rs index 5eb3a4c4..2c9cdc8a 100644 --- a/src/ui/assets.rs +++ b/src/ui/assets.rs @@ -1,31 +1,9 @@ -//! The app's [`gpui::AssetSource`]: tty7's own bundled icons layered over -//! gpui-component's icon set. -//! -//! gpui-component ships the generic UI glyphs (close, chevrons, `bot`, …) via -//! [`gpui_component_assets::Assets`]. tty7 adds a small set of third-party -//! coding-agent brand marks (`icons/agents/*.svg`) for the tab avatars — see -//! [`crate::core::cli_agent::CLIAgent::icon_path`]. Rather than fork the -//! upstream asset crate to carry app-specific brand art, this source resolves -//! tty7's icons first and delegates everything else downstream, so both sets -//! load through the single `AssetSource` gpui allows. - use std::borrow::Cow; use gpui::{AssetSource, Result, SharedString}; -/// tty7's asset source. Registered once in `main` via `with_assets`. pub struct Assets; -/// Prefix that opts a single call site *out* of the overrides in [`agent_icon`] -/// and takes gpui-component's own glyph instead: `stock/icons/search.svg`. -/// -/// Needed because the overrides are keyed on the asset path, which makes them -/// app-wide (see [`agent_icon`]). Most of them are wanted everywhere, but the -/// detail panel's set is drawn for 18px tiles sitting beside solid dock glyphs, -/// and a few of those shapes are too heavy at the 16px the Settings page uses — -/// its `⋯` in particular, whose filled `r=2` dots smear into three blobs there. -/// Rather than fork the whole set under a second name, those call sites ask for -/// stock by path. const STOCK_PREFIX: &str = "stock/"; impl AssetSource for Assets { @@ -40,105 +18,18 @@ impl AssetSource for Assets { } fn list(&self, path: &str) -> Result<Vec<SharedString>> { - // Only gpui-component enumerates its icons; tty7's brand marks are - // referenced by explicit path, never listed, so the downstream set is - // the whole answer. gpui_component_assets::Assets.list(path) } } -/// The bytes of a bundled agent brand mark, or `None` if `path` isn't one of -/// ours. Kept as an explicit match (rather than `rust-embed`) because the set is -/// tiny and fixed, and `include_bytes!` needs no extra build dependency. -/// -/// Note that matching on the *path* makes every arm here an app-wide override, -/// not a local one: `gpui_component_macros::icon_named!` derives `IconName` from -/// the downstream asset filenames, so `IconName::Search.path()` is literally -/// `"icons/search.svg"` and every `Icon::new(IconName::Search)` in the tree — -/// tty7's and gpui-component's own — resolves through the arm below. Adding a -/// name that upstream also ships redraws it everywhere; check the call sites -/// before doing so, and prefer a name upstream *doesn't* use (`circle-info`) -/// when only one place should change. A call site that wants the downstream -/// glyph despite an override here asks for it by [`STOCK_PREFIX`]. fn agent_icon(path: &str) -> Option<&'static [u8]> { let bytes: &'static [u8] = match path { - // Flush `>_` prompt glyph for the plain-shell tab avatar (Lucide's - // unboxed `terminal`, which gpui-component doesn't bundle — it only - // ships the boxed `square-terminal`). "icons/terminal.svg" => include_bytes!("../../assets/icons/terminal.svg"), - // A git glyph on the detail-panel spec below (gpui-component bundles - // none). Serves both the sidebar row's branch line and the Changes tab. - // Drawn as a commit graph — a trunk with a node at each end, branching - // once — rather than lucide's long arc slung between two floating rings: - // that one is the loosest, most lopsided shape in a row of four. Nodes - // are filled, because at the sidebar's 11px a stroked ring's hole - // collapses into a blur. "icons/git-branch.svg" => include_bytes!("../../assets/icons/git-branch.svg"), - // Dock glyphs for the window chrome: a rounded frame with one inset solid - // block marking which dock is open. gpui-component only ships the hollow - // line `panel-left/right`, which says a panel exists but not which one is - // showing, so tty7 carries its own. The block is a flat fill, not a wash — - // nothing in this set relies on partial alpha surviving rasterisation. "icons/panel-left.svg" => include_bytes!("../../assets/icons/panel-left.svg"), "icons/panel-right.svg" => include_bytes!("../../assets/icons/panel-right.svg"), - // The chrome glyphs beside those dock tiles. Lucide's `ellipsis` strokes - // three `r=1` circles, and at 18px the cap overlaps its own fill and the - // dots blur into grey smudges, so these are filled to the node rule below. "icons/plus.svg" => include_bytes!("../../assets/icons/plus.svg"), "icons/ellipsis.svg" => include_bytes!("../../assets/icons/ellipsis.svg"), - // The detail panel's own set: four tab tiles at 18px and four controls at - // 13px, all in one panel, so they're drawn to one spec instead of taken - // from lucide as-is. - // - // The spec is "humanist": full, rounded, near-square. Every glyph here - // draws the *conventional* thing — a magnifier is a magnifier, a folder - // is a folder, a sheet has a dog-ear. Several rounds of this set tried - // swapping those metaphors for cleverer ones (terminal-native objects, - // brand-derived panels, deliberately unclosed forms) and every one of - // them cost more recognition than it bought character. What carries the - // set is how the familiar shape is *drawn*, not which shape it is. - // - // stroke 2.1, flat across the set, round caps and joins - // radius 3.4–4.4 — generous, matching the app's own 10px panels - // span 3.4→20.6, near-square bounding box; circles widen ~4%, - // since a round shape reads smaller at equal geometry - // curvature one step fuller than a mechanical arc would give (`eye`, - // `folder`'s shoulder), which is what makes the set feel - // drawn rather than constructed - // nodes filled, r ≥ 1.6 — a stroked dot hazes below 16px - // - // The five craft rules the set is held to, in the order they're usually - // violated: - // - // 1. Optical weight, not geometric: round shapes are drawn ~4% larger - // than square ones so a row of mixed glyphs reads level. - // 2. Tangent, never intrusion: `search`'s handle leaves the circle at - // its 45° tangent point rather than aiming at the centre — an - // overlap there shows as a lump at 64px. - // 3. Even interior rhythm: within one glyph, gaps between strokes are - // equal (`file`'s two text rules are spaced as far apart as each is - // from the frame). Uneven interior air is the single biggest source - // of "drawn sloppily". - // 4. One terminal treatment: every end is round, every corner shares - // the radius band. Don't mix a hard-folded corner into this set. - // 5. Equal apparent size, not equal bounds: `folder` is wide and short - // so it's drawn wider; `file` is narrow and tall so it's drawn - // taller. They occupy the same visual area, not the same rectangle. - // - // The stock lucide glyphs share none of that: they mix stroke weights, - // sit a 21-wide circle next to an 18-wide folder, and leave so much dead - // space inside the frame that a row reads as four glyphs from four sets. - // - // Shape choices worth keeping: `info` is a panel with a title bar ruled - // across the top and one content line under it — a picture of what the - // tab actually opens (cwd, shell, branch, changes) — rather than the - // circled `i`, which is the most-drawn icon there is and says "help" as - // readily as "details"; the title rule is also what keeps it from - // colliding with `panel-left`, which is the same frame with a block in - // it. Outline's last row is cut short, because three full-width rules - // read as a hamburger menu rather than a list. `copy`'s back sheet wraps - // three sides and stops on its own curves instead of poking two raw stubs - // out of an L. "icons/list.svg" => include_bytes!("../../assets/icons/list.svg"), "icons/folder-closed.svg" => include_bytes!("../../assets/icons/folder-closed.svg"), "icons/folder-open.svg" => include_bytes!("../../assets/icons/folder-open.svg"), @@ -146,45 +37,11 @@ fn agent_icon(path: &str) -> Option<&'static [u8]> { "icons/eye.svg" => include_bytes!("../../assets/icons/eye.svg"), "icons/search.svg" => include_bytes!("../../assets/icons/search.svg"), "icons/copy.svg" => include_bytes!("../../assets/icons/copy.svg"), - // `folder` and `file` carry no detail-panel role of their own — they're - // here because overriding `folder-open` above would otherwise split the - // file tree down the middle, drawing expanded rows on this spec and - // collapsed ones (and every file) from stock lucide. - // - // `folder` is the file tree's collapsed row; `folder-closed` is the - // Files *tab* label, and carries one inner rule so the two names stop - // resolving to one identical drawing. The rule is legible there because - // the tab renders at 18px — at the tree's 13px it would only crowd the - // box, which is why the collapsed row doesn't wear it. "icons/folder.svg" => include_bytes!("../../assets/icons/folder.svg"), "icons/file.svg" => include_bytes!("../../assets/icons/file.svg"), - // The circled `i` that `info.svg` used to be, kept under its own name for - // the Settings nav's About row — there the glyph labels a section rather - // than a detail tab, and "panel with two lines written in it" says nothing - // about *About*. No upstream `IconName` maps here, so it's referenced by - // path (see `settings.rs`). "icons/circle-info.svg" => include_bytes!("../../assets/icons/circle-info.svg"), - // The switcher's two machine marks. gpui-component ships neither a - // laptop nor a server (its closest are `hard-drive` and `cpu`, both of - // which say "a component" rather than "a computer"), and these carry - // the whole local/remote distinction on a list where every other row is - // a monogram — so they are drawn to the spec above rather than - // approximated. Names upstream doesn't use, so no `IconName` resolves - // through them and nothing else in the tree is redrawn; both are - // referenced by path from `switcher.rs`. - // - // Deliberately the *conventional* pair (a clamshell, a rack) per rule 3 - // of the shape notes above: this is the one place in the app where the - // glyph is the only thing saying "your work is on another machine", and - // a cleverer metaphor would spend recognition it cannot afford. "icons/machine-local.svg" => include_bytes!("../../assets/icons/machine-local.svg"), "icons/machine-remote.svg" => include_bytes!("../../assets/icons/machine-remote.svg"), - // The Files tab's remote (SFTP) mode needs a refresh it doesn't need - // locally: the local tree watches the directories it displays and - // invalidates itself, a remote listing has nothing watching it. Drawn to - // the circle rule above (r 8.6) so it sits level with the `eye` beside it - // rather than lucide's r=9 `rotate-cw`, whose arrow head is also a size - // step heavier than anything else in the row. "icons/refresh.svg" => include_bytes!("../../assets/icons/refresh.svg"), "icons/agents/claude.svg" => include_bytes!("../../assets/icons/agents/claude.svg"), "icons/agents/codex.svg" => include_bytes!("../../assets/icons/agents/codex.svg"), @@ -195,16 +52,7 @@ fn agent_icon(path: &str) -> Option<&'static [u8]> { "icons/agents/cursor.svg" => include_bytes!("../../assets/icons/agents/cursor.svg"), "icons/agents/goose.svg" => include_bytes!("../../assets/icons/agents/goose.svg"), "icons/agents/droid.svg" => include_bytes!("../../assets/icons/agents/droid.svg"), - // The one mark not taken from the vendor directly: xAI publishes its - // symbol only as a ~2:1 landscape lockup that turns to mush as a 16px - // silhouette, so this is lobehub/lobe-icons' square transcription (MIT), - // drawn for exactly this avatar use. Its notice rides in the SVG. "icons/agents/grok.svg" => include_bytes!("../../assets/icons/agents/grok.svg"), - // Pi's own mark, from pi.dev — the one file that arrives with theme - // logic attached (`logo-auto.svg` carries a `prefers-color-scheme` - // style block). The geometry is kept as published and rescaled to the - // 24x24 grid; the CSS is dropped, since these avatars are tinted by the - // app and no other mark here brings a stylesheet. Details in the SVG. "icons/agents/pi.svg" => include_bytes!("../../assets/icons/agents/pi.svg"), _ => return None, }; @@ -215,9 +63,6 @@ fn agent_icon(path: &str) -> Option<&'static [u8]> { mod tests { use super::*; - /// The Settings page reaches for stock glyphs by path; if that stopped - /// bypassing the overrides it would silently pick up the detail panel's - /// heavier redraws again, which is the regression this prefix exists to undo. #[test] fn stock_prefix_bypasses_the_overrides() { for name in ["search", "ellipsis"] { @@ -236,9 +81,6 @@ mod tests { } } - /// Every agent avatar must resolve to real bytes. Adding a brand mark means - /// touching two files — the SVG and the arm above — and forgetting the - /// second one costs the agent its avatar with nothing to show for it. #[test] fn every_agent_icon_resolves() { for agent in crate::core::cli_agent::CLIAgent::ALL { @@ -251,8 +93,6 @@ mod tests { } } - /// A `stock/` path for a glyph tty7 never overrode still has to resolve — - /// the prefix is a bypass, not a separate asset set. #[test] fn stock_prefix_works_for_unoverridden_glyphs() { assert_eq!( diff --git a/src/ui/code_editor.rs b/src/ui/code_editor.rs index b18bb06e..a3509a55 100644 --- a/src/ui/code_editor.rs +++ b/src/ui/code_editor.rs @@ -1,32 +1,3 @@ -//! The code panel: a full-body editor overlay that covers the terminal, -//! settings-overlay style. It stops short of the right panel rather than -//! covering it, so the file tree there stays beside the editor — the tree used -//! to be this overlay's own left column and is not any more. -//! -//! A lightweight "look at / touch up code without leaving the terminal" -//! editor, not a full IDE. The text engine is `gpui_component::input:: -//! InputState` in CodeEditor mode, which brings rope storage, tree-sitter -//! syntax highlighting, line numbers, indent guides, code folding, -//! auto-indent, undo/redo and an in-buffer search/replace bar. This module -//! owns everything around that engine: the open-file set and tab strip, dirty -//! tracking and save, external-modification reload (via `notify`), the -//! unsaved-close confirmation, and the overlay chrome itself. The file tree is -//! `ui::file_tree`'s, drawn by the right panel's Files tab. -//! -//! Deliberately *not* an IDE: there is no language-server integration, and -//! adding one is not a wanted feature. Opening a `.rs` file silently spawning -//! rust-analyzer — a background process indexing the whole workspace for -//! hundreds of megabytes of RAM — is not something a terminal emulator should -//! do to its user. Highlighting comes from tree-sitter grammars compiled into -//! gpui-component (see [`language_for_path`]), which is static, in-process, -//! and costs nothing beyond parsing the open buffer. -//! -//! Layout: overlaying the body (like Settings and the diff overlay) rather -//! than docking a side column means toggling never resizes the terminal — no -//! PTY resize, no reflow — and the editor gets the full body width. The tab -//! sidebar stays visible; switching tabs re-roots the tree. One entry point: -//! the title-bar tile in `tab_strip` (`ToggleCodePanel`, ⌘⇧E; Esc closes). - use std::collections::HashSet; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -45,68 +16,28 @@ use gpui_component::{ use crate::ui::app::Tty7App; use crate::ui::host_ops::{HostOps, MTime, SharedHost, WatchSub}; -/// Refuse to open files larger than this: the component's code editor is rated -/// to ~50K lines, and a multi-megabyte blob is almost never what a terminal -/// user meant to open in a side panel. const MAX_FILE_BYTES: u64 = 4 * 1024 * 1024; -/// Debounce for external-change reloads, matching the config hot-reload: a -/// save is often a truncate→write→rename burst that should collapse to one. const RELOAD_DEBOUNCE: std::time::Duration = std::time::Duration::from_millis(200); -/// One open file: the component editor state plus the bookkeeping that turns -/// it into a *file* editor (path, dirty flag, on-disk snapshot identity). pub(crate) struct OpenFile { pub(crate) path: PathBuf, pub(crate) input: Entity<InputState>, - /// The buffer has edits not yet written to `path`. pub(crate) dirty: bool, - /// mtime of the content we last loaded from / saved to disk; used to drop - /// watcher echoes of our own saves. - /// - /// [`MTime`], not `SystemTime`, and nanosecond-precise on purpose: the echo - /// test is mtime equality, so a coarser clock would swallow a genuine - /// external edit that landed in the same tick as our own write. disk_mtime: Option<MTime>, - /// Bumped on every buffer change, so a save that lands can tell whether the - /// text it wrote is still the text in the buffer. edit_seq: u64, - /// The `edit_seq` the in-flight write's snapshot was taken at, or `None` - /// when nothing is being written. Also the single-flight latch: a second - /// ⌘S while this is set queues rather than races. saving: Option<u64>, - /// A save was asked for while one was in flight. Re-issued when that one - /// lands, so the last content the user asked to save is the content on disk. save_pending: bool, - /// The user answered "Save" to a close prompt, so this buffer closes once - /// its write lands. - /// - /// Lives on the buffer rather than being threaded through the save call - /// because a save can be *queued* behind one already in flight: passing it - /// as an argument meant the queued request's intent was dropped and the - /// in-flight one's was replayed, so a close silently did nothing. save_then_close: bool, - /// Bumped every time a reload is issued; a landing that is no longer the - /// newest discards itself. Two watcher batches can put two reads in flight, - /// and without this the older one can land last and install stale text - /// *marked clean* — a buffer that no longer matches disk and never - /// re-checks. reload_seq: u64, - /// Disk changed under unsaved edits: show the reload/keep banner instead - /// of silently clobbering either side. pub(crate) conflict: bool, - /// Markdown files can flip the buffer into a rendered preview. pub(crate) preview: bool, - /// Soft-wrap state (mirrored here — the input's own flag isn't readable). pub(crate) wrap: bool, _sub: Subscription, - /// Repaints the app when the input notifies (cursor moves, scrolls…) so - /// the status bar's Ln/Col stays live. _observe: Subscription, } impl OpenFile { - /// Tab label: the file name (the path differentiates in the tooltip). fn label(&self) -> SharedString { self.path .file_name() @@ -116,19 +47,10 @@ impl OpenFile { } } -/// Per-tab code-panel state, hung on [`Tab::code`](crate::ui::app::Tab) with -/// the same lifecycle contract as the diff overlay: only the active tab's -/// panel renders, switching away hides it, closing the tab drops it. The -/// shared caches (directory listings, gitignore matchers, filesystem -/// watchers) live on [`Tty7App`] — this holds only what is truly this tab's: -/// its open files and its tree view state. pub(crate) struct TabCode { - /// Whether the overlay is currently shown for this tab. The open-file set - /// survives hiding (Esc) — only closing the tab drops it. pub(crate) visible: bool, pub(crate) files: Vec<OpenFile>, pub(crate) active: usize, - /// File-tree roots: this tab's pane cwds resolved to repo roots. pub(crate) roots: Vec<PathBuf>, pub(crate) expanded: std::collections::HashSet<PathBuf>, pub(crate) selected: Option<PathBuf>, @@ -137,12 +59,6 @@ pub(crate) struct TabCode { impl TabCode { pub(crate) fn new() -> Self { Self { - // Born hidden. This state used to be created only by opening the - // overlay, so defaulting to visible was harmless; now the right - // panel's Files tab creates it just to hold the tree's roots and - // expansion, and a default of `true` popped an empty editor open - // ("No file open") the moment you looked at the tree. Every path - // that actually wants the overlay sets `visible` itself. visible: false, files: Vec::new(), active: 0, @@ -157,52 +73,19 @@ impl TabCode { } } -/// App-global editor infrastructure shared by every tab's panel. pub(crate) struct EditorPanelState { - /// Watches the parent directories of open files (across all tabs) for - /// external changes. - /// - /// One long-lived subscription whose set moves with the open files, rather - /// than a watcher rebuilt per open — remotely, a rebuild is a round trip - /// and a server-side watcher recreated every time a file is opened or - /// closed. `Arc` because `set_dirs` is itself a host call. watch: Option<Arc<WatchSub>>, - /// The host `watch` was opened against, kept so a subscription is never - /// reused across a different one. - /// - /// A `HostId` is not enough to tell them apart: reconnecting removes the - /// dead `RemoteHost` and inserts a fresh one under the *same* id, so the id - /// matches while the `ControlClient` behind the old subscription is gone. - /// Compared by pointer, which distinguishes both that and an outright - /// switch to another machine. watch_host: Option<SharedHost>, - /// A subscription is being opened; keeps a burst of opens from asking for - /// one each. watch_opening: bool, - /// A `set_dirs` is in flight, and whether the set moved again while it was. - /// - /// `set_dirs` replaces the watched set wholesale, so two of them in flight - /// resolve by arrival order, not issue order — and the loser strands the - /// watcher on a stale set *permanently*, because the caller only re-issues - /// when the desired set changes. Single-flight instead: one out at a time, - /// re-issued from the current set when it lands. watch_busy: bool, watch_dirty: bool, - /// The directories the watch spans — every open file's parent. watched_dirs: HashSet<PathBuf>, - /// The open files themselves. The watch is per-directory, so this is what - /// separates "a file we care about changed" from "something else in that - /// directory did". watched_files: HashSet<PathBuf>, - /// Feeds changed paths from the watch into the UI-side reload loop spawned - /// in [`EditorPanelState::new`]. events_tx: smol::channel::Sender<Vec<PathBuf>>, } impl EditorPanelState { pub(crate) fn new(window: &mut Window, cx: &mut Context<Tty7App>) -> Self { - // The reload loop lives for the app: it debounces watcher pings and - // routes them to `handle_external_change` on the UI thread. let (tx, rx) = smol::channel::unbounded::<Vec<PathBuf>>(); cx.spawn_in(window, async move |app, cx| { while let Ok(first) = rx.recv().await { @@ -213,15 +96,13 @@ impl EditorPanelState { } let ok = app.update_in(cx, |app, window, cx| { for path in changed { - // The watch is on directories, so most of what arrives - // is about files nobody has open. if app.editor.watched_files.contains(&path) { app.editor_handle_external_change(&path, window, cx); } } }); if ok.is_err() { - break; // app dropped; stop the loop + break; } } }) @@ -239,15 +120,7 @@ impl EditorPanelState { } } -// --------------------------------------------------------------------------- -// Pure helpers (tested). -// --------------------------------------------------------------------------- - -/// The tree-sitter language name for a path, matching the grammars compiled -/// into gpui-component's `tree-sitter-languages` feature. Falls back to -/// `"text"` (plain, no highlighting) for anything unknown. pub(crate) fn language_for_path(path: &Path) -> &'static str { - // Whole-filename matches first (no useful extension). if let Some(name) = path.file_name().and_then(|n| n.to_str()) { let lowered = name.to_ascii_lowercase(); match lowered.as_str() { @@ -255,7 +128,6 @@ pub(crate) fn language_for_path(path: &Path) -> &'static str { "cmakelists.txt" => return "cmake", _ => {} } - // Dotfile shell rc's: .zshrc, .bashrc, .profile… if lowered.starts_with('.') && (lowered.contains("shrc") || lowered.ends_with("profile")) { return "bash"; } @@ -302,36 +174,17 @@ pub(crate) fn language_for_path(path: &Path) -> &'static str { } } -/// Quick binary sniff: a NUL byte in the head of the file. Text files never -/// contain NULs; this catches executables/images before `from_utf8` chokes on -/// them with a less helpful error. fn looks_binary(bytes: &[u8]) -> bool { bytes.iter().take(8192).any(|b| *b == 0) } -/// What a watcher event means for one buffer holding the changed file. #[derive(Debug, PartialEq, Eq)] enum ExternalChange { - /// Not a change we should act on — our own write, or one we cannot yet - /// distinguish from our own write. Ignore, - /// Disk moved under unsaved edits: raise the banner and let the user pick. Conflict, - /// Clean buffer, changed file: take the new content silently. Reload, } -/// Decide what a changed file means for one buffer. -/// -/// Pulled out of the event handler because it is the whole of the -/// external-change contract and the only part of it worth testing directly: -/// everything around it is GPUI plumbing. -/// -/// `saving` is the subtle one. While our own write is in flight `disk_mtime` -/// still names the *previous* content, so the echo test below would call our -/// own save an external change and — on a clean buffer — reload the file out -/// from under the write. The write's landing sets the new mtime; anything -/// genuinely external gets reported again after it. fn classify_external_change( saving: bool, dirty: bool, @@ -341,9 +194,6 @@ fn classify_external_change( if saving { return ExternalChange::Ignore; } - // Our own save's echo: the mtime matches what we last wrote or loaded. - // `Some` on both sides deliberately — a filesystem with no mtime cannot - // prove an echo, and guessing "echo" there would drop real changes. if observed.is_some() && observed == disk_mtime { return ExternalChange::Ignore; } @@ -354,26 +204,12 @@ fn classify_external_change( } } -/// What a landed write does to the buffer it wrote. -/// -/// Separated for the same reason: this is the three-way answer that the ⌘S -/// exemption turns on, and it is pure. #[derive(Debug, PartialEq, Eq)] struct SaveLanding { - /// The buffer still holds what reached disk, so it may be marked clean. clean: bool, - /// Another save was asked for while this one flew; re-issue it. requeue: bool, } -/// Settle an in-flight write. -/// -/// `wrote_seq` is the buffer's edit counter when the snapshot was taken and -/// `current_seq` is where it is now: unequal means the user kept typing, so the -/// bytes on disk are already stale and the buffer stays dirty. -/// -/// A failed write never requeues — a path that cannot be written would -/// otherwise re-issue forever, one notification per round. fn settle_save(ok: bool, wrote_seq: u64, current_seq: u64, pending: bool) -> SaveLanding { SaveLanding { clean: ok && wrote_seq == current_seq, @@ -381,12 +217,7 @@ fn settle_save(ok: bool, wrote_seq: u64, current_seq: u64, pending: bool) -> Sav } } -// --------------------------------------------------------------------------- -// Tty7App: open / save / close / external reload. -// --------------------------------------------------------------------------- - impl Tty7App { - /// The active tab's code-panel state, if the panel was ever opened there. pub(crate) fn tab_code(&self) -> Option<&TabCode> { self.tabs.get(self.active)?.code.as_deref() } @@ -395,24 +226,15 @@ impl Tty7App { self.tabs.get_mut(self.active)?.code.as_deref_mut() } - /// Like [`tab_code_mut`], but creates the state instead of returning `None`. - /// The panel state used to be born with the code overlay, so anything that - /// needed it could assume the overlay had been opened at least once — no - /// longer true now that the right panel's Files tab renders the same tree - /// without ever opening the overlay. pub(crate) fn tab_code_mut_or_init(&mut self) -> Option<&mut TabCode> { let tab = self.tabs.get_mut(self.active)?; Some(tab.code.get_or_insert_with(|| Box::new(TabCode::new()))) } - /// Whether the active tab's code panel is currently shown. pub(crate) fn code_panel_visible(&self) -> bool { self.tab_code().is_some_and(|c| c.visible) } - /// Rebuild the external-change watcher over every tab's open files. - /// Watches each file's *parent directory* (non-recursively): editors that - /// save via rename replace the inode, which a direct file watch loses. fn editor_rebuild_watcher(&mut self, cx: &mut Context<Self>) { let files: HashSet<PathBuf> = self .tabs @@ -432,27 +254,12 @@ impl Tty7App { self.editor_watch_apply(cx); } - /// Push `editor.watched_dirs` at the subscription, opening one first if - /// there isn't one yet. - /// - /// Split from [`editor_rebuild_watcher`](Self::editor_rebuild_watcher) - /// because that one returns early when the set hasn't moved — which is - /// right for a caller reacting to an open or a close, and wrong for the - /// landing below, whose whole job is to apply a set that moved while there - /// was nothing to apply it to. fn editor_watch_apply(&mut self, cx: &mut Context<Self>) { let want: Vec<PathBuf> = self.editor.watched_dirs.iter().cloned().collect(); let Some(host) = self.active_host(cx) else { return; }; - // Same rule as the file tree's: a subscription belongs to the host that - // opened it. A reconnect inserts a fresh `RemoteHost` under the same - // `HostId`, so the id matches while the `ControlClient` behind this - // subscription is gone — `set_dirs` then fails, is warned and dropped, - // and nothing opens a new one. The cost here is quieter and worse than - // a stale tree: external-change detection is what stops a save - // clobbering an edit made on the other side. if !self .editor .watch_host @@ -488,8 +295,6 @@ impl Tty7App { return; } if self.editor.watch_opening { - // The landing re-reads `watched_dirs`, so a set that moved while - // the subscription was opening is applied when it arrives. return; } self.editor.watch_opening = true; @@ -532,9 +337,6 @@ impl Tty7App { ); } - /// Open `path` in the active tab's editor (activating an existing file tab - /// when it is already open) and reveal the panel. Errors surface as window - /// notifications rather than a half-open tab. pub(crate) fn open_file_in_editor( &mut self, path: &Path, @@ -544,15 +346,7 @@ impl Tty7App { if self.tabs.get(self.active).is_none() { return; } - // Opening a file is an act on the editor, so it comes forward — the - // file tree lives in the right panel and stays clickable even while the - // diff overlay covers the column. self.raise_code_overlay(); - // The already-open check runs twice: once here against the path as - // given, so the overwhelmingly common case (a click on a tree row, - // whose path is already canonical) costs nothing, and once more when - // the canonical path comes back, which is the one that is actually - // authoritative. if self.editor_activate_open(path, window, cx) { return; } @@ -564,14 +358,7 @@ impl Tty7App { host, window, cx, - // The failure arm carries the finished message rather than an - // error value: every one of these is phrased around the path, and - // the path is only settled once `canonicalize` has run out here. move |h| -> Result<(PathBuf, String, Option<MTime>), String> { - // Canonicalize first — it decides identity, and two paths to - // one file must not become two buffers. A failure keeps the - // path as given, which is the habit this call site has always - // had. let path = h.canonicalize(&p).unwrap_or(p); let meta = match h.stat(&path) { Ok(m) => m, @@ -602,8 +389,6 @@ impl Tty7App { ); } - /// Bring an already-open `path` to the front, reporting whether it was - /// open at all. fn editor_activate_open( &mut self, path: &Path, @@ -617,9 +402,6 @@ impl Tty7App { return false; }; code.visible = true; - // Activating always surfaces to the front of the strip: the strip - // is MRU-ordered and only its head fits on screen (see - // `render_editor_tabs`), so the active file must live there. let f = code.files.remove(ix); code.files.insert(0, f); code.active = 0; @@ -628,7 +410,6 @@ impl Tty7App { true } - /// Put a file that finished loading into the active tab. fn editor_install_file( &mut self, path: PathBuf, @@ -637,9 +418,6 @@ impl Tty7App { window: &mut Window, cx: &mut Context<Self>, ) { - // The canonical path is the authoritative identity, and the load took - // long enough that the file may have been opened by another route in - // the meantime. if self.editor_activate_open(&path, window, cx) { return; } @@ -662,9 +440,6 @@ impl Tty7App { .soft_wrap(false) .default_value(text) }); - // Dirty tracking: `set_value` suppresses events, so every Change here - // is a real user edit. Files may be open in any tab, not just the - // active one, so the lookup scans all tabs. let sub = cx.subscribe_in(&input, window, { let path = path.clone(); move |this: &mut Tty7App, _input, ev, _window, cx| { @@ -679,9 +454,6 @@ impl Tty7App { return; }; f.dirty = true; - // Every edit moves the buffer away from whatever an - // in-flight save is writing, which is how that save knows - // not to declare the buffer clean when it lands. f.edit_seq = f.edit_seq.wrapping_add(1); cx.notify(); } @@ -693,7 +465,6 @@ impl Tty7App { .expect("checked at function entry"); let code = tab.code.get_or_insert_with(|| Box::new(TabCode::new())); let observe = cx.observe(&input, |_, _, cx| cx.notify()); - // New files join at the front of the MRU strip (always visible). code.files.insert( 0, OpenFile { @@ -720,17 +491,10 @@ impl Tty7App { cx.notify(); } - /// `ToggleCodePanel` (⌘⇧E / the title-bar tree icon / Esc): flip the - /// active tab's code overlay. First open creates the tab's panel state; - /// hiding keeps it (open files survive Esc), and only closing the tab - /// drops it. Opening re-roots the file tree from the tab's panes and - /// focuses the panel; closing hands focus back to the terminal. pub(crate) fn toggle_code_panel(&mut self, window: &mut Window, cx: &mut Context<Self>) { let Some(tab) = self.tabs.get_mut(self.active) else { return; }; - // Buried under the diff overlay, this shortcut means "come forward" — - // hiding a panel the user can't see would look like it did nothing. let buried = tab.overlay_top == crate::ui::app::OverlayTop::Diff && tab.diff_overlay.is_some() && tab.code.as_ref().is_some_and(|c| c.visible); @@ -761,23 +525,18 @@ impl Tty7App { cx.notify(); } - /// Bring the code overlay in front of the diff overlay. See - /// [`Tab::overlay_top`](crate::ui::app::Tab). fn raise_code_overlay(&mut self) { if let Some(tab) = self.tabs.get_mut(self.active) { tab.overlay_top = crate::ui::app::OverlayTop::Code; } } - /// Focus the active file's text input (e.g. right after opening a file). fn focus_editor(&self, window: &mut Window, cx: &mut Context<Self>) { if let Some(f) = self.tab_code().and_then(|c| c.active_file()) { f.input.update(cx, |input, cx| input.focus(window, cx)); } } - /// Whether keyboard focus currently sits inside the editor panel. Lets - /// shared shortcuts (⌘S, ⌘W) route here before their terminal meaning. pub(crate) fn editor_has_focus(&self, window: &Window, cx: &Context<Self>) -> bool { self.code_panel_visible() && self @@ -791,7 +550,6 @@ impl Tty7App { }) } - /// `EditorSave` (⌘S): write the active buffer back to its path. pub(crate) fn editor_save_active(&mut self, window: &mut Window, cx: &mut Context<Self>) { let Some(id) = self .tab_code() @@ -803,26 +561,6 @@ impl Tty7App { self.editor_save_file(id, false, window, cx); } - /// Write one buffer back to its path, optionally closing it once the write - /// lands. - /// - /// The write is asynchronous (an explicit exemption): ⌘S no longer - /// blocks the UI thread, so the dirty marker clears a frame later rather - /// than instantly. Three things that costs us, and how each is paid: - /// - /// | Case | Handling | - /// |---|---| - /// | The user keeps typing while the write is in flight | The snapshot's `edit_seq` is compared on landing; a buffer that moved stays dirty, because it no longer matches what reached disk | - /// | Two ⌘S in a row | Single-flight. The second sets `save_pending` and is re-issued when the first lands, so the newest content wins and two writes never race for the same file | - /// | The write fails | The buffer stays dirty, `save_pending` is dropped so a failing path can't notify in a loop, and the error is shown | - /// - /// The buffer is named by the `EntityId` of its input, which is the only - /// identity that survives the wait. A tab index does not: closing or - /// reordering a *terminal* tab shifts `self.tabs` under an in-flight write, - /// and the landing would then either miss the buffer — stranding `saving` - /// set, which silently disables every later save *and* every external-change - /// check for that file — or find a different buffer of the same path in - /// another tab and settle that one instead. fn editor_save_file( &mut self, id: gpui::EntityId, @@ -830,22 +568,14 @@ impl Tty7App { window: &mut Window, cx: &mut Context<Self>, ) { - // Resolved before the buffer is borrowed: the write cannot go anywhere - // without a machine to write to, and taking it after would hold a - // mutable borrow of `self` across an immutable read of it. let Some(host) = self.active_host(cx) else { return; }; let Some(f) = self.editor_file_mut(id) else { return; }; - // Sticky, and OR-accumulated: a close asked for while a plain ⌘S is in - // flight must still close when that write lands. f.save_then_close |= then_close; if f.saving.is_some() { - // A write is already out for this buffer. Queue rather than race: - // two writes of the same file can land on disk in either order, and - // the loser would leave stale content behind. f.save_pending = true; return; } @@ -857,9 +587,6 @@ impl Tty7App { host, window, cx, - // One call, one round trip: the write answers with its own - // post-write metadata, so no external edit can land between the - // write and a follow-up `stat` and be mistaken for ours. move |h| h.write_file(&target, text.as_bytes()).map(|m| m.mtime), move |app, result: std::io::Result<Option<MTime>>, window, cx| { let Some(f) = app.editor_file_mut(id) else { @@ -874,8 +601,6 @@ impl Tty7App { ); match result { Ok(mtime) => { - // The mtime of the bytes we just wrote, so the watcher - // echo of our own save is recognised and ignored. f.disk_mtime = mtime; } Err(e) => HostOps::notify_err(window, cx, "Save failed", &e), @@ -885,8 +610,6 @@ impl Tty7App { f.conflict = false; } if landing.requeue { - // `save_then_close` stays on the buffer, so the queued - // round inherits it rather than the first caller's copy. app.editor_save_file(id, false, window, cx); cx.notify(); return; @@ -903,10 +626,6 @@ impl Tty7App { cx.notify(); } - /// One open buffer, by the identity of its input entity. - /// - /// Scans every tab: a file may be open in more than one, and the entity id - /// is what tells those buffers apart. fn editor_file_mut(&mut self, id: gpui::EntityId) -> Option<&mut OpenFile> { self.tabs .iter_mut() @@ -915,8 +634,6 @@ impl Tty7App { .find(|f| f.input.entity_id() == id) } - /// Where a buffer sits right now, as `(tab index, file index)`. Both move, - /// so this is only ever valid for the duration of one UI-thread turn. fn editor_file_position(&self, id: gpui::EntityId) -> Option<(usize, usize)> { self.tabs.iter().enumerate().find_map(|(tab_ix, t)| { let code = t.code.as_deref()?; @@ -925,8 +642,6 @@ impl Tty7App { }) } - /// Close the file tab at `ix`. Dirty buffers get a native three-way prompt - /// (save / discard / cancel) before anything is lost. pub(crate) fn editor_close_file( &mut self, ix: usize, @@ -948,15 +663,10 @@ impl Tty7App { &["Save", "Discard", "Cancel"], cx, ); - // The prompt is awaited, so the buffer is named by its input entity - // rather than by an index that closing another tab would shift. let id = f.input.entity_id(); cx.spawn_in(window, async move |app, cx| { let Ok(choice) = answer.await else { return }; let _ = app.update_in(cx, |app, window, cx| match choice { - // Save, then close — the close rides on the write landing (see - // `editor_save_file`), so a failed save keeps the tab open - // without the caller having to re-check anything. 0 => app.editor_save_file(id, true, window, cx), 1 => { if let Some((tab_ix, ix)) = app.editor_file_position(id) { @@ -969,8 +679,6 @@ impl Tty7App { .detach(); } - /// If focus is in the editor, close the active file tab and report `true` - /// (so ⌘W routes here instead of closing the terminal tab). pub(crate) fn editor_close_active_if_focused( &mut self, window: &mut Window, @@ -996,9 +704,6 @@ impl Tty7App { self.editor_remove_file_in(self.active, ix, cx); } - /// [`editor_remove_file`](Self::editor_remove_file) for a named tab — the - /// save-then-close path lands after an await, by which time the active tab - /// may not be the one the file is in. fn editor_remove_file_in(&mut self, tab_ix: usize, ix: usize, cx: &mut Context<Self>) { let Some(code) = self .tabs @@ -1018,9 +723,6 @@ impl Tty7App { cx.notify(); } - /// A watched file changed on disk. Clean buffers reload silently; dirty - /// ones raise the conflict banner and let the user pick a side. The file - /// may be open in several tabs — each buffer is handled on its own. pub(crate) fn editor_handle_external_change( &mut self, path: &Path, @@ -1043,8 +745,6 @@ impl Tty7App { ); } - /// Decide what a changed file means for each buffer holding it, once the - /// host has answered with its mtime. fn editor_apply_external_change( &mut self, path: &Path, @@ -1080,9 +780,6 @@ impl Tty7App { } } - /// Replace one buffer with the on-disk content (used by the silent reload - /// and the conflict banner's "Reload" choice). A vanished file just keeps - /// the buffer and marks it dirty — saving will recreate it. pub(crate) fn editor_reload_from_disk( &mut self, tab_ix: usize, @@ -1100,10 +797,6 @@ impl Tty7App { }; let target = f.path.clone(); let id = f.input.entity_id(); - // Only the newest reload may install. Two watcher batches can put two - // reads in flight, and background completion order is unconstrained — - // an older answer landing last would install stale text and mark it - // clean, leaving a buffer that does not match disk and never rechecks. f.reload_seq = f.reload_seq.wrapping_add(1); let seq = f.reload_seq; let Some(host) = self.active_host(cx) else { @@ -1114,8 +807,6 @@ impl Tty7App { window, cx, move |h| { - // One hop for both, so the mtime belongs to the bytes we read - // rather than to whatever the file became in between. let bytes = h.read_file(&target, MAX_FILE_BYTES)?; let text = String::from_utf8(bytes).map_err(|_| { std::io::Error::new(std::io::ErrorKind::InvalidData, "not valid UTF-8") @@ -1128,11 +819,9 @@ impl Tty7App { return; }; if f.reload_seq != seq { - return; // a newer reload supersedes this answer + return; } let Ok((text, mtime)) = result else { - // A vanished (or unreadable) file keeps the buffer and - // marks it dirty — saving will recreate it. f.dirty = true; f.conflict = false; cx.notify(); @@ -1141,10 +830,6 @@ impl Tty7App { f.disk_mtime = mtime; f.dirty = false; f.conflict = false; - // The reload replaces the text wholesale, and `set_value` - // suppresses the Change event, so `edit_seq` must move by hand - // — otherwise a save in flight would look like it still - // matched the buffer. f.edit_seq = f.edit_seq.wrapping_add(1); let input = f.input.clone(); input.update(cx, |input, cx| input.set_value(text, window, cx)); @@ -1154,14 +839,7 @@ impl Tty7App { } } -// --------------------------------------------------------------------------- -// Rendering. -// --------------------------------------------------------------------------- - impl Tty7App { - /// The code panel: a full-body overlay of `[file tree | editor]` covering - /// the terminal (settings/diff-overlay style), or `None` while closed. - /// The terminal underneath keeps its size — toggling never reflows it. pub(crate) fn render_code_overlay( &mut self, window: &mut Window, @@ -1172,7 +850,6 @@ impl Tty7App { } let body = match self.tab_code().and_then(|c| c.active_file()) { None => self.render_editor_empty(cx).into_any_element(), - // Markdown preview replaces the buffer with a rendered view. Some(f) if f.preview => { let markdown = f.input.read(cx).text().to_string(); div() @@ -1189,8 +866,6 @@ impl Tty7App { } Some(f) => { let input = f.input.clone(); - // `appearance(false)`: no border/background of its own — the - // buffer sits flush in the panel instead of in a rounded box. Input::new(&input) .appearance(false) .font_family(cx.theme().mono_font_family.clone()) @@ -1217,41 +892,20 @@ impl Tty7App { v_flex() .id("code-panel") .absolute() - // Fills its column, which is now everything *except* the detail - // panel — the panel is a sibling of that column, not a child of it, - // so the tree that opens files stays visible beside the editor - // without the overlay needing to know the panel's width. .inset_0() - // The overlay must swallow input to the terminal behind it. .occlude() .bg(cx.theme().background) - // Escape (not consumed by the editor's own search/completion - // handling, which stops propagation) drops back to the terminal. .on_key_down(cx.listener(|this, ev: &gpui::KeyDownEvent, window, cx| { if ev.keystroke.key == "escape" { this.toggle_code_panel(window, cx); } })) - // No top inset: the header row below *is* the title bar's row, and - // it clears the window controls itself (see `render_editor_header`). - // Padding the whole overlay down would cost a blank 40px band and - // still misalign the editor's top edge with the panel's tab row. - // No tree column here: the right panel owns the file tree now, and - // the overlay stops short of it (see the `right` inset above), so - // the tree stays visible beside the editor instead of being - // duplicated inside it. .child(h_flex().flex_1().min_h_0().w_full().child(editor_col)) .child(self.render_code_status_bar(window, cx)) .into_any_element(), ) } - /// The editor's one header row: which file is open, and a way back to the - /// terminal. Not a tab strip — the file tree is the switcher now, so this only - /// has to answer "what am I looking at" without earning a row of chrome for - /// every buffer that was ever opened. Sits on the title bar's line and matches - /// its height, so the editor's top edge lines up with the panel's tab row and - /// the rail's controls across the window. fn render_editor_header( &self, window: &mut Window, @@ -1260,20 +914,11 @@ impl Tty7App { let active = self.tab_code().and_then(|c| c.active_file()); let name = active.map(|f| f.label()); let dirty = active.is_some_and(|f| f.dirty); - // The overlay fills the column left of the detail panel. With the rail out - // that column starts after it, and the traffic lights sit on the rail's - // surface — but with the rail collapsed (or in horizontal-tabs mode) the - // column starts at the window's left edge and the lights are right where - // the filename would go, so the header takes the window controls' reserve - // as its inset instead. let lead = if self.left_panel_open(cx) { crate::ui::app::CONTENT_INSET } else { crate::ui::app::TITLE_BAR_LEAD }; - // The overlay covers the real title bar, so this row inherits its drag and - // zoom gestures — otherwise opening a file turns the top of the window into - // a strip that looks like the caption and can't move it. crate::ui::app::title_bar_drag(h_flex().id("editor-header"), "editor-header", window, cx) .flex_none() .h(px(crate::ui::app::TITLE_BAR_HEIGHT)) @@ -1294,7 +939,6 @@ impl Tty7App { }) .child(name.unwrap_or_else(|| SharedString::from("No file open"))), ) - // Same amber dot the tree marks unsaved files with. .when(dirty, |d| { d.child( div() @@ -1305,15 +949,8 @@ impl Tty7App { ) }) .child( - // `occlude()` for the same reason the title bar's own tiles carry - // it: this row is a `WindowControlArea::Drag`, which on Windows is - // HTCAPTION, and the OS takes the press before gpui hit-tests. div().occlude().flex_shrink_0().child( crate::ui::tab_strip::chrome_tile_sized( - // This header is the title bar's own height and sits flush - // with it, so its one control is a full chrome tile — not the - // half-size one it used to be, which read as a different - // class of button on the same line. Button::new("editor-panel-close").icon(Icon::new(IconName::Close)), crate::ui::app::TILE_SIZE, crate::ui::app::TILE_GLYPH_LINE, @@ -1329,12 +966,9 @@ impl Tty7App { ) } - /// The Zed-style status bar along the panel bottom: repo-relative path on - /// the left; preview/wrap toggles and the cursor position on the right. fn render_code_status_bar(&self, _window: &Window, cx: &mut Context<Self>) -> gpui::Div { let code = self.tab_code(); let muted = cx.theme().muted_foreground; - // `repo › relative/path` for the active file; just the repo otherwise. let path_text: Option<SharedString> = code.map(|c| { let repo = c .roots @@ -1420,7 +1054,6 @@ impl Tty7App { .when_some(cursor, |this, t| this.child(div().child(t))) } - /// Empty state: the panel is open with nothing loaded. fn render_editor_empty(&self, cx: &Context<Self>) -> gpui::Div { v_flex() .size_full() @@ -1440,7 +1073,6 @@ impl Tty7App { ) } - /// Banner shown when the file changed on disk while the buffer is dirty. fn render_editor_conflict_banner(&self, cx: &mut Context<Self>) -> AnyElement { let tab_ix = self.active; let ix = self.tab_code().map(|c| c.active).unwrap_or(0); @@ -1514,58 +1146,43 @@ mod tests { Some(MTime { secs, nanos }) } - /// M2 regression guard: the save → external-change → - /// reload states still decide correctly now that the write is asynchronous. #[test] fn external_changes_are_told_apart_from_our_own_saves() { let ours = t(100, 0); - // The echo of our own save: same mtime, nothing to do. assert_eq!( classify_external_change(false, false, ours, ours), ExternalChange::Ignore ); - // A real external edit to a clean buffer reloads silently. assert_eq!( classify_external_change(false, false, ours, t(101, 0)), ExternalChange::Reload ); - // The same edit under unsaved work raises the banner instead of - // clobbering either side. assert_eq!( classify_external_change(false, true, ours, t(101, 0)), ExternalChange::Conflict ); - // Nanosecond precision is the point of `MTime`: an external write in - // the same second as ours must not read as an echo. assert_eq!( classify_external_change(false, false, t(100, 0), t(100, 1)), ExternalChange::Reload ); - // While our own write is in flight, `disk_mtime` still names the old - // content — acting on it would reload the file out from under the save. assert_eq!( classify_external_change(true, false, ours, t(101, 0)), ExternalChange::Ignore ); - // A filesystem with no mtime cannot prove an echo, so a change there is - // treated as real rather than silently dropped. assert_eq!( classify_external_change(false, false, None, None), ExternalChange::Reload ); } - /// M2 regression guard (the ⌘S exemption): the three things - /// asynchronous saving has to get right. #[test] fn a_landed_save_only_cleans_a_buffer_that_did_not_move() { - // Nothing happened during the write: the buffer is clean. assert_eq!( settle_save(true, 7, 7, false), SaveLanding { @@ -1574,8 +1191,6 @@ mod tests { } ); - // The user kept typing: what reached disk is already stale, so the - // buffer stays dirty and the amber dot stays up. assert_eq!( settle_save(true, 7, 9, false), SaveLanding { @@ -1584,7 +1199,6 @@ mod tests { } ); - // A second ⌘S arrived mid-write: re-issue it so the newest content wins. assert_eq!( settle_save(true, 7, 9, true), SaveLanding { @@ -1593,8 +1207,6 @@ mod tests { } ); - // A failed write never cleans and never re-issues — requeueing a path - // that cannot be written is an infinite notification loop. assert_eq!( settle_save(false, 7, 7, true), SaveLanding { diff --git a/src/ui/diff_overlay.rs b/src/ui/diff_overlay.rs index 8892d27b..8662337e 100644 --- a/src/ui/diff_overlay.rs +++ b/src/ui/diff_overlay.rs @@ -1,38 +1,3 @@ -//! The working-tree diff overlay: a read-only, GitHub-style side-by-side diff -//! that covers the terminal area when the user clicks a sidebar row's git line -//! (`⎇ branch +N −N`) or a Changes-panel row. A scrolling column of per-file -//! cards with collapsible hunk bodies — old on the left, new on the right — -//! plus an untracked-files section `git diff` itself can't show. -//! -//! The sidebar half of that is opt-out: everything below assumes the git line -//! is a click target, which it is unless -//! [`sidebar_diff_preview`](crate::core::config::Config::sidebar_diff_preview) -//! is off. The panel row is not gated. -//! -//! Deliberately a *lens*, not a git client: no staging, no discard. The -//! terminal keeps running underneath (the overlay covers -//! only the body area, never the sidebar, so other tabs' git lines stay -//! clickable to switch which repo is shown). The overlay belongs to the tab it -//! was opened on: switching tabs hides it, switching back restores it, closing -//! the tab drops it. Esc, the ✕, or re-clicking the same git line closes it. -//! -//! Data comes from [`crate::terminal::git_diff`], probed off-thread on open -//! and re-probed automatically while open whenever the shared -//! [`GitStatusCache`](crate::terminal::git_status::GitStatusCache) lands a -//! snapshot whose branch or counts disagree with what's shown — so a finishing -//! command or agent turn refreshes the overlay through the exact trigger -//! machinery the sidebar numbers already use. -//! -//! One probe, one snapshot, however many watchers: every tab's overlay and the -//! Changes panel go through -//! [`spawn_shared_diff_probe`](Tty7App::spawn_shared_diff_probe) and hold the -//! result behind an `Arc`. The element tree, meanwhile, is *not* virtualized — -//! so what keeps a big working tree from stalling the window is refusing to -//! build the rows in the first place: past -//! [`AUTO_COLLAPSE_TOTAL_LINES`](git_diff::AUTO_COLLAPSE_TOTAL_LINES) every -//! file opens collapsed under a summary, and at most -//! [`MAX_RENDERED_FILES`] cards are built at all. - use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -51,70 +16,23 @@ use crate::ui::app::Tty7App; use crate::ui::rounding; use crate::ui::rounding::RoundedCorners as _; -/// What the overlay currently shows: probing, a parsed snapshot, or the -/// answer that the cwd stopped being a repo. pub(crate) enum DiffLoad { - /// First probe still in flight. Loading, - /// A landed snapshot, shared rather than owned: one probe result reaches - /// every tab whose overlay watches this cwd *and* the Changes panel, and - /// the snapshot is a deep tree of owned strings — cloning it per holder on - /// the UI update path is exactly the cost issue #239 measured as a stall. Ready(Arc<DiffSnapshot>), - /// The probe came back "not a work tree" (repo deleted, dir gone). NotARepo, } -/// State of an open diff overlay (`None` on its [`Tab`](crate::ui::app::Tab) -/// when closed). Per-tab: switching tabs hides/restores it, closing the tab -/// drops it; only the active tab's overlay is rendered. pub(crate) struct DiffOverlayState { - /// The machine the diff is read from — the pane's own host, so an overlay - /// opened on a pane whose repository lives elsewhere shows *that* - /// repository. Part of the toggle key together with `cwd`: the same path on - /// two machines is two different diffs. - /// - /// The id, not the host object: an overlay outlives a reconnect (it is only - /// dropped by closing it or its tab), and the object it was opened with - /// belongs to the connection that has since been replaced. Every re-probe - /// resolves the id afresh, so a reconnected machine's next refresh lands - /// instead of failing forever against a dead client. pub(crate) host_id: crate::ui::host_ops::HostId, - /// The pane cwd the diff is probed from — the same path the clicked git - /// line resolved its status through, so overlay and sidebar agree on the - /// repo. Also the toggle key: re-clicking a line with this cwd closes. pub(crate) cwd: PathBuf, - /// Focus target so Esc lands on the overlay's key handler. pub(crate) focus_handle: FocusHandle, pub(crate) load: DiffLoad, - /// A probe is currently in flight (initial or refresh). pub(crate) loading: bool, - /// Files the user has explicitly expanded (`true`) or collapsed (`false`), - /// keyed by path so the choice survives a background refresh of the - /// snapshot. Absent means "follow the default". - /// - /// Absolute state, deliberately not an inversion set. It used to be a - /// `HashSet` of "files flipped away from their default", which was fine - /// while the default was per-file and stable — but the repo-wide - /// `collapse_all` moves the default for *every* file at once, so a refresh - /// that crossed the oversized threshold inverted every explicit choice - /// simultaneously: the two files the user had opened snapped shut and the - /// rest sprang open. Storing what the user actually wanted makes a moving - /// default unable to touch it. pub(crate) expanded: HashMap<String, bool>, - /// When set, the overlay shows only this file (repo-relative path), always - /// expanded — the "click a row in the Changes panel" entry point. `None` is - /// the whole-tree view the git line opens. Kept as a path rather than an - /// index so a background re-probe that reorders files doesn't swap which - /// file is on screen; a path that vanishes from the diff falls back to the - /// full list rather than showing an empty overlay. pub(crate) focus: Option<String>, } impl Tty7App { - /// Open the diff overlay for `cwd` — or close it when it's already open - /// for that same cwd (the git line acts as a toggle). Opening for a - /// different cwd swaps the overlay's repo in place. pub(crate) fn toggle_diff_overlay( &mut self, host: crate::ui::host_ops::HostId, @@ -125,11 +43,6 @@ impl Tty7App { self.toggle_diff_overlay_at(host, cwd, None, window, cx) } - /// The same toggle, scoped to one file: opens the overlay showing only - /// `focus` (repo-relative), which is what the Changes panel's rows do. The - /// toggle key is the pair — re-clicking the row that's already on screen - /// closes, while clicking a *different* row swaps the shown file in place - /// without the overlay blinking shut and re-probing. pub(crate) fn toggle_diff_overlay_at( &mut self, host: crate::ui::host_ops::HostId, @@ -139,15 +52,9 @@ impl Tty7App { cx: &mut Context<Self>, ) { let active = self.active; - // Was the diff already the front overlay? If it was buried under the - // code panel, this click means "bring it up", not "close it" — closing - // something the user can't currently see would read as the click doing - // nothing. let was_front = self.tabs.get(active).is_some_and(|t| { t.overlay_top == crate::ui::app::OverlayTop::Diff || !self.code_panel_visible() }); - // Acting on the diff raises it over the code panel, whether it was - // already open or not. if let Some(tab) = self.tabs.get_mut(active) { tab.overlay_top = crate::ui::app::OverlayTop::Diff; } @@ -157,19 +64,12 @@ impl Tty7App { .and_then(|t| t.diff_overlay.as_mut()) .filter(|o| o.cwd == cwd && o.host_id == host) { - // Already open on this repo showing this exact thing, and already on - // top — toggle off. Some(o) if o.focus == focus && was_front => { self.close_diff_overlay(window, cx); return; } - // Open on this repo, different file: retarget. The snapshot is - // already loaded and covers every file, so there is nothing to - // re-probe — this is a pure re-render. Some(o) => { o.focus = focus; - // Take focus too, so Esc closes the diff rather than whatever - // was focused before it came forward (often the editor). let handle = o.focus_handle.clone(); window.focus(&handle, cx); cx.notify(); @@ -177,23 +77,15 @@ impl Tty7App { } None => {} } - // The Changes panel may already hold this very repo's snapshot — it is - // the same `git diff HEAD`. Opening on it makes the overlay paint - // immediately instead of flashing "Reading diff…" for a probe whose - // answer is already in the process, and costs an `Arc` bump. A refresh - // probe still flies below, so the seeded view is never the last word. - // Read here, before the `&mut` borrow of the tab. let seed = match (&self.right_panel.diff_cwd, &self.right_panel.diff) { (Some(panel_key), Some(Some(snap))) if *panel_key == (host, cwd.clone()) => { DiffLoad::Ready(Arc::clone(snap)) } _ => DiffLoad::Loading, }; - // The overlay steals focus (it needs Esc); snapshot the active pane so - // closing lands back on the same terminal — same discipline as Settings. self.remember_active_pane(window, cx); let Some(tab) = self.tabs.get_mut(active) else { - return; // home page — no tab body to overlay + return; }; let focus_handle = cx.focus_handle(); tab.diff_overlay = Some(DiffOverlayState { @@ -210,9 +102,6 @@ impl Tty7App { cx.notify(); } - /// The file the active tab's overlay is currently scoped to, if any — the - /// Changes panel reads it to mark the matching row as selected, so panel and - /// overlay can't disagree about what's on screen. pub(crate) fn diff_overlay_focus( &self, host: crate::ui::host_ops::HostId, @@ -222,8 +111,6 @@ impl Tty7App { (overlay.cwd == cwd && overlay.host_id == host).then_some(overlay.focus.as_deref())? } - /// Close the active tab's overlay (Esc, ✕, or the toggle) and give focus - /// back to the active terminal. pub(crate) fn close_diff_overlay(&mut self, window: &mut Window, cx: &mut Context<Self>) { let active = self.active; let taken = self @@ -236,10 +123,6 @@ impl Tty7App { } } - /// Kick off an off-thread full-diff probe for the overlay's cwd. In-flight - /// dedup is a simple flag: refresh triggers while one flies are dropped — - /// the status cache will fire again on the next real change, and a - /// just-landed diff is fresh enough. fn spawn_diff_probe(&mut self, cx: &mut Context<Self>) { let active = self.active; let Some(overlay) = self @@ -254,9 +137,6 @@ impl Tty7App { } let cwd = overlay.cwd.clone(); let id = overlay.host_id; - // A machine that is not registered has nothing to probe. Leave `loading` - // alone so the overlay keeps the snapshot it has (or its loading state) - // and the next trigger tries again — a reconnect re-registers the id. let Some(host) = crate::ui::host_registry::HostRegistry::lookup(cx, id) else { return; }; @@ -264,20 +144,6 @@ impl Tty7App { self.spawn_shared_diff_probe(host, cwd, cx); } - /// One `git diff HEAD` per repository, however many things are waiting on - /// it — where "repository" is the machine *and* the path, since the same - /// path on two hosts is two different work trees. - /// - /// The overlay and the Changes panel used to probe the same repository - /// independently and each keep its own `DiffSnapshot` — issue #239's fifth - /// finding. Deduping here means opening both costs one invocation and one - /// parse, and [`install_diff_snapshot`](Self::install_diff_snapshot) hands - /// the *same* `Arc` to both rather than a second copy. - /// - /// Callers still mark themselves as waiting first (the overlay's `loading` - /// flag, the panel's `diff_pending`): that's the "refreshing…" hint, and it - /// is cleared by whichever probe lands for this repo, not necessarily the - /// one the caller thought it started. pub(crate) fn spawn_shared_diff_probe( &mut self, host: crate::ui::host_ops::SharedHost, @@ -286,13 +152,6 @@ impl Tty7App { ) { let key = (host.id(), cwd.clone()); if !self.diff_probes_inflight.insert(key.clone()) { - // Someone is already asking this exact question — but they asked it - // *earlier*, and the answer in flight describes the tree as it was - // then. This caller only got here because something changed since, - // so folding it into that request would hand it a snapshot already - // known to be stale and leave nothing to trigger another look: the - // overlay's own re-check is gated on `loading`, which the landing - // clears. Remember to ask again instead. self.diff_probes_restale.insert(key); return; } @@ -305,10 +164,6 @@ impl Tty7App { move |app, result, cx| { app.diff_probes_inflight.remove(&key); app.install_diff_snapshot(key.0, &cwd, result.map(Arc::new), cx); - // Re-ask for whoever was folded in above. Cleared first, so the - // fresh probe starts with a clean slate and a request that - // arrives while *it* flies marks the flag again — this converges - // rather than looping, because a quiet tree never sets it. if app.diff_probes_restale.remove(&(key.0, cwd.clone())) { app.spawn_shared_diff_probe(host_for_retry, cwd, cx); } @@ -316,11 +171,6 @@ impl Tty7App { ); } - /// Hand a landed probe to everything watching `cwd`: every tab whose - /// overlay shows it (the spawning tab may no longer be active, and sibling - /// tabs on the same repo are equally stale) and the Changes panel when it - /// is on the same cwd. Slots that closed or swapped repos while the probe - /// flew are skipped. fn install_diff_snapshot( &mut self, host: crate::ui::host_ops::HostId, @@ -338,30 +188,17 @@ impl Tty7App { continue; }; overlay.loading = false; - // `Arc::clone`, not a deep copy of the file/hunk/line tree. overlay.load = match &snap { Some(snap) => DiffLoad::Ready(Arc::clone(snap)), None => DiffLoad::NotARepo, }; landed = true; } - // The panel's *wait* is cleared by the answer it asked for, whoever - // actually ran it — that's what `diff_pending` is for, and clearing it - // on a result for a repo the panel has since left is what lets the - // render path notice nothing is cached and re-probe. let key = (host, cwd.to_path_buf()); if self.right_panel.diff_pending.as_ref() == Some(&key) { self.right_panel.diff_pending = None; landed = true; } - // The panel's *data*, though, is claimed by the repo key alone. - // Requiring the panel to have been the one waiting meant a probe the - // overlay started was thrown away for the panel even when it was - // sitting on that exact repo — so clicking the sidebar counts left the - // overlay showing the new snapshot and the panel still rendering the - // old one, in the same window. With probes deduped per repo there is at - // most one in flight, so there is no out-of-order overwrite to guard - // against. if self.right_panel.diff_cwd.as_ref() == Some(&key) { self.right_panel.diff = Some(snap); landed = true; @@ -371,15 +208,7 @@ impl Tty7App { } } - /// Re-probe the open overlay when the shared status cache learned - /// something newer than what's shown — called from the app's - /// `observe_global::<GitStatusCache>` hook, i.e. on the very triggers - /// (command end, agent-turn end, cwd change) that refresh the sidebar - /// numbers. Comparing branch + totals keeps the quiet case (unrelated - /// repo's probe landing) from spawning needless `git diff` runs. pub(crate) fn maybe_refresh_diff_overlay(&mut self, cx: &mut Context<Self>) { - // Only the active tab's overlay is visible; hidden ones catch up via - // this same check when their tab is activated (`activate` calls us). let Some(overlay) = self .tabs .get(self.active) @@ -391,7 +220,7 @@ impl Tty7App { return; } let DiffLoad::Ready(snap) = &overlay.load else { - return; // initial probe pending, or repo gone — nothing to diff against + return; }; let Some(status) = cx .try_global::<crate::terminal::git_status::GitStatusCache>() @@ -404,9 +233,6 @@ impl Tty7App { } } - /// The overlay element, or `None` when closed. Mounted as the topmost - /// absolute child of the body area — it covers the terminal but not the - /// sidebar or title strip. pub(crate) fn render_diff_overlay( &self, window: &mut Window, @@ -417,10 +243,6 @@ impl Tty7App { let content = match &overlay.load { DiffLoad::Loading => self.diff_message("Reading diff…", cx), DiffLoad::NotARepo => self.diff_message("Not a git repository", cx), - // Empty because the read broke, not because the tree is clean. Both - // land here as a snapshot with no files — see - // `DiffSnapshot::read_failed` — and only one of them may be reported - // as a fact about the repository. DiffLoad::Ready(snap) if empty_snapshot(snap) && snap.read_failed => self.diff_message( "Couldn't read the working-tree diff — retrying on the next refresh.", cx, @@ -439,13 +261,7 @@ impl Tty7App { v_flex() .absolute() .inset_0() - // Blocks mouse from reaching the terminal underneath. .occlude() - // Same gradient/opacity-aware paint as the root and the settings - // overlay, so a gradient or image theme doesn't snap to a flat - // color here. On a translucent theme this second layer compounds - // the alpha a little — deliberate: the overlay must occlude the - // terminal behind it to stay readable. .bg( match cx.try_global::<crate::ui::presets::ActiveBackground>() { Some(bg) => crate::ui::theme::window_background(bg), @@ -465,17 +281,12 @@ impl Tty7App { ) } - /// Top bar: branch, file/line totals, a subtle refresh spinner slot, ✕. fn diff_header( &self, overlay: &DiffOverlayState, window: &mut Window, cx: &mut Context<Self>, ) -> impl IntoElement + use<> { - // Through `stats` like every other whole-snapshot question on the render - // path, rather than `totals` plus `untracked_count`: same single walk, - // and it keeps "ask the snapshot once" a rule with no exceptions to - // drift from. let (branch, files, untracked, added, removed) = match &overlay.load { DiffLoad::Ready(s) => { let stats = s.stats(); @@ -484,18 +295,11 @@ impl Tty7App { } _ => (String::new(), 0, 0, 0, 0), }; - // The overlay now covers the title strip, so its header *is* the title - // bar for as long as it's up: same height, and the same left inset the - // editor header uses — content clears the traffic lights whenever the - // rail isn't there to hold that space for us. let lead = if self.left_panel_open(cx) { crate::ui::app::CONTENT_INSET } else { crate::ui::app::TITLE_BAR_LEAD }; - // Standing in for the title bar means carrying its gestures too: the - // overlay covers the real bar, so without this the whole top of the window - // stops moving it while a diff is up. let row = crate::ui::app::title_bar_drag( h_flex().id("diff-overlay-header"), "diff-overlay-header", @@ -505,7 +309,6 @@ impl Tty7App { row.flex_shrink_0() .h(px(crate::ui::app::TITLE_BAR_HEIGHT)) .pl(px(lead)) - // Trailing tile aligns on its glyph's ink, like every corner control. .pr(px(crate::ui::app::tile_trailing_inset())) .gap_2() .items_center() @@ -524,13 +327,7 @@ impl Tty7App { .font_weight(FontWeight::MEDIUM) .child(branch), ) - // Scoped to one file: the branch stays (it's still what we diff - // against) but the totals give way to the file's own name, with a - // click target back to the whole tree — otherwise the only way out - // of a focused view would be to close and re-open the overlay. .when_some(focused_name(overlay), |bar, name| { - // Wrapped like every other control on a drag row — see the header's - // own note: HTCAPTION would otherwise swallow the click on Windows. bar.child( div().occlude().flex_shrink_0().child( h_flex() @@ -602,7 +399,6 @@ impl Tty7App { }) }, ) - // A quiet "refreshing" hint while a re-probe flies over stale data. .when( overlay.loading && matches!(overlay.load, DiffLoad::Ready(_)), |bar| { @@ -618,9 +414,6 @@ impl Tty7App { .child( div().occlude().flex_shrink_0().child( crate::ui::tab_strip::chrome_tile_sized( - // Explicit tile, not `.small()`: this bar stands in for the - // title bar while the overlay is up, so its close control is - // the same tile the title bar's controls are. Button::new("diff-overlay-close").icon(Icon::new(IconName::Close)), crate::ui::app::TILE_SIZE, crate::ui::app::TILE_GLYPH_LINE, @@ -636,7 +429,6 @@ impl Tty7App { ) } - /// A centered single-line state (loading / clean / not-a-repo). fn diff_message(&self, text: &'static str, cx: &Context<Self>) -> AnyElement { div() .flex_1() @@ -649,7 +441,6 @@ impl Tty7App { .into_any_element() } - /// The scrolling column of per-file diff cards plus the untracked section. fn diff_file_list( &self, snap: &DiffSnapshot, @@ -657,21 +448,12 @@ impl Tty7App { focused: Option<usize>, cx: &mut Context<Self>, ) -> AnyElement { - // One walk for every whole-snapshot number this render needs, rather - // than one walk per question over a file list whose length is the size - // of the working tree — see `DiffSnapshot::stats`. let stats = snap.stats(); - // An oversized tree opens fully collapsed: the file rows are still the - // useful part, and the bodies are what cost. A file the user opened by - // name is exempt — that's an explicit request for one body, not for the - // whole tree. See `DiffSnapshot::oversized`. let oversized = focused.is_none() && stats.oversized; let mut list = v_flex().gap_3().p_4().w_full(); if oversized { list = list.child(self.diff_oversized_notice(snap, &stats, cx)); } - // Hard ceiling on cards built at all: even collapsed, one card per file - // is one card per file, and the list is not virtualized. let shown = snap.files.len().min(MAX_RENDERED_FILES); for (idx, file) in snap.files.iter().enumerate() { if focused.is_some_and(|f| f != idx) { @@ -680,9 +462,6 @@ impl Tty7App { if focused.is_none() && idx >= shown { break; } - // A file opened by name was asked for explicitly — show its body - // even when it's over the auto-collapse threshold. The header still - // toggles, so a huge file can be folded back down. let is_expanded = if focused == Some(idx) { expanded.get(&file.path).copied().unwrap_or(true) } else { @@ -705,7 +484,6 @@ impl Tty7App { )), ); } - // Untracked files are a property of the tree, not of the focused file. if focused.is_none() && !snap.untracked.is_empty() { list = list.child(self.diff_untracked_section(snap, cx)); } @@ -718,11 +496,6 @@ impl Tty7App { .into_any_element() } - /// The banner an oversized diff leads with: says why every file is folded - /// shut and points at the two ways out (expand one file, or use the - /// terminal). Deliberately *above* the list rather than instead of it — the - /// file rows with their `+N −N` are the part that still reads fine at this - /// size. fn diff_oversized_notice( &self, snap: &DiffSnapshot, @@ -748,7 +521,6 @@ impl Tty7App { .into_any_element() } - /// One file's card: a clickable header row and, when expanded, the hunks. fn diff_file_card( &self, idx: usize, @@ -756,10 +528,6 @@ impl Tty7App { expanded: bool, cx: &mut Context<Self>, ) -> AnyElement { - // Binary files and pure renames have no hunk body to reveal; their - // header is inert (no chevron, no click). A file the repo-wide budget - // emptied *is* expandable even with no hunks — what it reveals is the - // note explaining why, which is otherwise unreachable. let expandable = !file.binary && (!file.hunks.is_empty() || file.truncated == Some(Truncation::Budget)); let (glyph, glyph_color) = match file.status { @@ -768,23 +536,13 @@ impl Tty7App { FileStatus::Deleted => ("D", cx.theme().danger), FileStatus::Renamed => ("R", cx.theme().muted_foreground), }; - // `old → new` for renames, the plain path otherwise. let shown_path = match &file.old_path { Some(old) => format!("{old} → {}", file.path), None => file.path.clone(), }; - // Whether the body paints anything at all. `expanded` alone is not that - // question: a binary file or a pure rename has no hunks and is not - // truncated, so its body is empty and the header *is* the card. A - // truncated file with no parsable hunks still renders the notice. let has_body = expanded && (!file.hunks.is_empty() || file.truncated.is_some()); - // The header paints a solid band flush into the card's corners, and the - // card's `overflow_hidden` cannot round it — that clip is a square, - // unantialiased scissor (issue #236, see `ui::rounding`). So the band - // carries the radius: top two when a body follows it, all four when the - // header is the card's only band. let header_corners = rounding::stack_corners( 0, if has_body { 2 } else { 1 }, @@ -811,11 +569,6 @@ impl Tty7App { .get_mut(active) .and_then(|t| t.diff_overlay.as_mut()) { - // Record what the user now wants, not "differs from - // the default": `expanded` is the state this card is - // currently drawn in, so the click means the - // opposite of it, and that answer keeps holding even - // if the default later moves under it. overlay.expanded.insert(path.clone(), !expanded); cx.notify(); } @@ -881,16 +634,11 @@ impl Tty7App { .border_1() .border_color(cx.theme().border) .rounded(rounding::CARD_RADIUS) - // Overflow backstop only; the bands inside round themselves. .overflow_hidden() .child(header); if has_body { let mut body = v_flex().w_full(); - // Split every hunk up front so the *last* row is knowable: a diff - // cell paints a tint, and the card's clip is square, so the row that - // ends the card has to draw the bottom corners itself. A truncation - // notice (no fill of its own) takes that job away again. let hunks: Vec<_> = file .hunks .iter() @@ -927,9 +675,6 @@ impl Tty7App { "Diff truncated at {} lines — run `git diff` in the terminal for the rest.", git_diff::MAX_LINES_PER_FILE ), - // Naming the repo-wide budget matters: this file may be - // three lines long, and "truncated" without a why reads as - // tty7 having lost the change. Truncation::Budget => { "Body not loaded — this working tree is past tty7's diff budget. \ Run `git diff` in the terminal for this file." @@ -951,13 +696,6 @@ impl Tty7App { card.into_any_element() } - /// One side-by-side row: the old (left) and new (right) cells with a hairline - /// splitter between them. A `None` cell — no counterpart on that side — - /// paints a muted placeholder so a pure add/remove reads as one column empty. - /// - /// `closes_card` marks the row that sits on the card's bottom edge; its two - /// outer cells then round their outer bottom corner, since the card's clip - /// cannot do it for them (see `ui::rounding`). fn diff_split_row(&self, row: &SplitRow, closes_card: bool, cx: &Context<Self>) -> AnyElement { let radius = if closes_card { rounding::inner_radius(rounding::CARD_RADIUS, rounding::HAIRLINE) @@ -966,7 +704,6 @@ impl Tty7App { }; h_flex() .w_full() - // Fixed row height so blank diff lines don't collapse. .h(px(19.)) .items_stretch() .text_xs() @@ -977,12 +714,6 @@ impl Tty7App { .into_any_element() } - /// One half of a split row: a right-aligned line-number gutter, then the - /// marker and text in the terminal font, tinted green/red when changed. - /// - /// `outer_radius` rounds the cell's own outer bottom corner — non-zero only - /// on the row that closes the card, whose tint would otherwise square that - /// corner off (see `ui::rounding`). fn diff_split_cell( &self, cell: Option<&SplitCell>, @@ -1023,23 +754,9 @@ impl Tty7App { .into_any_element() } - /// The trailing "Untracked files" section: names only — `git diff HEAD` - /// has no blob to diff a never-added file against, but hiding them would - /// read as lost work (agents create files constantly). - /// - /// Bounded exactly like the file-card list above it, and for the same - /// reason: this is one non-virtualized row per path, and `ls-files - /// --others` on a tree whose dependency directory isn't ignored yet answers - /// with tens of thousands of them. The header count is the true total, so - /// capping the rows never makes files look gone. fn diff_untracked_section(&self, snap: &DiffSnapshot, cx: &Context<Self>) -> AnyElement { let total = snap.untracked_count(); let untracked = &snap.untracked[..snap.untracked.len().min(MAX_RENDERED_FILES)]; - // Same filled-band-in-a-rounded-card shape as `diff_file_card`, so the - // header owns the corners it sits in. The rows below it paint no fill, - // which is why only the top pair is ever non-zero here. Counted off the - // *total*, not the capped slice: a section with a "… and N more" tail - // still has rows under its header. let header_corners = rounding::stack_corners( 0, if total == 0 { 1 } else { 2 }, @@ -1101,17 +818,11 @@ impl Tty7App { } } -/// Resolve the overlay's focused path to an index into `snap.files`. `None` -/// means "show everything" — either nothing is focused, or the focused path is -/// no longer in the diff (the user reverted it while the overlay was open), in -/// which case falling back to the full list beats an empty screen. fn focused_file(snap: &DiffSnapshot, overlay: &DiffOverlayState) -> Option<usize> { let path = overlay.focus.as_deref()?; snap.files.iter().position(|f| f.path == path) } -/// The focused file's name for the header, only once it's known to be in the -/// snapshot — so a stale focus doesn't label a list that shows every file. fn focused_name(overlay: &DiffOverlayState) -> Option<String> { let DiffLoad::Ready(snap) = &overlay.load else { return None; @@ -1120,27 +831,10 @@ fn focused_name(overlay: &DiffOverlayState) -> Option<String> { Some(snap.files[idx].path.clone()) } -/// Nothing to show: no changed file and no untracked path. Says nothing about -/// *why* — [`DiffSnapshot::read_failed`] is what tells a clean tree apart from -/// a read that never landed. fn empty_snapshot(snap: &DiffSnapshot) -> bool { snap.files.is_empty() && snap.untracked.is_empty() } -/// Whether a file's body shows. -/// -/// An explicit choice in `expanded` is final: it is answered before the default -/// is even computed, so nothing about the snapshot can change it. That ordering -/// is the whole point — `collapse_all` is a repo-wide default that moves as the -/// working tree grows and shrinks, and a user who opened one file inside an -/// oversized diff must not have it snap shut the moment an agent reverts enough -/// lines to drop the tree back under the threshold. -/// -/// Files the user never touched follow the default: small text diffs open, big -/// ones closed, and past -/// [`AUTO_COLLAPSE_TOTAL_LINES`](git_diff::AUTO_COLLAPSE_TOTAL_LINES) nothing -/// opens at all, because the per-file threshold can't see that forty innocent -/// files are about to expand at once. fn file_expanded(file: &FileDiff, expanded: &HashMap<String, bool>, collapse_all: bool) -> bool { if let Some(&want) = expanded.get(&file.path) { return want; @@ -1148,27 +842,6 @@ fn file_expanded(file: &FileDiff, expanded: &HashMap<String, bool>, collapse_all !collapse_all && file.added + file.removed <= AUTO_COLLAPSE_LINES } -/// The parenthetical inside the oversized banner: one clause per axis that -/// contributes, so the banner never claims the *diff* is big when what is -/// actually big is an un-ignored untracked tree. -/// -/// Whether hunks were dropped is answered by the parser's own per-file -/// [`Truncation`] flags, never by comparing the retained count against -/// `added + removed`. Those two numbers are scoped differently — retained -/// counts the context lines that get rendered too, `added + removed` doesn't — -/// so on a diff of many small hunks the retained figure is the *larger* of the -/// two and a `loaded < total` test reads a truncated tree as complete, while -/// the file cards below it say "body not loaded". The comparison has produced a -/// wrong answer on each axis it was ever asked about; it decides nothing here. -/// -/// Both axes are named, and they compose: one file can hit the per-file cap in -/// the same snapshot where the repo-wide budget emptied another, and the banner -/// has to account for a body the reader can see is missing either way. -/// -/// The changed-line figure stays [`totals`](DiffSnapshot::totals) exactly, so -/// the banner agrees with the `+N −N` in the header directly above it; the -/// retained figure is named as rendered rows rather than joined to it by "of", -/// because it is not a fraction of it. fn oversized_summary(snap: &DiffSnapshot, stats: &DiffStats) -> String { let mut parts = vec![format!( "{} changed file{}", @@ -1199,35 +872,24 @@ fn oversized_summary(snap: &DiffSnapshot, stats: &DiffStats) -> String { parts.join(", ") } -/// Which column a split cell belongs to — picks the marker and tint. #[derive(Clone, Copy)] enum Side { Old, New, } -/// One half of a side-by-side row. `changed` distinguishes an added/removed -/// line (tinted) from a context line (plain, shown identically on both sides). struct SplitCell { no: Option<u32>, text: String, changed: bool, } -/// A side-by-side row: old on the left, new on the right. Either side is `None` -/// when a change block is longer on the other side (pure add/remove, or an -/// uneven replacement). struct SplitRow { left: Option<SplitCell>, right: Option<SplitCell>, } -/// Pair a hunk's unified lines into side-by-side rows: removed lines fill the -/// left column, added lines the right, and a context line flushes any pending -/// change block before landing on both sides. Within a block the two columns -/// align positionally (i-th removed ↔ i-th added), leftovers pair with `None`. fn split_hunk(lines: &[git_diff::DiffLine]) -> Vec<SplitRow> { - // Tabs don't expand in UI text layout; four spaces keeps indentation readable. fn clean(text: &str) -> String { text.replace('\t', " ") } @@ -1296,9 +958,6 @@ mod tests { } } - /// An uneven replacement (2 removed ↔ 1 added) between two context lines: - /// the pair aligns positionally, the extra removed line pairs with an empty - /// right column, and context lines land identically on both sides. #[test] fn pairs_removed_and_added_side_by_side() { let lines = vec![ @@ -1311,28 +970,23 @@ mod tests { let rows = split_hunk(&lines); assert_eq!(rows.len(), 4); - // Leading context: same text both sides, not tinted. let l = rows[0].left.as_ref().unwrap(); let r = rows[0].right.as_ref().unwrap(); assert_eq!((l.no, l.text.as_str(), l.changed), (Some(1), "a", false)); assert_eq!((r.no, r.text.as_str(), r.changed), (Some(1), "a", false)); - // First changed row: removed[0] ↔ added[0], both tinted. let l = rows[1].left.as_ref().unwrap(); let r = rows[1].right.as_ref().unwrap(); assert_eq!((l.no, l.text.as_str(), l.changed), (Some(2), "b", true)); assert_eq!((r.no, r.text.as_str(), r.changed), (Some(2), "B", true)); - // Leftover removed line pairs with an empty right column. assert_eq!(rows[2].left.as_ref().unwrap().text, "c"); assert!(rows[2].right.is_none()); - // Trailing context resumes both columns. assert_eq!(rows[3].left.as_ref().unwrap().no, Some(4)); assert_eq!(rows[3].right.as_ref().unwrap().no, Some(3)); } - /// Tabs render as four spaces so indentation survives UI text layout. #[test] fn expands_tabs_in_cell_text() { let lines = vec![line(LineKind::Added, None, Some(1), "\tindented")]; @@ -1341,8 +995,6 @@ mod tests { assert!(rows[0].left.is_none()); } - /// A file of `added` changed lines, small enough to open by default on its - /// own. fn small_file(path: &str, added: u32) -> FileDiff { FileDiff { path: path.to_string(), @@ -1361,11 +1013,6 @@ mod tests { } } - /// A file whose body is mostly context: one changed line under six context - /// lines, so `retained_lines` counts seven where `+N −N` counts one. A tree - /// of these is the shape where the retained figure *exceeds* the changed - /// one, which is where a `loaded < total` comparison reads truncation - /// backwards. fn context_heavy_file(path: &str) -> FileDiff { FileDiff { path: path.to_string(), @@ -1385,21 +1032,14 @@ mod tests { } } - /// An explicit choice map, for the tests below. fn choices<const N: usize>(pairs: [(&str, bool); N]) -> HashMap<String, bool> { pairs.into_iter().map(|(p, v)| (p.to_string(), v)).collect() } - /// The banner text for a snapshot, deriving its stats the way the render - /// path does — so these tests exercise the same numbers the overlay shows - /// rather than a hand-assembled set. fn banner(snap: &DiffSnapshot) -> String { oversized_summary(snap, &snap.stats()) } - /// The per-file threshold on its own: a small file opens, a big one doesn't, - /// and an explicit choice overrides either. Unchanged behaviour — this is - /// the small-working-tree case that must feel identical. #[test] fn per_file_collapse_is_unchanged_below_the_repo_threshold() { let small = small_file("small.rs", 10); @@ -1413,10 +1053,6 @@ mod tests { assert!(file_expanded(&big, &picked, false)); } - /// The repo-wide override: past the total threshold nothing opens by - /// default, however small each file is — the case a per-file rule can't see - /// (issue #239, finding 4). An explicit choice still wins, which is what - /// "expand individual files" in the oversized notice means. #[test] fn repo_wide_collapse_overrides_the_per_file_default() { let small = small_file("small.rs", 10); @@ -1430,16 +1066,11 @@ mod tests { ); } - /// An explicit choice must survive the default moving under it — the defect - /// the inversion-set representation had. A refresh that crosses the - /// oversized threshold in either direction leaves every file the user - /// touched exactly as they left it, and moves only the ones they didn't. #[test] fn explicit_choices_survive_an_oversized_transition() { let opened = small_file("opened.rs", 10); let closed = small_file("closed.rs", 10); let untouched = small_file("untouched.rs", 10); - // The user opened one file and shut another while the tree was oversized. let picked = choices([("opened.rs", true), ("closed.rs", false)]); for collapse_all in [true, false] { @@ -1452,15 +1083,10 @@ mod tests { "an explicitly closed file stays closed (collapse_all={collapse_all})" ); } - // Only the file the user never touched follows the default. assert!(!file_expanded(&untouched, &picked, true)); assert!(file_expanded(&untouched, &picked, false)); } - /// Sixty files of a hundred and fifty lines each never trip the per-file - /// threshold (each is well under it), yet would open 9000 diff rows at once. - /// `oversized` catches it on the line axis, and with everything collapsed - /// the overlay builds zero rows. #[test] fn many_medium_files_are_oversized_and_build_no_rows() { let snap = DiffSnapshot { @@ -1494,23 +1120,8 @@ mod tests { assert_eq!(rows_collapsed, 0); } - /// The other side of the same coin: a busy-but-ordinary afternoon is *not* - /// oversized and opens expanded exactly as it does today. The thresholds - /// must not tax a normal working tree. - /// - /// Built out of context-heavy files rather than bare changed lines, because - /// that is what a real diff looks like and it is the difference the line - /// threshold is most easily mis-set against: git prints three lines of - /// context each side of every hunk, so `retained_lines` runs several times - /// the `+N −N` a person reads off the header. A tree of forty files with a - /// handful of small hunks each is an afternoon's work, and it must open. #[test] fn an_ordinary_busy_tree_is_not_oversized() { - // Forty files × ten hunks × (6 context + 1 changed) — 2800 retained - // lines behind a header reading `+400 −0`, which is a morning, not a - // refactor. Sized to sit above the threshold this used to carry and - // below the one it carries now: an assertion that passes either way - // would not be watching anything. let snap = DiffSnapshot { files: (0..40) .map(|i| { @@ -1537,13 +1148,6 @@ mod tests { assert!(snap.files.iter().all(|f| file_expanded(f, &none, false))); } - /// An empty snapshot means one of two opposite things, and the overlay has - /// to tell them apart before it says either out loud. - /// - /// "Working tree clean" is a claim about the repository. A probe that could - /// not run — a refused stream, a read that went silent, a git racing a - /// concurrent write — produces exactly the same empty file list, and saying - /// it there tells someone their changes are gone. #[test] fn an_empty_snapshot_reads_as_clean_only_when_the_probe_worked() { let clean = DiffSnapshot { @@ -1564,8 +1168,6 @@ mod tests { ); assert!(broken.read_failed, "and distinguishable by this"); - // A read that failed *after* producing something is not the empty case - // at all: the file list renders, and no claim about emptiness is made. let partial = DiffSnapshot { files: vec![small_file("one.rs", 3)], read_failed: true, @@ -1574,16 +1176,6 @@ mod tests { assert!(!empty_snapshot(&partial)); } - /// A huge untracked list does *not* collapse the diff — and must not. - /// - /// It is the same one-row-per-entry cost, but `oversized` is not the lever - /// that answers it: folding every file body shut leaves the untracked - /// section rendering exactly as many rows as before, because that section - /// has no bodies to fold. Driving it from here meant a tree with an - /// un-ignored `node_modules` and three edited files hid the three cheap - /// things, kept the expensive one, and told the reader their working tree - /// was too large to render. What actually bounds it is the retention cap and - /// the row cap, asserted below. #[test] fn a_huge_untracked_list_does_not_collapse_the_diff() { let snap = DiffSnapshot { @@ -1601,7 +1193,6 @@ mod tests { "collapsing the diff would not have removed a single untracked row" ); - // The bound that does apply, on the rows that are actually expensive. assert_eq!( snap.untracked.len(), git_diff::MAX_UNTRACKED, @@ -1619,9 +1210,6 @@ mod tests { ); } - /// The untracked section builds at most [`MAX_RENDERED_FILES`] rows while - /// still reporting the true total, so a capped list never reads as files - /// having vanished. #[test] fn untracked_rows_are_capped_but_the_count_stays_true() { let snap = DiffSnapshot { @@ -1637,8 +1225,6 @@ mod tests { assert_eq!(snap.untracked_count() - rendered, 12_045); } - /// A snapshot built without the streaming probe (tests, `..Default`) must - /// not under-report: the count falls back to the retained length. #[test] fn untracked_count_falls_back_to_the_retained_length() { let snap = DiffSnapshot { @@ -1649,8 +1235,6 @@ mod tests { assert_eq!(snap.untracked_count(), 3); } - /// However many files change, the overlay builds at most - /// [`MAX_RENDERED_FILES`] cards and says so. #[test] fn file_cards_are_capped() { let snap = DiffSnapshot { @@ -1668,20 +1252,12 @@ mod tests { ); } - /// The shape the old `loaded < total_lines` test could not see: many - /// one-line hunks, each carrying its context. Retained lines count context - /// and `+N −N` does not, so here the retained figure is the *larger* of the - /// two — a budget-truncated tree that the comparison read as complete, - /// leaving the banner silent while the file cards below it said "body not - /// loaded". The banner must name the budget whenever any file carries - /// [`Truncation::Budget`], whatever the diff's shape. #[test] fn the_banner_names_the_budget_when_context_outweighs_the_changes() { let files: Vec<FileDiff> = (0..200) .map(|i| context_heavy_file(&format!("f{i}.rs"))) .collect(); - // Same tree, one file whose body the repo-wide budget dropped. let mut truncated = files.clone(); truncated.push(FileDiff { hunks: vec![], @@ -1707,7 +1283,6 @@ mod tests { "the exact `+N −N` from the header, not the retained count: {summary}" ); - // The same shape with nothing dropped must not claim a truncation. let whole = DiffSnapshot { files, ..Default::default() @@ -1716,13 +1291,6 @@ mod tests { assert!(!banner(&whole).contains("budget")); } - /// The sibling axis, blind in exactly the same place: one file cut at - /// [`MAX_LINES_PER_FILE`](git_diff::MAX_LINES_PER_FILE) inside a - /// context-heavy tree. `loaded < total_lines` is false here — the context - /// lines of every other file more than cover the cut one — so the branch - /// that used to carry this clause stayed silent while that file's own card - /// read "Diff truncated at 2000 lines". Both axes now come from the - /// parser's flags, and a snapshot carrying both must name both. #[test] fn the_banner_names_the_per_file_cap_when_context_outweighs_the_changes() { let files: Vec<FileDiff> = (0..200) @@ -1756,14 +1324,12 @@ mod tests { "the exact `+N −N` from the header, not the retained count: {summary}" ); - // Nothing cut, nothing claimed. let whole = DiffSnapshot { files: files.clone(), ..Default::default() }; assert!(!banner(&whole).contains("per-file")); - // Both kinds in one snapshot: neither clause may mask the other. let mut both = files; both.push(FileDiff { truncated: Some(Truncation::PerFile), @@ -1782,16 +1348,11 @@ mod tests { assert!(summary.contains("per-file cap"), "{summary}"); } - /// Measurement harness for issue #239, finding 2 — run with - /// `cargo test -- --ignored --nocapture bench_snapshot_share`. #[test] #[ignore = "measurement, not an assertion"] fn bench_snapshot_share() { use std::time::Instant; - // Two sizes: what v26.7.5 would have held for a big agent session - // (300 files × 300 lines, no repo-wide budget), and what this build - // retains for the same tree once the budget applies. for (label, files, per_file) in [ ("unbudgeted (v26.7.5 shape)", 300, 300), ("budgeted (this build retains)", 300, 67), diff --git a/src/ui/file_tree.rs b/src/ui/file_tree.rs index 18a91978..bb8a70e4 100644 --- a/src/ui/file_tree.rs +++ b/src/ui/file_tree.rs @@ -1,37 +1,3 @@ -//! Local project file tree (the right panel's Files tab — see -//! `right_panel::render_panel_files` for the column that hosts it). -//! -//! Modelled on Warp's Project Explorer: lazily-loaded directories, gitignore -//! awareness (ignored entries render dimmed, not hidden), a filesystem watcher -//! that keeps listings fresh, keyboard navigation, inline new-file / rename -//! editing, and a per-row context menu (open / cd / reveal / copy path / -//! delete / attach to a coding agent). Roots come from the active tab's panes: -//! each pane's cwd resolves to its repository root (walk up to `.git`), so a -//! tab whose panes sit in two repos shows both as top-level roots. -//! -//! The panel is a lazy tree over a [`Host`] — the abstraction that makes the -//! same tree work against this machine or a remote one. Listings are cached per -//! `(host, directory)` and invalidated by watcher events, so a huge repo only -//! ever pays for the directories actually expanded. -//! -//! The watch is scoped the same way: **non-recursive**, over the roots plus the -//! expanded directories and nothing else — see -//! [`FileTreeState::sync_watch`]. So `target/`, `node_modules` and the inside of -//! `.git` produce no events at all unless the user has expanded them, and what -//! does arrive names a directory the tree is displaying. Anything reasoning -//! about "what the watcher reports" starts there, not from a recursive walk of -//! the root. -//! -//! **Nothing here touches the filesystem directly.** Every read, every write and -//! every `git` invocation goes through [`HostOps`], which runs the blocking -//! `Host` call on the background executor and lands the answer on the UI thread. -//! Render only ever reads the cache, and a miss turns into a queued load whose -//! answer arrives with a `cx.notify()` a frame or more later. A directory the -//! user just expanded is therefore empty for one paint, which is what the cache -//! miss costs and what every other editor does — far better than stalling the -//! frame on a cold `.gitignore` chain, on a 2000-directory walk, or (remotely) -//! on a network round trip. - use std::collections::HashSet; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -49,56 +15,29 @@ use gpui_component::input::{Input, InputEvent, InputState}; use gpui_component::menu::{ContextMenuExt as _, PopupMenu, PopupMenuItem}; use gpui_component::{ActiveTheme as _, Icon, IconName, Sizable as _, h_flex, v_flex}; -/// Per-level indent (px) for nested rows. const INDENT: f32 = 14.0; -/// Debounce for watcher-driven refreshes (same rationale as the config -/// hot-reload: coalesce a save burst into one reload). const REFRESH_DEBOUNCE: std::time::Duration = std::time::Duration::from_millis(200); -/// Debounce for the Files-tab search box. Each query walks up to -/// [`SEARCH_MAX_DIRS`] directories, so only the pause after the last keystroke -/// should pay for a walk — typing "src" otherwise buys three of them. const SEARCH_DEBOUNCE: std::time::Duration = std::time::Duration::from_millis(200); -/// Hits a search stops at, so a query like "e" can't walk a whole monorepo. const SEARCH_LIMIT: usize = 200; -/// Directories a search visits even if nothing matches, so a typo can't turn -/// into a full-disk crawl. Also what bounds a walk through a symlink cycle: -/// `Host::read_dir` follows links, so `a/link -> a` yields an unbounded chain of -/// distinct paths and this budget — not cycle detection — is what ends it. const SEARCH_MAX_DIRS: usize = 2000; -/// One directory entry in a cached listing. -/// -/// `PartialEq` so a landed relist can tell "this directory changed" from "a file -/// in it was rewritten": the watcher reports both, and only the first is worth a -/// repaint (issue #243). #[derive(Clone, PartialEq)] pub(crate) struct TreeEntry { pub name: String, pub path: PathBuf, pub is_dir: bool, - /// Matched by the gitignore chain (or is `.git` itself): rendered dimmed. pub ignored: bool, } -/// What a landed listing means for the caller: whether to go round again, and -/// whether it is worth a repaint. -/// -/// Two separate questions. A listing superseded in flight has to be re-read -/// whatever it contained, and one that came back identical to what is on screen -/// is not worth a frame however current it is. struct Landed { - /// The listing was superseded while it flew, so ask again. superseded: bool, - /// It differs from what the tree was already showing. changed: bool, } -/// A flattened visible row: what the list renders and what keyboard -/// navigation walks. Roots are rows too (depth 0, always expanded-looking). pub(crate) struct TreeRow { pub entry: TreeEntry, pub depth: usize, @@ -106,7 +45,6 @@ pub(crate) struct TreeRow { pub expanded: bool, } -/// One in-progress inline edit (new file / new folder / rename). pub(crate) enum TreeEdit { NewFile { dir: PathBuf, @@ -131,7 +69,6 @@ impl TreeEdit { } } - /// The directory whose listing hosts the edit row. fn host_dir(&self) -> &Path { match self { TreeEdit::NewFile { dir, .. } | TreeEdit::NewFolder { dir, .. } => dir, @@ -140,35 +77,17 @@ impl TreeEdit { } } -/// A directory key: which machine, and which path on it. -/// -/// The pairing is the point. `/home/me/proj` exists on the laptop *and* on the -/// remote box, and a cache keyed by path alone would happily serve one -/// machine's listing for the other's directory. type DirKey = (HostId, PathBuf); -/// The Files-tab search box's off-thread state: the query the newest walk -/// covers, the generation that identifies it, and the hits last accepted. #[derive(Default)] struct SearchState { - /// Bumped per walk so a slow one can't overwrite a newer one's answer — - /// same guard as `right_panel`'s process poll. generation: u64, - /// The query the in-flight (or last completed) walk covers. Render compares - /// against the live input, so a repaint mid-walk doesn't queue a second one. pending: String, - /// The dotfile setting that walk ran under. The walk bakes it in — hidden - /// and ignored entries never enter the hits — so flipping the eye toggle - /// has to re-walk, even though the query never moved. hidden: bool, hits: Vec<TreeEntry>, } impl SearchState { - /// Point the search at `query`, returning the generation a fresh walk - /// should carry — `None` when the current one already covers it. An empty - /// query drops the hits so the next one can't flash the previous one's - /// results before its own land. fn retarget(&mut self, query: &str, show_hidden: bool) -> Option<u64> { if self.pending == query && self.hidden == show_hidden { return None; @@ -183,7 +102,6 @@ impl SearchState { Some(self.generation) } - /// Take a landed walk's hits unless a newer query superseded it. fn accept(&mut self, generation: u64, hits: Vec<TreeEntry>) -> bool { if self.generation != generation { return false; @@ -192,75 +110,27 @@ impl SearchState { true } - /// Forget both the in-flight walk and what it covered, so the next render - /// starts a new one for the same query. For when the ground moved under it - /// (the ignore rules changed, or the tree got new roots) rather than the - /// query changing. fn restart(&mut self) { self.generation += 1; self.pending.clear(); } } -/// App-global file-tree infrastructure, held on [`Tty7App`]. The per-tab view -/// state (roots, expansion, selection) lives in -/// [`TabCode`](crate::ui::code_editor::TabCode); everything here is path-keyed -/// cache or chrome shared by every tab's panel — one panel shows at a time. pub(crate) struct FileTreeState { - /// Lazily-loaded listing per directory; invalidated by watcher events. The - /// only thing render reads — a miss is a queued load, never a host call. children: ByHost<PathBuf, Vec<TreeEntry>>, loads: InFlight<DirKey>, - /// Cached listings a watcher event has outdated: still on screen, queued to - /// be relisted, replaced only when the new listing lands. - /// - /// Dropping them at invalidation time instead is what a filesystem change - /// used to do, and it is invisible locally — the relist is microseconds. On - /// a remote host it is a round trip during which the directory has no - /// listing at all, so every row under it leaves the screen and comes back: - /// one file rewritten a few times a second makes the whole tree strobe. stale: HashSet<DirKey>, - /// Each pane cwd resolved to its repository root, so deriving the root set - /// is a cache read rather than a walk up the tree per frame. repo_roots: ByHost<PathBuf, PathBuf>, repo_root_loads: InFlight<DirKey>, search: SearchState, pub(crate) show_hidden: bool, pub(crate) editing: Option<TreeEdit>, editing_subs: Vec<Subscription>, - /// The live watch over the union of every tab's roots and expanded - /// directories. - /// - /// One long-lived subscription whose *set* moves, rather than a watcher - /// rebuilt per change: on a remote host a rebuild is a round trip plus a - /// server-side watcher torn down and recreated for every disclosure - /// triangle. `Arc` because `set_dirs` is itself a host call and has to be - /// handed to the background executor. watch: Option<Arc<WatchSub>>, - /// The host `watch` was opened against, kept so a subscription is never - /// reused across a different one. - /// - /// A `HostId` is not enough to tell them apart: reconnecting removes the - /// dead `RemoteHost` and inserts a fresh one under the *same* id, so the id - /// matches while the `ControlClient` behind the old subscription is gone. - /// Compared by pointer, which distinguishes both that and an outright - /// switch to another machine. watch_host: Option<SharedHost>, - /// A subscription is being opened. Without this, render would ask for one - /// per frame until the first answer lands. watch_opening: bool, - /// A `set_dirs` is in flight, and whether the set moved again while it was. - /// - /// `set_dirs` replaces the watched set wholesale, so two in flight resolve - /// by arrival order rather than issue order — and the loser strands the - /// watch on a stale set *permanently*, since the caller only re-issues when - /// the desired set changes. Expanding two directories a frame apart is - /// enough to hit it locally; over an RPC, out-of-order is the normal case. watch_busy: bool, watch_dirty: bool, - /// What the watch currently spans. The union can move without the active - /// tab's roots moving — closing a tab drops roots from it — so the sync - /// check compares this rather than trusting the per-tab comparison. watched: HashSet<PathBuf>, events_tx: smol::channel::Sender<(HostId, Vec<PathBuf>)>, pub(crate) focus_handle: FocusHandle, @@ -273,8 +143,6 @@ impl FileTreeState { while let Ok((host, first)) = rx.recv().await { cx.background_executor().timer(REFRESH_DEBOUNCE).await; let mut changed: HashSet<PathBuf> = first.into_iter().collect(); - // Only coalesce batches from the same host: a path means nothing - // without the machine it is on. while let Ok((h, more)) = rx.try_recv() { if h == host { changed.extend(more); @@ -310,29 +178,14 @@ impl FileTreeState { } } - /// Point the watch at `dirs` — roots plus every expanded directory, since - /// the subscription is non-recursive and only the directories on screen can - /// produce a visible change. - /// - /// Opens the subscription on first use and moves its set thereafter. Both - /// are blocking host calls, so both go through [`HostOps`]. fn sync_watch(&mut self, host: SharedHost, dirs: HashSet<PathBuf>, cx: &mut Context<Tty7App>) { self.watched = dirs; let want: Vec<PathBuf> = self.watched.iter().cloned().collect(); - // A subscription belongs to the host that opened it. Reconnecting drops - // the dead `RemoteHost` and inserts a fresh one under the same - // `HostId`, and adopting another workspace can change the host outright - // — in both cases the subscription here is over a `ControlClient` that - // is gone. `set_dirs` on it then fails with `ConnectionReset`, which is - // warned and dropped, and nothing ever opens a new one: after the first - // reconnect of a remote workspace the tree stops seeing changes made on - // the far side for the rest of the window's life. if !self .watch_host .as_ref() .is_some_and(|opened_with| Arc::ptr_eq(opened_with, &host)) { - // Dropping the subscription is what unsubscribes, on both sides. self.watch = None; self.watch_host = None; self.watch_busy = false; @@ -365,8 +218,6 @@ impl FileTreeState { return; } if self.watch_opening { - // The landing re-reads `watched`, so a set that moves while the - // subscription is being opened is applied when it arrives. return; } self.watch_opening = true; @@ -389,8 +240,6 @@ impl FileTreeState { return; } }; - // The receiver is cloned out so the pump owns a handle - // independent of the subscription the state holds. let events = sub.events().clone(); app.file_tree.watch = Some(sub); app.file_tree.watch_host = Some(opened_host); @@ -405,7 +254,6 @@ impl FileTreeState { } }) .detach(); - // The set may have moved while the subscription was opening. if app.file_tree.watched != opened_with { let want = app.file_tree.watched.clone(); let Some(host) = app.active_host(cx) else { @@ -417,15 +265,6 @@ impl FileTreeState { ); } - /// Ask for a listing of every root and expanded directory that isn't cached - /// yet. Called from render — so it must stay map lookups — and from the - /// watcher callback, which re-reads what it just marked instead of asking - /// for a paint to do it. Either way the `read_dir` runs on the background - /// executor and the answer arrives with a `cx.notify()`, which is why a - /// just-expanded directory fills in on the next frame rather than this one. - /// - /// Both callers first check that the listings are actually being drawn — - /// see [`file_tree_listings_on_screen`](Tty7App::file_tree_listings_on_screen). fn request_loads( &mut self, host: &SharedHost, @@ -433,7 +272,6 @@ impl FileTreeState { expanded: &HashSet<PathBuf>, cx: &mut Context<Tty7App>, ) { - // Roots always list; expanded dirs list on demand. for root in roots { self.request_load(host, root.clone(), root.clone(), cx); for dir in expanded { @@ -444,12 +282,6 @@ impl FileTreeState { } } - /// Spawn one directory listing, unless it's already cached (and current) or - /// already out. - /// - /// A cached-but-stale directory does re-ask: its rows stay on screen from - /// the old listing while the new one flies, which is the whole point of - /// keeping it. fn request_load( &mut self, host: &SharedHost, @@ -466,8 +298,6 @@ impl FileTreeState { self.spawn_load(host, dir, root, cx); } - /// The listing itself, with the "should we?" already decided — so the - /// landing can go round again without re-testing a cache it just filled. fn spawn_load( &mut self, host: &SharedHost, @@ -484,16 +314,10 @@ impl FileTreeState { let dir = dir.clone(); let root = root.clone(); move |h| { - // An unreadable directory lists as empty, exactly as it - // always has: the row stays, shows nothing under it, and - // does not re-ask every frame. let entries = h.read_dir(&dir, Some(&root)).unwrap_or_default(); entries .into_iter() .map(|e| TreeEntry { - // `Host::join`, not `PathBuf::join`: a Windows - // client joining a remote POSIX path would - // otherwise produce `/home/me\src`. path: h.join(&dir, &e.name), name: e.name, is_dir: e.is_dir, @@ -504,17 +328,12 @@ impl FileTreeState { }, move |app, entries, cx| { let landed = app.file_tree.land_load(&key, id, dir.clone(), entries); - // Only a listing that came back *different* is worth a frame. if landed.changed { cx.notify(); } if !landed.superseded { return; } - // Superseded: the snapshot stays on screen, and we go round - // again so it converges. Only while the tree is still pointed - // at the same machine — a workspace that moved on has no use - // for this directory. let Some(host) = app.active_host(cx) else { return; }; @@ -527,19 +346,6 @@ impl FileTreeState { ); } - /// Retire a listing and put it in the cache. - /// - /// The listing lands **either way**. One that was superseded is still a - /// real snapshot of that directory, and one change out of date beats - /// nothing at all. - /// - /// Throwing it away instead starves any directory that changes faster than - /// the round trip: every answer arrives already stale, the cache never - /// fills, and the rows blink out on every paint. Locally that window is - /// microseconds and the case never arises. Over an SSH link it is the whole - /// round trip, so a single file rewritten a few times a second is enough — - /// a coding agent's `~/.claude.json` is exactly that, and it made a remote - /// tree rooted at `$HOME` flicker at the link's round-trip rate. fn land_load( &mut self, key: &DirKey, @@ -547,12 +353,6 @@ impl FileTreeState { dir: PathBuf, entries: Vec<TreeEntry>, ) -> Landed { - // Asked before the insert, because the insert is what destroys the - // answer. A relist that read back exactly what is already on screen has - // nothing to show, and the watcher reports a file's *contents* changing - // as readily as an entry appearing — so a build writing into a - // directory the tree is displaying would otherwise repaint the window - // once per `REFRESH_DEBOUNCE` to draw the same rows again (#243). let changed = self.children.get(id, &dir) != Some(&entries); let superseded = land_listing( &mut self.loads, @@ -569,9 +369,6 @@ impl FileTreeState { } } - /// Point the search at `query` (empty = not searching), starting a - /// debounced background walk when it isn't the one already in flight. - /// Called from render, so the steady state is one string comparison. fn sync_search(&mut self, query: &str, roots: &[PathBuf], cx: &mut Context<Tty7App>) { let Some(generation) = self.search.retarget(query, self.show_hidden) else { return; @@ -580,15 +377,10 @@ impl FileTreeState { let (query, roots) = (query.to_string(), roots.to_vec()); cx.spawn(async move |app, cx| { cx.background_executor().timer(SEARCH_DEBOUNCE).await; - // Another keystroke during the wait retargeted the search: bow out - // before touching the host at all, which is the point of the wait. let _ = app.update(cx, |app, cx| { if app.file_tree.search.generation != generation { return; } - // The whole walk runs host-side in one call. Listing per - // directory from here would be up to `SEARCH_MAX_DIRS` round - // trips, which across an ocean is several minutes. let Some(host) = app.active_host(cx) else { return; }; @@ -618,17 +410,12 @@ impl FileTreeState { .detach(); } - /// The hits of the last accepted walk, as flat rows. Until one lands this - /// is empty (or, mid-retype, the previous query's — better than blanking - /// the list for every keystroke). fn search_rows(&self) -> Vec<TreeRow> { self.search .hits .iter() .map(|e| TreeRow { entry: e.clone(), - // Flat: a match's own indentation would be meaningless without - // its ancestors on screen. depth: 0, is_root: false, expanded: false, @@ -636,8 +423,6 @@ impl FileTreeState { .collect() } - /// Flatten `roots` + `expanded` directories into display order (both come - /// from the active tab's panel state). pub(crate) fn visible_rows( &self, host: HostId, @@ -694,19 +479,6 @@ impl FileTreeState { } } - /// Mark the cached listing for `dir` for a refresh after a change under it. - /// Returns whether there was anything to mark. - /// - /// The listing stays until its replacement lands — see [`FileTreeState::stale`]. - /// - /// The return value is what keeps a batch that reached nothing from - /// repainting (issue #243). The subscription is non-recursive over the - /// roots plus the expanded directories (see - /// [`sync_watch`](FileTreeState::sync_watch)), so nearly everything that - /// arrives *does* name a directory the tree holds — but not all of it: a - /// watched directory whose listing never landed (a read that failed, a root - /// removed underneath) is neither cached nor pending, and relisting for it - /// buys a round trip and a frame for rows that are not on screen. fn invalidate_dir(&mut self, host: HostId, dir: &Path) -> bool { let key: DirKey = (host, dir.to_path_buf()); let cached = self.children.get(host, dir).is_some(); @@ -718,23 +490,6 @@ impl FileTreeState { cached || pending } - /// Whether a `.gitignore` in this batch can reach anything the tree holds. - /// - /// A `.gitignore` at `D/.gitignore` governs `D` and everything below it and - /// nowhere else, so it matters only when some directory the tree caches or - /// is loading sits under `D`. A correctness guard on the branch, not a - /// throughput one: [`invalidate_all`](FileTreeState::invalidate_all) marks - /// *every* cached listing and restarts the search, and taking that for a - /// file that cannot govern anything the tree holds is work with no possible - /// visible result. A non-recursive watch makes such a batch rare — the - /// `.gitignore` has to be a direct child of a watched directory to arrive at - /// all — but "rare" is not "never": a watched directory whose own listing - /// failed holds nothing, and neither does one still in flight when the - /// deeper cache is empty. - /// - /// The test is complete as well as safe: any directory whose matchers could - /// be cached is an ancestor of a directory that was successfully listed, and - /// that one is in `children`. fn gitignore_reaches_tree(&self, host: HostId, paths: &HashSet<PathBuf>) -> bool { paths .iter() @@ -751,18 +506,6 @@ impl FileTreeState { }) } - /// Mark every listing for a refresh — for the changes no smaller invalidation covers: a - /// `.gitignore` edit (its patterns reach any depth below it) or a new root - /// set. - /// - /// The compiled matchers themselves are no longer ours to clear: they live - /// in the host, which drops them from inside its own watcher when a - /// `.gitignore` moves. That is what gives a remote client the same - /// invalidation for free — the server's host is the one watching. - /// Deliberately leaves the resolved repository roots alone. Nothing this - /// covers can change where a repository starts, and clearing them here - /// would have the root derivation re-resolve every cwd immediately after - /// installing the roots that triggered it. fn invalidate_all(&mut self) { self.stale .extend(self.children.keys().map(|(host, dir)| (host, dir.clone()))); @@ -770,20 +513,6 @@ impl FileTreeState { self.search.restart(); } - /// Forget which repository each cwd belongs to — for a `.git` appearing or - /// disappearing, the only thing that can move a repository root. - /// - /// Reaches less far than it sounds: the roots a tab is *showing* live in - /// `TabCode::roots` and are re-derived only when they are empty, on a tab - /// switch, or on a panel toggle. So this makes the next such derivation - /// correct rather than relocating a root under a tab that is already open — - /// which is also what the synchronous version it replaced did. - /// - /// Returns whether anything was actually forgotten. Nothing here re-resolves - /// what it drops: [`file_tree_refresh_roots`](Tty7App::file_tree_refresh_roots) - /// does, and it only runs from a paint. So a caller that has cleared this - /// cache still owes the window a frame, whatever else its batch did or did - /// not reach (issue #243). fn invalidate_repo_roots(&mut self) -> bool { let had = !self.repo_roots.is_empty() || !self.repo_root_loads.is_empty(); self.repo_roots.clear(); @@ -791,18 +520,6 @@ impl FileTreeState { had } - /// Show `op`'s result in `dir`'s cached listing before the host has - /// confirmed it, returning the listing as it was so a failure can put it - /// back verbatim. - /// - /// Optimism is the whole point: a row that only appears once the write has - /// landed reads, on a remote host, as a keystroke the app dropped. The - /// snapshot is what makes it safe — one `Vec` clone, and undoing is - /// restoring rather than inverting each kind of edit. - /// - /// A directory with nothing cached is left alone: there is no listing to - /// show the guess in, and inventing one would claim the directory holds - /// only this entry. fn optimistic( &mut self, host: HostId, @@ -810,26 +527,15 @@ impl FileTreeState { op: &TreeWrite, target: &TreeEntry, ) -> Option<Vec<TreeEntry>> { - // A listing requested before the write would otherwise land mid-edit - // and erase the optimistic row — a visible flicker on any host whose - // `read_dir` is not instant. self.loads.invalidate(&(host, dir.to_path_buf())); optimistic_write(&mut self.children, host, dir, op, target) } - /// Put `dir`'s listing back after an [`optimistic`](Self::optimistic) guess - /// the host rejected. fn rollback(&mut self, host: HostId, dir: &Path, before: Option<Vec<TreeEntry>>) { rollback_write(&mut self.children, host, dir, before) } } -// --------------------------------------------------------------------------- -// Pure helpers (tested). -// --------------------------------------------------------------------------- - -/// Directories first, then case-insensitive by name (dotfiles keep their -/// leading-dot position in that ordering — they sort before letters). pub(crate) fn sort_entries(entries: &mut [TreeEntry]) { entries.sort_by(|a, b| { b.is_dir @@ -838,9 +544,6 @@ pub(crate) fn sort_entries(entries: &mut [TreeEntry]) { }); } -/// The cache mutation behind [`FileTreeState::optimistic`], over the map rather -/// than the state, so it can be exercised without a GPUI app to mint a -/// `FocusHandle` from. fn optimistic_write( children: &mut ByHost<PathBuf, Vec<TreeEntry>>, host: HostId, @@ -865,19 +568,6 @@ fn optimistic_write( before } -/// The undo half of [`optimistic_write`]: drop the guess and let the directory -/// relist. -/// -/// Deliberately *not* "put the snapshot back". Between the guess and the -/// failure, a watcher event may have invalidated the listing and a fresh load -/// may have installed the true one — and reinstating a snapshot on top of that -/// leaves the tree showing pre-change content indefinitely, since nothing is -/// left in flight or marked stale to correct it. Discarding cannot be wrong: -/// the host is the authority, the next paint asks it, and the row vanishing on -/// failure is exactly what an optimistic write costs. -/// -/// `before` is taken by value so the snapshot is consumed rather than left -/// lying around for a caller to misuse. fn rollback_write( children: &mut ByHost<PathBuf, Vec<TreeEntry>>, host: HostId, @@ -888,7 +578,6 @@ fn rollback_write( children.remove(host, &dir.to_path_buf()); } -/// Single-quote a path for the shell; embedded `'` becomes `'\''`. pub(crate) fn shell_quote(path: &Path) -> String { let s = path.to_string_lossy(); if !s.is_empty() @@ -900,67 +589,25 @@ pub(crate) fn shell_quote(path: &Path) -> String { format!("'{}'", s.replace('\'', r"'\''")) } -// --------------------------------------------------------------------------- -// Tty7App: toggling, fs events, row operations. -// --------------------------------------------------------------------------- - impl Tty7App { - /// The machine the file tree and the code editor act on: this window's, - /// [`spawn_host`](Tty7App::spawn_host) resolved to its host object. - /// - /// **Derived from the window's workspace, never cached.** A window shows one - /// workspace and a workspace names one machine, so there is a - /// right answer at every instant and no event to subscribe to — a tab - /// switch, a new split, a rebind and a session restore all move it by - /// construction. An earlier version of this re-derived the id from the - /// active tab's panes inside - /// [`file_tree_refresh_roots`](Self::file_tree_refresh_roots), which is only - /// called when the root set is empty or a tab switch finds the panel open — - /// so the tree could keep acting on the machine it was last rooted for. - /// - /// `None` means that machine is not reachable: a remote workspace whose - /// connection dropped. Call sites stop there rather than falling back to the - /// local host, which would list *this* machine's `/home/me/proj` in a tree - /// labelled with the remote's. pub(crate) fn active_host(&self, cx: &App) -> Option<SharedHost> { HostRegistry::lookup(cx, self.spawn_host(cx)) } - /// Derive the root set from the active tab's panes: each pane cwd maps to - /// its repo root (or itself outside a repo); home as the last resort. - /// - /// Called on every paint of the tree, so it is written to be cheap and to - /// act only when the derived set actually differs. Deriving it once and - /// pinning it — what this used to do — left the tree on the directory the - /// panel happened to open in, which on a remote workspace is `$HOME` every - /// time. pub(crate) fn file_tree_refresh_roots(&mut self, window: &mut Window, cx: &mut Context<Self>) { let id = self.spawn_host(cx); let Some(host) = self.active_host(cx) else { - return; // that machine is gone; the tree keeps what it has + return; }; let leaves = match self.tabs.get(self.active) { Some(tab) => tab.pane.terminals(), None => Vec::new(), }; - // Only panes on the window's own machine contribute. They all are, by - // design — but a stray one (a tab carried across a rebind) would put - // a path from another machine into the root set, and every listing of it - // would then be asked of the wrong host. - // - // `cwd`, not `host_cwd`: a native-SSH pane reports a path this machine - // cannot answer for, and the tree has always shown it anyway (as a root - // that lists nothing). Unchanged here on purpose — remote *workspaces* - // are what this is fixing. let cwds: Vec<PathBuf> = leaves .iter() .filter(|leaf| leaf.read(cx).host_id() == id) .filter_map(|leaf| leaf.read(cx).cwd()) .collect(); - // Resolving a cwd to its repository root is a host call — a walk up the - // ancestors testing for `.git`, which is one round trip remotely. So it - // is answered from cache, and a miss queues the resolution instead of - // blocking the frame. let mut roots: Vec<PathBuf> = Vec::new(); let mut resolved = true; for cwd in &cwds { @@ -976,18 +623,9 @@ impl Tty7App { } } } - // A partial answer would make the tree flap: roots would appear one - // resolution at a time, each one clearing the caches. Every landing - // notifies, so the next paint re-enters here and eventually completes. if !resolved { return; } - // Last resort when no pane has reported a cwd yet. **Only on the local - // host**: `HOME` is this machine's, and handing `/Users/me` to a remote - // workspace's tree roots it at a path that does not exist over there — - // a row that lists nothing, labelled as if it were the remote's. A - // remote tree with nothing to root on is better left empty until the - // first OSC 7 arrives, which is a moment away. if roots.is_empty() && id.is_local() && let Some(home) = std::env::var_os("HOME") @@ -998,28 +636,14 @@ impl Tty7App { let Some(code) = self.tab_code_mut_or_init() else { return; }; - // Dropping the caches is only correct work when the root set actually - // moved, and doing it unconditionally would spin: this runs on every - // paint, so a tab that can't produce any roots (no panes, no `HOME`) - // would clear the caches and re-notify every frame. if roots != code.roots { code.roots = roots; - // Refresh listings but keep expansion state; the caches are shared - // (path-keyed), so a stale entry only costs a relist. self.file_tree.invalidate_all(); cx.notify(); } self.file_tree_sync_watch(host, cx); } - /// Point the watch at every tab's roots *and* expanded directories. - /// - /// Two changes from the recursive watch this replaces. The set now includes - /// expanded directories, because a non-recursive watch on the roots alone - /// would never report a change two levels down. And it is the union across - /// tabs, which can move while the active tab's roots stay put — closing a - /// tab takes its roots out of it — so the comparison is against the union - /// itself rather than riding on the per-tab check. fn file_tree_sync_watch(&mut self, host: SharedHost, cx: &mut Context<Self>) { let union: HashSet<PathBuf> = self .tabs @@ -1032,8 +656,6 @@ impl Tty7App { } } - /// Resolve one pane cwd to its repository root, unless that is already - /// cached or already out. fn file_tree_request_repo_root( &mut self, host: &SharedHost, @@ -1050,9 +672,6 @@ impl Tty7App { cx, { let cwd = cwd.clone(); - // Outside any repository the cwd is its own root — which is - // also what a failed probe falls back to, so an unreadable - // directory still shows as a root rather than vanishing. move |h| h.repo_root(&cwd).ok().flatten() }, move |app, root, cx| { @@ -1066,131 +685,37 @@ impl Tty7App { ); } - /// Whether the tree column is actually being drawn, for the work that only - /// a visible tree can have a result for. - /// - /// Three conditions, because the Files tab does not always draw the local - /// tree. An open panel on that tab reaches - /// [`render_panel_files`](Self::render_panel_files), which hands the column - /// to the SFTP browser instead whenever the tab's detail pane is a connected - /// native-SSH one — the local tree is not drawn at all then, and re-reading - /// its listings is the same waste as re-reading them for a closed panel. - /// `open_pane_id` is the fact that branch leaves behind, so reading it here - /// is reading the decision itself rather than re-deriving it (which would - /// need a `Window` this callback has not got). - /// - /// It lags by one paint: the batch arriving between switching to such a pane - /// and the paint that opens the browser still counts as on screen. That - /// direction is the safe one — a single extra re-read, versus a tree that - /// stops refreshing while somebody is looking at it. - /// - /// Deliberately not consulting the tab's `code.visible`: the tree left the - /// code overlay for the panel, and the overlay draws the editor only (see - /// `render_code_overlay`). pub(crate) fn file_tree_on_screen(&self, cx: &App) -> bool { self.right_panel_open(cx) && self.right_panel_tab == RightPanelTab::Files && self.sftp_panel.open_pane_id.is_none() } - /// The Files search box's query, trimmed and lowercased. The one place it - /// is derived, because what the tree draws turns on it in two places. fn file_tree_query(&self, cx: &App) -> String { self.file_search.read(cx).value().trim().to_lowercase() } - /// Whether the Files search box has a query in it, which is the tree's - /// other mode: [`render_file_tree_rows`](Self::render_file_tree_rows) draws - /// [`search_rows`](FileTreeState::search_rows) then — flat hits from their - /// own host-side walk — and the cached directory listings are not on screen - /// at all. - /// - /// The test itself rather than each caller's own copy of it, because the - /// paint and the watcher have to agree about which mode the tree is in. pub(crate) fn file_tree_searching(&self, cx: &App) -> bool { !self.file_tree_query(cx).is_empty() } - /// Whether the tree is drawing directory listings, for the work that only a - /// drawn listing can have a result for — which is every `read_dir` the tree - /// asks for. - /// - /// Strictly narrower than [`file_tree_on_screen`](Self::file_tree_on_screen): - /// a searching tree *is* on screen, and still owes its column paints, but - /// none of them read a listing. Relisting for one is the same waste as - /// relisting for a closed panel — a round trip per marked directory per - /// event batch, on a remote workspace — and the marks carry the change - /// across the same way, re-read by the first paint after the box is - /// cleared. pub(crate) fn file_tree_listings_on_screen(&self, cx: &App) -> bool { self.file_tree_on_screen(cx) && !self.file_tree_searching(cx) } - /// Watcher callback (debounced): mark the affected listings for a refresh - /// and re-read them. A `.gitignore` change resets ignore state wholesale — - /// its patterns can affect any depth below it. - /// - /// See [`event_can_change_a_row`] for what is deliberately ignored here. - /// - /// Notably this no longer repaints for its own sake (issue #243). The - /// subscription is non-recursive over the roots plus the expanded - /// directories — see [`sync_watch`](FileTreeState::sync_watch) — so what - /// arrives is a change in a directory the tree is *displaying*, and a file's - /// contents being rewritten arrives exactly as loudly as an entry appearing - /// or disappearing. An editor saving on every keystroke, a build writing its - /// log next to the sources, a formatter rewriting the file in place: each of - /// those was a full-window redraw every [`REFRESH_DEBOUNCE`], the tree - /// asking to be drawn before it knew whether it had anything new to draw. - /// The re-read's own landing repaints instead, and only when the listing - /// came back different. - /// - /// Two things still repaint on the event itself: the whole-cache - /// `.gitignore` branch, whose re-walk only a paint performs, and a batch - /// that moved a repository root. Both are gated on - /// [`file_tree_on_screen`](Self::file_tree_on_screen), because both exist to - /// get a paint that would do nothing while the column is not drawn — and a - /// searching tree does want both, its walk being what `invalidate_all` - /// restarts. - /// - /// The re-read is gated on the narrower - /// [`file_tree_listings_on_screen`](Self::file_tree_listings_on_screen) - /// instead: it is the only effect here that a search box with something in - /// it can have no use for. pub(crate) fn file_tree_apply_fs_events( &mut self, host: HostId, paths: &HashSet<PathBuf>, cx: &mut Context<Self>, ) { - // Every batch, named. A watcher that reports a change nobody made is - // indistinguishable from a real edit at this layer — it just drops the - // listing cache and the next paint relists, which over a remote link is - // a round trip per batch. If that ever runs away, this is the line that - // says which paths are doing it. log::debug!( target: "tty7::file_tree", "fs events on host {host:?}: {:?}", paths.iter().take(8).collect::<Vec<_>>() ); - // Whether anything below can act on this batch at all. The tree draws in - // one place — the right panel's Files tab — and every effect this - // function has is either a paint of that column or a listing read to - // fill it. With the panel closed the subscription stays open (it is - // derived from every tab's roots and expansion, which outlive the - // panel), so without this check a build writing into a watched directory - // buys a `read_dir` per marked directory, per batch, for a column nobody - // can see — a network round trip each, on a remote workspace. The marks - // are what carry the change across: they stay, and the first paint after - // the panel comes back re-reads them. let on_screen = self.file_tree_on_screen(cx); - // The narrower of the two, for the re-read alone: a tree with a query in - // its search box is drawn, and owes the paints below, but draws hits - // rather than listings — so a `read_dir` issued for it lands in a cache - // nothing is reading. Same predicate the paint decides its own mode - // with, rather than a second copy of the test. let listings_on_screen = self.file_tree_listings_on_screen(cx); - // A `.git` coming or going moves a repository root, which is the one - // thing the cached root derivation cannot notice by itself. let mut roots_moved = false; if paths.iter().any(|p| { p.file_name().is_some_and(|n| n == ".git") @@ -1200,20 +725,11 @@ impl Tty7App { }) { roots_moved = self.file_tree.invalidate_repo_roots(); } - // The caches are shared across tabs, so invalidate unconditionally — - // a hidden tab's stale listing would otherwise survive until reopened. let gitignore_touched = paths .iter() .any(|p| p.file_name().is_some_and(|n| n == ".gitignore")); if gitignore_touched && self.file_tree.gitignore_reaches_tree(host, paths) { - // The host has already dropped the compiled matchers from inside - // its own watcher; what is left for us is the listings that carry - // the `ignored` flags those matchers produced. self.file_tree.invalidate_all(); - // Repaints on the event itself: `invalidate_all` restarts the - // search, and only a paint re-walks it. `SearchState::restart` - // clears `pending`, so a panel that is closed here re-walks on the - // paint that reopens it instead. if on_screen { cx.notify(); } @@ -1222,40 +738,13 @@ impl Tty7App { for dir in dirs_to_relist(paths, self.file_tree.show_hidden) { touched |= self.file_tree.invalidate_dir(host, dir); } - // A batch that moved a repository root owes the window a frame - // whatever else it reached: `file_tree_refresh_roots` is what - // re-resolves the cache just cleared, and it only runs from a paint. - // Nothing else here would ask for one — `.git` is a dot-file, so - // under the default `show_hidden: false` it never survives - // `dirs_to_relist`, and a batch naming nothing but `.git` leaves - // `touched` false and returns just below. if roots_moved && on_screen { cx.notify(); } if !touched { - // Nothing the tree holds was reached, so there is nothing to - // re-read and nothing more to redraw. return; } - // Deliberately *not* restarting the search here. Its results are - // their own walk rather than a view over the listings just dropped, - // so they do go stale — but restarting on every event batch starves - // the walk outright: this callback fires roughly every - // `REFRESH_DEBOUNCE`, and a restart bumps the generation that the - // walk re-checks after waiting `SEARCH_DEBOUNCE`, so under any - // sustained churn (a build writing into a directory somebody has - // expanded — the watch follows the expansion set and knows nothing - // about gitignore) every - // walk bows out before it ever reads a directory and the list stays - // empty forever. A snapshot that's a few seconds old until the next - // keystroke is the better failure. - // Re-read what was marked, here rather than on the next paint. - // Waiting for one would mean notifying to *get* one, which is the - // full-window redraw per event batch this function's doc comment is - // about. Only while the listings are the thing being drawn, and only - // while the tree is still pointed at the machine the events came - // from. if !listings_on_screen { return; } @@ -1283,8 +772,6 @@ impl Tty7App { cx.notify(); } - /// Row activation (click / Enter): directories toggle, files open in the - /// editor panel. fn file_tree_activate( &mut self, row_path: &Path, @@ -1295,10 +782,6 @@ impl Tty7App { if let Some(code) = self.tab_code_mut() { code.selected = Some(row_path.to_path_buf()); } - // Search results are a flat list, so "expand" there has nothing to show. - // Clicking a directory in them means "take me to it": drop the query and - // open the real tree down to that directory, which is the only way the - // click can produce a visible result. let searching = !self.file_search.read(cx).value().trim().is_empty(); if is_dir && searching { self.file_tree_reveal(row_path, cx); @@ -1315,8 +798,6 @@ impl Tty7App { cx.notify(); } - /// Expand `dir` and every ancestor of it up to its root, so a path buried - /// several levels down becomes visible in one step. fn file_tree_reveal(&mut self, dir: &Path, cx: &mut Context<Self>) { let roots = self.tab_code().map(|c| c.roots.clone()).unwrap_or_default(); let Some(root) = roots.iter().find(|r| dir.starts_with(r)).cloned() else { @@ -1331,7 +812,6 @@ impl Tty7App { cx.notify(); } - /// Keyboard navigation over the flattened rows. fn file_tree_key_down( &mut self, ev: &KeyDownEvent, @@ -1382,7 +862,6 @@ impl Tty7App { if is_dir && expanded && !is_root { code.expanded.remove(&path); } else if parent_in_rows && let Some(parent) = path.parent() { - // Jump to the parent row (stay put at a root). code.selected = Some(parent.to_path_buf()); } } @@ -1408,11 +887,6 @@ impl Tty7App { } } - // ----- Inline edits (new file / new folder / rename) -------------------- - - /// `target_is_dir` comes from the row that opened the menu. It used to be a - /// `Path::is_dir()` call right here — a stat on the UI thread, and on a - /// remote host a round trip before the input box could even appear. fn file_tree_begin_edit( &mut self, edit_for: TreeEditKind, @@ -1448,8 +922,6 @@ impl Tty7App { }, ); self.file_tree.editing_subs = vec![sub]; - // New entries land in the target dir (or the file's parent), which - // must be expanded for the inline input row to show. let host_dir = if target_is_dir { target.to_path_buf() } else { @@ -1498,7 +970,6 @@ impl Tty7App { }; let id = host.id(); let dir = edit.host_dir().to_path_buf(); - // What to do, and what the row should look like while it is happening. let (new_path, is_dir, op): (PathBuf, bool, TreeWrite) = match &edit { TreeEdit::NewFile { dir, .. } => (host.join(dir, &name), false, TreeWrite::NewFile), TreeEdit::NewFolder { dir, .. } => (host.join(dir, &name), true, TreeWrite::NewFolder), @@ -1509,10 +980,6 @@ impl Tty7App { .get(id, &dir) .and_then(|entries| entries.iter().find(|e| e.path == *path)) .is_some_and(|e| e.is_dir); - // `Host::join` on the parent, not `Path::with_file_name`: - // the latter re-pushes with the *client's* separator, so a - // Windows client renaming a remote `/home/me/a.rs` would ask - // for `/home/me\b.rs`. let parent = path.parent().unwrap_or(path); ( host.join(parent, &name), @@ -1541,26 +1008,18 @@ impl Tty7App { move |h| match &op { TreeWrite::NewFile => h.create_file_new(&target), TreeWrite::NewFolder => h.create_dir(&target, false), - // No `exists` probe first: that is a second round trip and a - // TOCTOU window. `Host::rename` promises `AlreadyExists`. TreeWrite::Rename { from } => h.rename(from, &target), TreeWrite::Delete => h.remove(&target, is_dir), }, move |app, result: std::io::Result<()>, window, cx| { match result { Ok(()) => { - // Relist for the truth — the optimistic row guessed at - // `ignored`, and the host is the authority on ordering. app.file_tree.invalidate_dir(id, &dir); - // A freshly created file opens straight into the editor. if matches!(edit, TreeEdit::NewFile { .. }) { app.open_file_in_editor(&new_path, window, cx); } } Err(e) => { - // Put the row back and say why. Leaving it would show a - // file that does not exist until something else - // happened to relist the directory. app.file_tree.rollback(id, &dir, rollback); if let Some(code) = app.tab_code_mut() && code.selected.as_deref() == Some(&*new_path) @@ -1577,11 +1036,6 @@ impl Tty7App { cx.notify(); } - /// Context-menu delete, with a native confirm (recursive for dirs). - /// - /// `is_dir` comes from the row rather than from a `stat`: the tree already - /// knows, and asking the host would put a round trip between the click and - /// the confirmation dialog. fn file_tree_delete( &mut self, path: PathBuf, @@ -1602,9 +1056,6 @@ impl Tty7App { PromptLevel::Warning, &format!("Delete \"{name}\"?"), Some(detail), - // Safe option first: the leading button is the Return-key default on - // macOS (NSAlert) and Windows (TaskDialog); "Cancel" is what gpui maps - // to the Escape key. &["Cancel", "Delete"], cx, ); @@ -1618,7 +1069,6 @@ impl Tty7App { let Some(parent) = path.parent().map(Path::to_path_buf) else { return; }; - // Optimistic: the row goes now, not a round trip later. let row = TreeEntry { name: name.clone(), path: path.clone(), @@ -1658,7 +1108,6 @@ impl Tty7App { .detach(); } - /// "cd here": type `cd <dir>` + Enter into the focused pane's PTY. fn file_tree_cd(&mut self, dir: &Path, window: &mut Window, cx: &mut Context<Self>) { let Some(leaf) = self .tabs @@ -1672,8 +1121,6 @@ impl Tty7App { self.focus_active(window, cx); } - /// "Attach to agent": paste an `@path` reference into the pane running a - /// coding agent (unsubmitted, so the user can keep typing the prompt). fn file_tree_attach_to_agent(&mut self, path: &Path, cx: &mut Context<Self>) { let Some(target) = self.agent_target_leaf(cx) else { crate::terminal::notify_desktop( @@ -1682,8 +1129,6 @@ impl Tty7App { ); return; }; - // Prefer a repo-relative path (what agents resolve best) when the file - // sits under one of the tree's roots. let rel = self .tab_code() .into_iter() @@ -1697,7 +1142,6 @@ impl Tty7App { } } -/// Which inline edit a context-menu entry starts. #[derive(Clone, Copy)] enum TreeEditKind { NewFile, @@ -1705,9 +1149,6 @@ enum TreeEditKind { Rename, } -/// A committed inline edit, reduced to the host call it becomes. Carries the -/// rename's source because that is the one piece the destination path does not -/// already imply. enum TreeWrite { NewFile, NewFolder, @@ -1715,65 +1156,23 @@ enum TreeWrite { Delete, } -// --------------------------------------------------------------------------- -// Rendering. -// --------------------------------------------------------------------------- - impl Tty7App { - /// The file-tree column: just the scrolling rows of the tree — no header, no - /// fixed width, no surface of its own — because the one thing that draws it - /// already has those. That is the right panel's Files tab, and only it: the - /// tree left the code overlay, which now draws the editor alone. - /// - /// The single draw site is why [`file_tree_on_screen`](Self::file_tree_on_screen) - /// is three conditions and no more, and why everything this function does - /// per paint — re-rooting, moving the watched set, requesting listings — - /// stops happening the moment the panel closes. - /// - /// Requesting listings stops a step earlier than the rest: a query in the - /// search box puts the column in its other mode, drawing hits from their own - /// walk, and no cached listing is read at all. That test is - /// [`file_tree_searching`](Self::file_tree_searching) rather than a local - /// one, because the watcher has to reach the same answer — see - /// [`file_tree_apply_fs_events`](Self::file_tree_apply_fs_events). pub(crate) fn render_file_tree_rows( &mut self, window: &mut Window, cx: &mut Context<Self>, ) -> AnyElement { - // Re-derive every paint, not just when the tree has no roots yet. - // - // The root of a pane is its **repository** root, so this does not make - // the tree chase the shell: `cd` inside a project resolves to the same - // root and nothing moves. What does move it is `cd`-ing to another - // project — which is exactly when the tree showing the old one is - // wrong. Rooting only on "empty, tab switch, panel toggle" pinned a - // remote workspace's tree to `$HOME` forever, because that is where a - // fresh login sits when the panel first opens. - // - // `file_tree_refresh_roots` compares before it acts: an unchanged root - // set costs a few map lookups and touches nothing. self.file_tree_refresh_roots(window, cx); let (roots, expanded) = match self.tab_code() { Some(code) => (code.roots.clone(), code.expanded.clone()), None => (Vec::new(), std::collections::HashSet::new()), }; let query = self.file_tree_query(cx); - // `None` — the tree's machine has gone away — still renders: the rows - // come from caches keyed by its id, so the tree stays on screen as it - // last was instead of blanking. What stops is the work that needs the - // machine: no watch to move, no listings to request. let host = self.active_host(cx); let host_id = self.spawn_host(cx); - // The watched set follows the expanded set, and expansion is toggled - // from half a dozen places (click, arrow keys, reveal, a new inline - // edit). Syncing here instead means one place that cannot be forgotten; - // the steady-state cost is a set comparison over a few dozen paths. if let Some(host) = host.clone() { self.file_tree_sync_watch(host, cx); } - // Both branches only read caches; whatever is missing is queued onto the - // background executor and shows up on the paint after it lands. self.file_tree.sync_search(&query, &roots, cx); let rows = if self.file_tree_searching(cx) { self.file_tree.search_rows() @@ -1791,8 +1190,6 @@ impl Tty7App { .track_scroll(&self.right_panel.tree_scroll) .px_1() .pb_1() - // Keyboard nav (arrows / enter / rename) followed the tree out of the - // overlay: the rows still own the focus handle its key handler reads. .track_focus(&self.file_tree.focus_handle) .on_key_down(cx.listener(|this, ev: &KeyDownEvent, window, cx| { this.file_tree_key_down(ev, window, cx); @@ -1808,7 +1205,6 @@ impl Tty7App { ) } - /// One row (plus, when an inline edit targets it, the edit input row). fn render_tree_row( &self, row: &TreeRow, @@ -1820,14 +1216,10 @@ impl Tty7App { let selected = self.tab_code().and_then(|c| c.selected.as_deref()) == Some(&*path); let muted = cx.theme().muted_foreground; let sf = cx.global::<crate::ui::presets::Surfaces>().popover; - // Unsaved edits used to be visible on the editor's file tabs; with those - // gone the tree is the only place an open buffer is represented, so it has - // to carry the dirty marker or unsaved work becomes invisible. let dirty = self .tab_code() .is_some_and(|c| c.files.iter().any(|f| f.dirty && f.path == *path)); - // Inline rename replaces the row's label with an input. let renaming = matches!( &self.file_tree.editing, Some(TreeEdit::Rename { path: p, .. }) if *p == path @@ -1871,17 +1263,8 @@ impl Tty7App { .py_1() .rounded(cx.theme().radius) .cursor_pointer() - // Soft inset-pill highlight on the content surface. The tree paints on - // `popover` (see the container below), so this is that surface's - // ladder — read explicitly rather than through `Theme::accent`, which - // is gpui-component's name for a row highlight and says nothing about - // which surface it was anchored to. Hover was `accent.opacity(0.5)`; a - // ladder rung is a real colour, so it doesn't change meaning depending - // on what it lands on. .when(selected, |d| d.bg(gpui::rgb(sf.selected))) .when(!selected, |d| d.hover(|s| s.bg(gpui::rgb(sf.hover)))) - // Folders take the full foreground, files the muted tone — a neutral - // weight difference, no hue, so the tree keeps the terminal's calm. .child(Icon::new(icon).xsmall().text_color(if is_dir { cx.theme().foreground } else { @@ -1907,8 +1290,6 @@ impl Tty7App { } }), ) - // Drag the row as external paths — the terminal's existing drop - // handler shell-escapes and inserts them. .on_drag(ExternalPaths(vec![path.clone()].into()), { let name = row.entry.name.clone(); move |_, _, _, cx| { @@ -1937,8 +1318,6 @@ impl Tty7App { let mut out: Vec<AnyElement> = vec![row_el.into_any_element()]; - // New-file/new-folder edit input renders as a pseudo-child row of its - // host directory (right after the dir's own row). if let Some(edit) = &self.file_tree.editing { let host_matches = match edit { TreeEdit::NewFile { dir, .. } | TreeEdit::NewFolder { dir, .. } => *dir == path, @@ -1961,10 +1340,6 @@ impl Tty7App { out } - /// The per-row right-click menu, mirroring Warp's Project Explorer set, plus - /// the tree's one view option (dotfiles) — which lives here rather than as a - /// header button: it is set once and then forgotten, and a tile in the header - /// spends the panel's scarcest row on it forever. fn tree_row_context_menu( menu: PopupMenu, path: &Path, @@ -2088,12 +1463,6 @@ impl Tty7App { } } -/// The tree's dotfile switch as a row of the tree's existing right-click menu. -/// -/// The label states what the click will do rather than checking off the current -/// state: a single checked item makes `PopupMenu` reserve a left icon gutter on -/// *every* row in the menu (see `tab_strip::window_chrome`), and the menu has a -/// dozen rows with nothing to put in one. fn dotfiles_menu_item(show_hidden: bool, app: &gpui::WeakEntity<Tty7App>) -> PopupMenuItem { let app = app.clone(); PopupMenuItem::new(if show_hidden { @@ -2109,7 +1478,6 @@ fn dotfiles_menu_item(show_hidden: bool, app: &gpui::WeakEntity<Tty7App>) -> Pop }) } -/// The little drag ghost shown while a row is dragged toward a terminal. struct DragGhost { name: String, } @@ -2131,8 +1499,6 @@ impl gpui::Render for DragGhost { } } -/// The body of [`FileTreeState::land_load`], over the two fields it touches so -/// the rule can be tested without an `App` to hang a whole tree state off. fn land_listing( loads: &mut InFlight<DirKey>, children: &mut ByHost<PathBuf, Vec<TreeEntry>>, @@ -2144,26 +1510,10 @@ fn land_listing( ) -> bool { let superseded = !loads.finish(key); children.insert(id, dir, entries); - // What was on screen has just been replaced, so the mark goes. A listing - // superseded in flight is re-requested by the caller rather than by the - // mark, which is why clearing it here cannot lose the refresh. stale.remove(key); superseded } -/// The directories a batch of watcher events can have changed the listing of. -/// -/// The parent of each event path, and **not the path itself**. A row is a child -/// of the directory it appears under, so an event on `d` is news for `d`'s -/// parent; `d`'s own listing changes only when something inside it does, and -/// that arrives as an event on that child. -/// -/// This is not just an economy. A watched directory gets an event of its own -/// whenever anything inside it is touched — including the dot-files -/// [`event_can_change_a_row`] deliberately skips — so relisting `d` for `d`'s -/// event puts back exactly the round trip that filter exists to avoid. `$HOME` -/// with a coding agent rewriting `~/.claude.json` was one relist of the home -/// directory per write, forever. fn dirs_to_relist(paths: &HashSet<PathBuf>, show_hidden: bool) -> HashSet<&Path> { paths .iter() @@ -2172,19 +1522,6 @@ fn dirs_to_relist(paths: &HashSet<PathBuf>, show_hidden: bool) -> HashSet<&Path> .collect() } -/// Whether a watcher event for `path` can change a row the tree is showing. -/// -/// A dot-file that is not on screen cannot, so relisting for it buys nothing -/// and costs a round trip. Worth skipping rather than merely wasteful: `$HOME` -/// on a machine somebody works on holds several files rewritten continuously -/// (`.claude.json`, `.bash_history`, shell state), and `$HOME` is exactly where -/// a fresh remote workspace roots its tree — so the relisting never stops. -/// -/// `show_hidden` is consulted rather than assumed: with hidden entries on -/// screen these events matter again. `.git` and `.gitignore` are handled by -/// their own tests *before* this one — those are dot-files whose contents -/// change what the visible rows mean, which is a different question from -/// whether the file itself is a row. fn event_can_change_a_row(path: &Path, show_hidden: bool) -> bool { show_hidden || !path @@ -2205,13 +1542,6 @@ mod tests { } } - /// A listing that was superseded while in flight still lands. - /// - /// Dropping it starves a directory that changes faster than the round trip: - /// every answer arrives stale, so the cache stays empty and the rows blink - /// out on every paint. That is unreachable locally (microseconds) and - /// routine over SSH, which is why it survived until a remote workspace hit - /// it — one file rewritten a few times a second was enough. #[test] fn a_listing_superseded_in_flight_is_still_shown() { let mut loads: InFlight<DirKey> = InFlight::default(); @@ -2223,7 +1553,6 @@ mod tests { let mut stale: HashSet<DirKey> = HashSet::new(); assert!(loads.begin(key.clone()), "the listing goes out"); - // A watcher event lands mid-flight — the case that used to discard. loads.invalidate(&key); let again = land_listing( @@ -2241,7 +1570,6 @@ mod tests { "the snapshot is on screen rather than thrown away" ); - // The undisturbed case still reports "no need to go round again". assert!(loads.begin(key.clone())); let again = land_listing( &mut loads, @@ -2255,11 +1583,6 @@ mod tests { assert!(!again, "nothing superseded it, so one listing is enough"); } - /// An outdated listing keeps its rows on screen until the replacement - /// lands, and the replacement clears the mark. - /// - /// Dropping it at invalidation time is invisible locally and strobes over a - /// link: every watcher batch blanks the directory for a whole round trip. #[test] fn an_outdated_listing_stays_on_screen_until_its_replacement_lands() { let mut loads: InFlight<DirKey> = InFlight::default(); @@ -2280,7 +1603,6 @@ mod tests { vec![entry("src", true)], ); - // What `invalidate_dir` does to a cached listing: mark, never remove. stale.insert(key.clone()); assert_eq!( children.get(id, &dir).map(Vec::len), @@ -2288,7 +1610,6 @@ mod tests { "the rows are still there to paint" ); - // …and the refresh does go out, which is what `request_load` asks. let current = children.get(id, &dir).is_some() && !stale.contains(&key); assert!(!current, "stale means re-ask"); @@ -2306,14 +1627,8 @@ mod tests { assert_eq!(children.get(id, &dir).map(Vec::len), Some(2)); } - /// A directory's own watcher event relists its *parent*, not itself. - /// - /// Relisting itself hands back the round trip that skipping dot-files - /// saves: a watched directory gets an event of its own for every write - /// inside it, hidden or not. #[test] fn a_directorys_own_event_does_not_relist_it() { - // One `~/.claude.json` write, as the watcher reports it. let batch: HashSet<PathBuf> = [ PathBuf::from("/home/me/.claude.json"), PathBuf::from("/home/me"), @@ -2328,7 +1643,6 @@ mod tests { ); assert!(dirs.contains(Path::new("/home")), "its parent is"); - // A visible file appearing under it does relist it — via its own path. let batch: HashSet<PathBuf> = [ PathBuf::from("/home/me/notes.md"), PathBuf::from("/home/me"), @@ -2337,7 +1651,6 @@ mod tests { .collect(); assert!(dirs_to_relist(&batch, false).contains(Path::new("/home/me"))); - // And with hidden entries shown, the dot-file is a row again. let batch: HashSet<PathBuf> = [PathBuf::from("/home/me/.claude.json")] .into_iter() .collect(); @@ -2345,10 +1658,6 @@ mod tests { assert!(dirs_to_relist(&batch, false).is_empty()); } - /// The churn that made a remote tree flicker: a coding agent rewriting - /// `~/.claude.json` several times a second, under a tree rooted at `$HOME`. - /// The file is not a row (hidden), so it must not cost a listing — and must - /// start costing one the moment hidden entries are shown. #[test] fn an_unshown_dot_file_does_not_trigger_a_relist() { let hidden = Path::new("/home/me/.claude.json"); @@ -2356,7 +1665,6 @@ mod tests { assert!(!event_can_change_a_row(hidden, false)); assert!(event_can_change_a_row(hidden, true)); assert!(event_can_change_a_row(visible, false)); - // A dot-*directory* is a row too once hidden entries are shown. assert!(!event_can_change_a_row( Path::new("/home/me/.config"), false @@ -2404,16 +1712,12 @@ mod tests { assert!(search.accept(second, vec![entry("foo.rs", false)])); assert_eq!(search.hits.len(), 1); - // The eye toggle filters hits inside the walk, so flipping it re-walks - // the query that's already on screen. let third = search .retarget("foo", true) .expect("showing dotfiles re-walks"); assert_ne!(second, third); assert!(search.retarget("foo", true).is_none()); - // Clearing the box drops the hits so the next query can't flash them, - // and a restart re-walks the same query rather than sitting on it. assert!(search.retarget("", true).is_none()); assert!(search.hits.is_empty()); search.retarget("foo", true).expect("typing again walks"); @@ -2421,35 +1725,21 @@ mod tests { assert!(search.retarget("foo", true).is_some(), "restart re-walks"); } - /// The listing the tree builds out of `Host::read_dir`, and the hits it - /// builds out of `Host::search`, still mean what the tree's own walk meant: - /// deepest gitignore match wins, `!` un-ignores, `.git` is ignored whatever - /// the patterns say, and ignored entries stay out of the search. - /// - /// The walk itself now lives in the host — this pins the *call*, which is - /// the part that is ours to get wrong: the two budgets and `show_hidden` - /// have to reach the host or the search silently changes shape. #[test] fn the_tree_reads_the_same_listing_out_of_the_host() { let host = tty7_core::host::local::LocalHost::new(); - // The fixture is built through the host too. Partly because it is the - // thing under test and partly because it keeps this module honest: the - // CI grep that forbids direct filesystem calls in `src/ui` does not - // know test modules from production code, and it should not have to. let tmp = std::env::temp_dir().join(format!("tty7-tree-host-{}", std::process::id())); let _ = host.remove(&tmp, true); host.create_dir(&tmp.join(".git"), true).unwrap(); host.create_dir(&tmp.join("src"), true).unwrap(); host.write_file(&tmp.join(".gitignore"), b"*.log\nbuild/\n") .unwrap(); - // The deeper file un-ignores one of the parent's patterns. host.write_file(&tmp.join("src/.gitignore"), b"!keep.log\n") .unwrap(); host.write_file(&tmp.join("drop.log"), b"").unwrap(); host.write_file(&tmp.join("src/keep.log"), b"").unwrap(); host.write_file(&tmp.join("src/main.rs"), b"").unwrap(); - // The exact mapping `request_load` performs, `Host::join` included. let list = |dir: &Path| -> Vec<TreeEntry> { host.read_dir(dir, Some(&tmp)) .unwrap() @@ -2494,8 +1784,6 @@ mod tests { let names: Vec<&str> = hits.iter().map(|h| h.name.as_str()).collect(); assert_eq!(names, vec!["keep.log"], "ignored hits stay out of search"); - // Showing dotfiles lets the ignored ones back in — the flag has to - // reach the host, because the walk is where the filtering happens. let hidden = host .search( std::slice::from_ref(&tmp), @@ -2512,13 +1800,6 @@ mod tests { let _ = host.remove(&tmp, true); } - /// `Host::read_dir` follows symlinks, so a link to a directory is now an - /// expandable directory — which means the search walk can follow a cycle. - /// - /// There is no cycle detection anywhere in the tree, and this pins why none - /// is needed for termination: `SEARCH_MAX_DIRS` bounds the walk. What it - /// does not prevent is a cycle near a root eating the whole budget, so the - /// test also shows the walk still finds a real hit past the loop. #[cfg(unix)] #[test] fn a_symlink_cycle_cannot_make_the_search_walk_forever() { @@ -2527,7 +1808,6 @@ mod tests { let _ = host.remove(&tmp, true); host.create_dir(&tmp, true).unwrap(); host.write_file(&tmp.join("needle.rs"), b"").unwrap(); - // `a/loop -> a`: every listing of it yields another directory to visit. host.create_dir(&tmp.join("a"), true).unwrap(); std::os::unix::fs::symlink(tmp.join("a"), tmp.join("a/loop")).unwrap(); @@ -2546,8 +1826,6 @@ mod tests { "breadth-first order finds the shallow hit before the cycle deepens" ); - // And the link itself reads as a directory — the behaviour change that - // makes the cycle reachable in the first place. let listed = host.read_dir(&tmp.join("a"), Some(&tmp)).unwrap(); let link = listed.iter().find(|e| e.name == "loop").expect("link"); assert!(link.is_dir, "a link to a directory expands as one"); @@ -2556,10 +1834,6 @@ mod tests { let _ = host.remove(&tmp, true); } - /// M2 regression guard: a create, a rename and a delete - /// each show their result before the host has confirmed it, and a failure - /// leaves the directory to relist rather than showing a row for a file that - /// does not exist. #[test] fn a_rejected_write_drops_the_row_it_guessed() { let host = HostId::LOCAL; @@ -2575,14 +1849,12 @@ mod tests { children.insert(host, dir.clone(), vec![entry("b.rs", false)]); }; - // Create: the new row appears, sorted into place. seed(&mut children); let new = entry("a.rs", false); let before = optimistic_write(&mut children, host, &dir, &TreeWrite::NewFile, &new); assert!(before.is_some()); assert_eq!(names(&children), vec!["a.rs", "b.rs"]); - // Rename: the old row goes, the new one arrives. seed(&mut children); let renamed = TreeEntry { name: "z.rs".into(), @@ -2601,14 +1873,11 @@ mod tests { ); assert_eq!(names(&children), vec!["z.rs"]); - // Delete: the row goes immediately. seed(&mut children); let doomed = entry("b.rs", false); optimistic_write(&mut children, host, &dir, &TreeWrite::Delete, &doomed); assert!(names(&children).is_empty()); - // A rejected write discards the listing entirely, so the next paint - // asks the host instead of trusting either the guess or a snapshot. seed(&mut children); let before = optimistic_write(&mut children, host, &dir, &TreeWrite::NewFile, &new); rollback_write(&mut children, host, &dir, before); @@ -2617,9 +1886,6 @@ mod tests { "a failed write leaves the directory to relist" ); - // The case that motivates discarding rather than restoring: a relist - // landed while the write was in flight, so the cache already holds the - // truth. Putting a pre-change snapshot back over it would stick. seed(&mut children); let before = optimistic_write(&mut children, host, &dir, &TreeWrite::NewFile, &new); children.insert(host, dir.clone(), vec![entry("fresh.rs", false)]); @@ -2629,8 +1895,6 @@ mod tests { "the stale snapshot never overwrites a newer listing" ); - // A directory nobody has listed stays unlisted rather than being - // invented as a one-entry listing. let other = PathBuf::from("/y"); let before = optimistic_write(&mut children, host, &other, &TreeWrite::NewFile, &new); assert!(before.is_none()); @@ -2640,25 +1904,6 @@ mod tests { } } -/// Issue #243 at the window: what a watcher event costs in frames. -/// -/// The other half of that issue — a refreshing directory keeping its rows on -/// screen — is [`FileTreeState::stale`]'s job and is covered by -/// `land_listing_keeps_a_superseded_snapshot`. These are about the frames: a -/// window with nothing new to draw must not be asked to draw. -/// -/// Measurements, not assertions about internals. gpui's test build redraws every -/// dirty window from inside `flush_effects`, so a real (headless) window plus -/// [`render_probe`](crate::ui::app::render_probe) counts exactly the frames the -/// app asked for — the one claim in the issue that is platform-independent, and -/// answerable without the reporter's Wayland session. -/// -/// What the live watch can actually deliver bounds what any of this can claim. -/// The subscription is non-recursive over the roots plus the expanded -/// directories, so the reachable case is a change *in a directory on screen* — -/// that is the one measured. Three cases here feed [`fs_event`] a path the live -/// subscription would drop before batching it; each is labelled a guard on the -/// predicate it exercises, and none of them is evidence of a symptom. #[cfg(all(test, unix))] mod render_idle_gpui_tests { use super::*; @@ -2667,19 +1912,8 @@ mod render_idle_gpui_tests { use gpui::{Entity, TestAppContext, VisualTestContext}; use tty7_core::core::config::RightPanelTab; - /// Draws a settle may legitimately spend before the count reads as a loop. - /// A repaint loop blows past this in well under a second, and it fails the - /// test rather than hanging it — the loop lives inside one `flush_effects` - /// call, which nothing outside it can interrupt. const BUDGET: u64 = 200; - /// These run one at a time. Every one of them drives a real window through - /// `LocalHost::shared()`, which is a process-wide `OnceLock` singleton with - /// a shared gitignore cache and its own pool — so concurrent cases contend - /// on it and the draw counts, which are the whole point here, stop being - /// meaningful. Poisoning is stepped over deliberately: one failing case - /// should report its own assertion, not turn every later one into a panic - /// about a poisoned lock. fn serial() -> std::sync::MutexGuard<'static, ()> { static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); LOCK.lock().unwrap_or_else(|e| e.into_inner()) @@ -2689,14 +1923,9 @@ mod render_idle_gpui_tests { let dir = std::env::temp_dir().join(format!("tty7-idle-{name}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); - // macOS reports watcher paths through `/private/var` while the cache is - // keyed by the root as handed in. Canonicalizing keeps these tests about - // the frames rather than about that. std::fs::canonicalize(&dir).unwrap() } - /// A window with the right panel open on the Files tab, rooted at `root` and - /// settled: the first listing has landed and everything it woke has run. fn files_panel_on( cx: &mut TestAppContext, root: &Path, @@ -2706,11 +1935,6 @@ mod render_idle_gpui_tests { std::os::unix::net::UnixStream, ) { let (app, mut vcx, mut pane) = test_window::harness_with_pane(cx); - // Tell the pane where it is, rather than writing the roots directly: - // render re-derives them from the active tab's panes every frame, so a - // root assigned behind that is replaced by the `$HOME` fallback on the - // very next paint. The test plays the daemon, and `Cwd` is the message a - // shell's OSC 7 turns into. DaemonMsg::Cwd(root.to_path_buf()) .encode(&mut pane) .expect("the pane's socket takes the cwd"); @@ -2719,9 +1943,6 @@ mod render_idle_gpui_tests { app.right_panel_tab = RightPanelTab::Files; cx.notify(); }); - // The pane's reader is a real thread and the cwd has to reach it, then - // be resolved to a repository root (a host call, answered off-thread), - // before the first listing is even asked for. let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20); loop { vcx.background_executor.run_until_parked(); @@ -2739,8 +1960,6 @@ mod render_idle_gpui_tests { ); std::thread::sleep(std::time::Duration::from_millis(20)); } - // And then until the root's own listing has landed, so a measurement - // never starts against a tree that has not drawn its rows yet. loop { app.update_in(&mut vcx, |_, _, cx| cx.notify()); vcx.background_executor.run_until_parked(); @@ -2760,7 +1979,6 @@ mod render_idle_gpui_tests { (app, vcx, pane) } - /// What the tree would paint right now. fn rows(app: &Entity<Tty7App>, vcx: &mut VisualTestContext) -> usize { app.update_in(vcx, |app, _, _| { let code = app.tab_code().expect("panel state"); @@ -2770,39 +1988,16 @@ mod render_idle_gpui_tests { }) } - /// Hand the app one debounced batch at the seam the watcher delivers to. - /// - /// The seam, not the watcher: this bypasses `WatchedDirs::translate`, so a - /// caller can synthesise a path the live subscription would never deliver. - /// The cases that do are labelled as guards on a predicate rather than as - /// symptoms, and say so. fn fs_event(app: &Entity<Tty7App>, vcx: &mut VisualTestContext, path: &Path) { app.update_in(vcx, |app, _, cx| { app.file_tree_apply_fs_events(HostId::LOCAL, &HashSet::from([path.to_path_buf()]), cx); }); } - /// Run everything the app has queued, including the host's real-thread - /// listings, to quiescence. A single `run_until_parked` is not enough: the - /// host answers off the deterministic executor, so its reply lands after the - /// test thread has already parked. fn settle(app: &Entity<Tty7App>, vcx: &mut VisualTestContext, root: &Path) { - // A wall-clock deadline, not an iteration count: the whole suite shares - // one `LocalHost` pool, so under a parallel `cargo test` a listing can - // take far longer to come back than it does alone. let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); while std::time::Instant::now() < deadline { vcx.background_executor.run_until_parked(); - // Loads, plus any mark on the tree *this test is looking at*. - // Deliberately not `stale.is_empty()`: the whole-cache branch marks - // every cached listing, and the cache outlives the panel's roots — - // the `$HOME` listing from before the pane reported its cwd is still - // in there, is not a root or an expanded directory, so - // `request_loads` never re-asks for it and its mark never clears. A - // settle waiting on that waits forever. - // - // Deliberately does not notify either — a settle that asked for - // paints would be counted by the draw probe it exists to serve. let quiet = app.update_in(vcx, |app, _, _| { app.file_tree.loads.is_empty() && !app @@ -2812,7 +2007,6 @@ mod render_idle_gpui_tests { .any(|(_, dir)| dir.starts_with(root)) }); if quiet { - // One more pass so the last landing's own notify is drawn. vcx.background_executor.run_until_parked(); return; } @@ -2821,22 +2015,12 @@ mod render_idle_gpui_tests { panic!("the tree never went quiet"); } - /// Draws over a quiet interval — no input, no filesystem change — *after* - /// the window has come to rest. Anything counted here is a frame the window - /// asked for with nothing to draw, which is what issue #243 is about. - /// - /// The rest comes first because settling legitimately costs a last frame or - /// two: the final listing lands and asks to be drawn. Render idle is not - /// "never draws again", it is "stops drawing" — so the measurement is the - /// second interval, once the first has absorbed the tail. A repaint loop - /// keeps both intervals busy and trips [`BUDGET`] long before either ends. fn draws_while_idle(vcx: &mut VisualTestContext) -> u64 { render_probe::arm(BUDGET); vcx.background_executor.run_until_parked(); vcx.executor() .advance_clock(std::time::Duration::from_secs(3)); vcx.background_executor.run_until_parked(); - // Now it is at rest. Count from here. render_probe::arm(BUDGET); vcx.executor() .advance_clock(std::time::Duration::from_secs(9)); @@ -2844,8 +2028,6 @@ mod render_idle_gpui_tests { render_probe::draws() } - /// The reporter's failing case and their two negative cases, measured the - /// same way: a settled window draws once and stops, whatever is in the tree. #[gpui::test] fn a_settled_files_panel_reaches_render_idle(cx: &mut TestAppContext) { let _serial = serial(); @@ -2881,18 +2063,6 @@ mod render_idle_gpui_tests { let _ = std::fs::remove_dir_all(&root); } - /// A guard on the `!touched` early return, **not** a symptom. - /// - /// Read the event below for what it is: `root/target/debug/artifact0.o`, - /// whose parent is neither a root nor an expanded directory. The live watch - /// cannot deliver it — it is non-recursive over roots ∪ expanded, and - /// `WatchedDirs::translate` drops anything whose parent is not in that set - /// before it is ever batched. So this measures no bug that can occur today. - /// What it holds down is the predicate: `invalidate_dir` answering "nothing - /// here" must stay a silent return, because that answer is also what a - /// watched directory with no landed listing gives, and because expanding - /// the watched set (or restoring a recursive watch) would make this exact - /// path live again. #[gpui::test] fn an_event_reaching_no_cached_listing_costs_no_frames(cx: &mut TestAppContext) { let _serial = serial(); @@ -2916,15 +2086,6 @@ mod render_idle_gpui_tests { let _ = std::fs::remove_dir_all(&root); } - /// The reachable case, and the one issue #243 is actually about on this - /// base: a directory the tree *is* showing, where what changed is a file's - /// contents rather than the set of entries. - /// - /// The displayed directories are exactly the watched ones, so this event is - /// delivered — a formatter rewriting the file, an editor saving on every - /// keystroke, a build dropping its log next to the sources. Each one cost a - /// full-window redraw, twice over: once for the `cx.notify()` in the event - /// handler and once for the landing of the relist it bought. #[gpui::test] fn rewriting_a_file_in_a_displayed_directory_costs_no_frames(cx: &mut TestAppContext) { let _serial = serial(); @@ -2947,9 +2108,6 @@ mod render_idle_gpui_tests { let _ = std::fs::remove_dir_all(&root); } - /// Not repainting for an event is only correct if a real change still - /// arrives — the re-read now happens in the event handler rather than on a - /// paint that a `cx.notify()` had to buy. #[gpui::test] fn a_real_change_still_reaches_the_panel(cx: &mut TestAppContext) { let _serial = serial(); @@ -2971,7 +2129,6 @@ mod render_idle_gpui_tests { settle(&app, &mut vcx, &root); assert_eq!(rows(&app, &mut vcx), before + 1, "the new file shows up"); - // Deletions too, and then the window settles again. std::fs::remove_file(&added).unwrap(); fs_event(&app, &mut vcx, &added); settle(&app, &mut vcx, &root); @@ -2980,16 +2137,6 @@ mod render_idle_gpui_tests { let _ = std::fs::remove_dir_all(&root); } - /// A guard on [`FileTreeState::gitignore_reaches_tree`], **not** a symptom. - /// - /// Same caveat as `an_event_reaching_no_cached_listing_costs_no_frames`: - /// `root/node_modules/pkg0/.gitignore` sits under a directory nobody - /// expanded, so the live non-recursive watch never delivers it and the - /// `npm install` story this test used to tell cannot happen here. It is kept - /// because the branch it guards is the expensive one — `invalidate_all` - /// marks every cached listing and restarts the search — and the predicate - /// deciding when to take it deserves a test that a refactor cannot quietly - /// invert. #[gpui::test] fn a_gitignore_that_governs_nothing_cached_costs_no_frames(cx: &mut TestAppContext) { let _serial = serial(); @@ -3008,8 +2155,6 @@ mod render_idle_gpui_tests { let _ = std::fs::remove_dir_all(&root); } - /// The other side of that scoping: a `.gitignore` that *can* reach the tree - /// still refreshes it, and the panel does not empty while it does. #[gpui::test] fn a_gitignore_in_the_displayed_tree_still_refreshes(cx: &mut TestAppContext) { let _serial = serial(); @@ -3024,8 +2169,6 @@ mod render_idle_gpui_tests { let ignore = root.join(".gitignore"); std::fs::write(&ignore, "file00.rs\n").unwrap(); fs_event(&app, &mut vcx, &ignore); - // Every cached listing is marked, which is what the whole-cache branch - // is for — and the rows are still on screen while it re-reads. let marked = app.update_in(&mut vcx, |app, _, _| { app.file_tree .stale @@ -3037,8 +2180,6 @@ mod render_idle_gpui_tests { assert_eq!(rows(&app, &mut vcx), before, "rows stay while it re-reads"); settle(&app, &mut vcx, &root); - // Nothing appears or disappears — an ignored entry renders dimmed, not - // hidden — and the re-read has cleared every mark. assert_eq!(rows(&app, &mut vcx), before); let left = app.update_in(&mut vcx, |app, _, _| { app.file_tree @@ -3048,23 +2189,9 @@ mod render_idle_gpui_tests { .count() }); assert_eq!(left, 0, "every marked listing under the root was re-read"); - // Deliberately not asserting the `ignored` flags here. The compiled - // matchers live in the host and are dropped from inside *its* watcher, - // which driving `file_tree_apply_fs_events` directly bypasses — so what - // this seam owns is the marking and the re-read, not what the host - // recomputes. `loader_tags_ignored_entries_down_the_gitignore_chain` - // covers the matchers themselves. let _ = std::fs::remove_dir_all(&root); } - /// A guard on `stale` staying bounded, **not** a symptom. - /// - /// The `target/debug/*.o` events below are again ones the live watch cannot - /// deliver. What the test defends is that `stale` is keyed by path and the - /// handler is fed paths it does not choose: marking one for a directory the - /// tree does not hold would let that set grow without limit, and the only - /// thing standing between it and that is `invalidate_dir` inserting solely - /// where `children` already has an entry. #[gpui::test] fn untracked_paths_leave_no_bookkeeping_behind(cx: &mut TestAppContext) { let _serial = serial(); @@ -3089,16 +2216,6 @@ mod render_idle_gpui_tests { let _ = std::fs::remove_dir_all(&root); } - /// The one batch that reaches nothing and must still draw: a `.git` - /// appearing or disappearing in a watched directory. - /// - /// `invalidate_repo_roots` empties the cwd → repository-root cache, and the - /// only thing that fills it again is `file_tree_refresh_roots`, which runs - /// from a paint. Meanwhile `.git` is a dot-file, so it never survives - /// `dirs_to_relist` under the default `show_hidden: false` and the batch - /// lands on the "reached nothing" return — which, unguarded, would leave the - /// cache cleared with nothing to resolve it and the tree showing the old - /// root until an unrelated event bought a frame. #[gpui::test] fn a_moved_repository_root_still_gets_its_frame(cx: &mut TestAppContext) { let _serial = serial(); @@ -3110,9 +2227,6 @@ mod render_idle_gpui_tests { "the panel resolved its pane's root, so there is a cache to clear" ); - // Come to rest first, so the frame counted below is the event's own and - // not one still owed from settling — the same reason `draws_while_idle` - // measures its second interval rather than its first. vcx.executor() .advance_clock(std::time::Duration::from_secs(3)); vcx.background_executor.run_until_parked(); @@ -3129,9 +2243,6 @@ mod render_idle_gpui_tests { "clearing the root cache asked for the paint that re-resolves it" ); - // The paint re-requests the root, and the host answers off the - // deterministic executor, so this waits on wall-clock like `settle` - // does rather than on a single `run_until_parked`. let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); loop { vcx.background_executor.run_until_parked(); @@ -3156,13 +2267,6 @@ mod render_idle_gpui_tests { let _ = std::fs::remove_dir_all(&root); } - /// A closed panel asks the host for nothing. - /// - /// The subscription outlives the panel — `file_tree_sync_watch` derives it - /// from every tab's roots and expansion, which persist — so events keep - /// arriving for a column nobody can see. Re-reading for them is a `read_dir` - /// per marked directory per batch, which on a remote workspace is that many - /// network round trips. The marks are what carry the change across instead. #[gpui::test] fn a_closed_panel_does_no_filesystem_work(cx: &mut TestAppContext) { let _serial = serial(); @@ -3181,11 +2285,6 @@ mod render_idle_gpui_tests { let path = root.join("file00.rs"); std::fs::write(&path, "changed").unwrap(); fs_event(&app, &mut vcx, &path); - // Long enough for a re-read to have been issued *and* landed. The host - // answers off the deterministic executor, so a single `run_until_parked` - // would leave "no work was done" and "the work has not come back yet" - // indistinguishable; after this, an unwanted re-read shows up as the - // mark below having cleared itself. let until = std::time::Instant::now() + std::time::Duration::from_secs(2); while std::time::Instant::now() < until { vcx.background_executor.run_until_parked(); @@ -3204,8 +2303,6 @@ mod render_idle_gpui_tests { assert_eq!(in_flight, 0, "nothing was asked of the host"); assert!(marked > 0, "but the change was recorded"); - // And it is picked up by the first paint after the panel comes back, - // which is what a mark is for. app.update_in(&mut vcx, |app, _, cx| { app.right_panel_visible = true; cx.notify(); @@ -3222,14 +2319,6 @@ mod render_idle_gpui_tests { let _ = std::fs::remove_dir_all(&root); } - /// The same thing one case further in: the panel is open, the tree column is - /// drawn, and it is drawing *search hits*. Those come from their own - /// host-side walk, so the cached listings are as invisible as they are - /// behind a closed panel, and re-reading one for an event batch buys a round - /// trip nothing renders. - /// - /// The query is set on the input rather than typed, which is the same state: - /// both the paint and the watcher read `file_search` for the answer. #[gpui::test] fn a_searching_tree_does_no_filesystem_work(cx: &mut TestAppContext) { let _serial = serial(); @@ -3254,9 +2343,6 @@ mod render_idle_gpui_tests { let path = root.join("file00.rs"); std::fs::write(&path, "changed").unwrap(); fs_event(&app, &mut vcx, &path); - // Long enough for a re-read to have been issued *and* landed, for the - // same reason `a_closed_panel_does_no_filesystem_work` waits: an - // unwanted one shows up as the mark below having cleared itself. let until = std::time::Instant::now() + std::time::Duration::from_secs(2); while std::time::Instant::now() < until { vcx.background_executor.run_until_parked(); @@ -3275,7 +2361,6 @@ mod render_idle_gpui_tests { assert_eq!(in_flight, 0, "nothing was asked of the host"); assert!(marked > 0, "but the change was recorded"); - // And clearing the box picks it up, exactly as reopening the panel does. app.update_in(&mut vcx, |app, window, cx| { app.file_search .update(cx, |st, cx| st.set_value("", window, cx)); @@ -3293,17 +2378,6 @@ mod render_idle_gpui_tests { let _ = std::fs::remove_dir_all(&root); } - /// The Files tab is open and the local tree is still not drawn: the SFTP - /// browser has the column, because the tab's detail pane is a connected - /// native-SSH one. - /// - /// Driven through `sftp_panel.open_pane_id` rather than through a real SSH - /// pane, which this harness cannot stand up — that field *is* what - /// `render_panel_files` branches on, set by `sftp_sync_pane` on the paint - /// that opens the browser, so it is the state the predicate has to read. - /// Everything happens inside one `update` because a paint would run - /// `sftp_sync_pane` against this window's local pane and close the browser - /// again, which is correct behaviour and would undo the setup. #[gpui::test] fn the_sftp_browser_holding_the_column_counts_as_not_drawn(cx: &mut TestAppContext) { let _serial = serial(); @@ -3333,7 +2407,6 @@ mod render_idle_gpui_tests { assert_eq!(in_flight, 0, "so nothing was asked of the host"); assert!(marked > 0, "but the change was recorded"); - // The mark is picked up once the tree has the column back. app.update_in(&mut vcx, |app, _, cx| { app.sftp_panel.open_pane_id = None; cx.notify(); diff --git a/src/ui/forwards.rs b/src/ui/forwards.rs index 804be22c..0a2f8f75 100644 --- a/src/ui/forwards.rs +++ b/src/ui/forwards.rs @@ -1,13 +1,3 @@ -//! Pane-contextual SSH loopback forward controls. -//! -//! Settings owns persistent preferences; this module owns the live forwarding -//! dashboard that only makes sense beside a concrete SSH pane. -//! -//! The dashboard is a **band in the detail panel's Info tab**, not a popover over -//! the terminal: a pane's forwards are one of its facts, so they belong beside its -//! cwd, processes and ports rather than in a floating panel of their own. The -//! rendering helpers here are called from `right_panel`'s Info body. - use gpui::{AnyElement, Context, Div, Entity, FontWeight, Stateful, div, prelude::*, px}; use gpui_component::button::{Button, ButtonVariants as _}; use gpui_component::input::Input; @@ -18,11 +8,6 @@ use crate::terminal::view::TerminalView; use crate::ui::app::{CONTENT_INSET, Tty7App}; impl Tty7App { - /// The in-pane native-SSH notice (PRD FR-E4): a dead pane shows a - /// bottom-centered "Disconnected — ⌘⇧R to reconnect" bar. Live/connecting - /// panes show nothing here — the tab status dot already carries the phase and - /// the daemon prints connect progress/failures into the buffer. Returns - /// `None` for a non-native or still-alive pane. pub(crate) fn render_ssh_status_strip( &self, leaf: &Entity<TerminalView>, @@ -42,11 +27,6 @@ impl Tty7App { let theme = cx.theme(); - // The failure reason is already printed into the terminal buffer - // (top-left, in red) by the daemon, so a top-left overlay would sit right - // on top of it. Dock the (actionable) reconnect notice at the - // bottom-center — clear of the output, a familiar "connection lost, - // reconnect" spot. let bar = h_flex() .occlude() .items_center() @@ -93,9 +73,6 @@ impl Tty7App { ) } - /// The in-pane "confirm close of a live SSH session" sheet (PRD FR-E3), - /// centered over the terminal. Enter/Close closes; Esc/Keep cancels. Returns - /// `None` when no confirmation is pending. pub(crate) fn render_ssh_close_confirm_overlay( &self, cx: &mut Context<Self>, @@ -157,14 +134,6 @@ impl Tty7App { ) } - /// The Info tab's **Forwards** band: what this pane routes across its - /// connection, sitting under Ports, which says what it listens on locally. - /// `None` for anything but a connected native-SSH pane — the band doesn't - /// exist rather than showing an empty section on every local shell. - /// - /// The rows are the daemon's list, re-fetched on the Info tab's own poll (see - /// `right_panel::sync_procs`), so a forward that dies out from under us turns - /// red here without anyone clicking anything. pub(crate) fn forwards_section( &self, pane_id: Option<u64>, @@ -172,8 +141,6 @@ impl Tty7App { ) -> Option<AnyElement> { let pane_id = pane_id?; let open = self.loopback_panel.form_pane_id == Some(pane_id); - // The `+` toggles the add form open. It's the band's only control, so it - // takes the header's trailing slot rather than a row of its own. let add = crate::ui::tab_strip::chrome_tile( Button::new(("ssh-forward-add-toggle", pane_id)) .icon(Icon::empty().path("icons/plus.svg").size(px(13.))), @@ -199,8 +166,6 @@ impl Tty7App { .collect(); let mono = cx.theme().mono_font_family.clone(); - // Rows inset themselves rather than the list, so the hover capsule bleeds - // into the same 12px gutter the Changes rows use. let mut list = v_flex().px(px(CONTENT_INSET - 4.)).py(px(2.)).gap(px(1.)); for forward in &managed { list = list.child(self.forward_row(forward, &mono, cx)); @@ -209,8 +174,6 @@ impl Tty7App { Some( v_flex() .child(self.panel_subtitle("Forwards", true, Some(add), cx)) - // The empty line is suppressed while the form is open: the form - // *is* the answer to "nothing here yet". .when(managed.is_empty() && !open, |this| { this.child( div() @@ -227,11 +190,6 @@ impl Tty7App { ) } - /// One forward, in the Info list's language: a mono kind letter, the bound - /// port as the same chip a listening port gets, and the destination trailing - /// it. Click to load it into the form (edit = re-establish); the `×` revealed - /// on hover tears it down. A description, where one was typed, takes a second - /// muted line — the only thing on the row that isn't derivable from the rule. fn forward_row( &self, forward: &ManagedForward, @@ -247,16 +205,11 @@ impl Tty7App { SshForwardKind::Dynamic => "D", }; let errored = matches!(forward.status, ForwardStatus::Error(_)); - // A bind host worth naming is one that isn't the loopback default — - // `0.0.0.0` means "reachable from the network", which the row must not - // hide behind a bare port number. let bind = if matches!(forward.bind_host.as_str(), "127.0.0.1" | "localhost" | "") { forward.bind_port.to_string() } else { format!("{}:{}", forward.bind_host, forward.bind_port) }; - // The tail carries the error where there is one: an error is what you - // need to read, and the destination is still on the row you clicked from. let tail = match &forward.status { ForwardStatus::Error(msg) => msg.clone(), ForwardStatus::Listening => match forward.kind { @@ -324,8 +277,6 @@ impl Tty7App { }), ) .child( - // Revealed on row hover — the same progressive disclosure the - // sidebar's rows use, so a list of forwards stays a list. div() .flex_shrink_0() .opacity(0.) @@ -352,14 +303,9 @@ impl Tty7App { ) } - /// The add/edit form, inline under the band. The 460px three-column layout the - /// old popover used doesn't survive a 260px column, so the fields stack: kind, - /// then one line each for bind and target, each `host : port`. fn forward_form(&self, pane_id: u64, cx: &mut Context<Self>) -> Div { let theme = cx.theme(); let muted = theme.muted_foreground; - // The form is inside the right panel, i.e. on the sunk rail — not on the - // settings sheet the segmented control otherwise assumes. let sf = cx.global::<crate::ui::presets::Surfaces>().sidebar; let kind = self.loopback_panel.mf_kind; let editing = self.loopback_panel.mf_editing.is_some(); @@ -368,11 +314,8 @@ impl Tty7App { SshForwardKind::Remote => 1, SshForwardKind::Dynamic => 2, }; - // Dynamic (SOCKS) forwards have no fixed target — grey the target line. let needs_target = kind != SshForwardKind::Dynamic; - // `host : port` on one line, the port sized to four digits and the host - // taking what's left. let pair = |label: &'static str, host: &Entity<gpui_component::input::InputState>, port: &Entity<gpui_component::input::InputState>| { diff --git a/src/ui/hints.rs b/src/ui/hints.rs index ec7b1326..5d14915e 100644 --- a/src/ui/hints.rs +++ b/src/ui/hints.rs @@ -1,42 +1,14 @@ -//! Hold-the-modifier tab-shortcut badges. -//! -//! Hold the bare `secondary` modifier (⌘ on macOS, Ctrl on Windows/Linux) for -//! a beat and every tab chip shows its switch digit (1…9 — the held modifier -//! itself is implied). -//! Releasing the modifier, adding another modifier, pressing any real key -//! (a chord like ⌘C), or the window changing active status all hide them -//! immediately — the chord dismissal lives in the keystroke interceptor -//! registered in `Tty7App::with_session` (it fires even for keys the terminal -//! consumes), and the activation dismissal in the observer beside it (a -//! window that deactivates mid-hold never receives the release). -//! -//! The trigger is a *hold*, not a chord: ⌘+Tab is reserved by macOS for the -//! system app switcher and never reaches the app. - use gpui::{Context, ModifiersChangedEvent, Window}; use crate::ui::app::Tty7App; -/// Hold this long before the badges show. Practiced chords land their key -/// within ~200ms of the modifier, so ⌘C never even flashes (the interceptor -/// is the backstop for slower chords), while a deliberate pause to look at -/// the tabs still reads as instant — the "immediate response" perception -/// threshold sits around 100–200ms. const BADGE_DELAY_MS: u64 = 200; -/// The badge label for tab `index`: just the digit ("1"…"9"). -/// The modifier is redundant — it's the key the user is holding right now — -/// and a bare digit fits the exact footprint of the close button the badge -/// replaces, so revealing it can't change the chip's width (no strip jitter -/// when an ellipsized label would otherwise reflow). Only tabs 0..9 have a -/// switch shortcut; callers gate on `index < 9`. pub(crate) fn tab_badge_label(index: usize) -> String { (index + 1).to_string() } impl Tty7App { - /// Track the bare-secondary hold that drives the badges: shown while - /// exactly "secondary held alone", hidden on any other modifier state. pub(crate) fn on_modifiers_changed( &mut self, ev: &ModifiersChangedEvent, @@ -46,9 +18,6 @@ impl Tty7App { let m = &ev.modifiers; self.set_link_modifier(m.secondary(), cx); - // Mirror `on_key_down`'s chord test: reject the other platform-ish key - // (⌃ on macOS, Win/Super elsewhere), Alt, and Shift, so only the bare - // secondary hold shows the badges. let extra_platform = if cfg!(target_os = "macos") { m.control } else { @@ -56,16 +25,12 @@ impl Tty7App { }; let bare_secondary = m.secondary() && !m.alt && !m.shift && !extra_platform; - // Every transition invalidates a previously scheduled reveal. self.mod_hint_gen = self.mod_hint_gen.wrapping_add(1); if !bare_secondary { self.dismiss_mod_hint(cx); return; } - // Bare secondary went down: schedule the reveal. The timer re-checks - // the generation so a release, added modifier, or chord keypress in - // the meantime cancels it. The task dies with the app (update → Err). let generation = self.mod_hint_gen; cx.spawn(async move |this, cx| { smol::Timer::after(std::time::Duration::from_millis(BADGE_DELAY_MS)).await; @@ -79,13 +44,6 @@ impl Tty7App { .detach(); } - /// Push the secondary-modifier state (⌘ on macOS, Ctrl elsewhere — the - /// same key that opens a link on click) down to every pane's link tracking. - /// Every tab, not just the active one: `on_modifiers_changed` only fires on - /// the frontmost window state, so a background tab that saw "⌘ down" but - /// never the matching release would keep a stale `true` — and a stale - /// `true` makes a plain, unmodified click open links (see - /// `TerminalView::link_modifier_down`). pub(crate) fn set_link_modifier(&mut self, down: bool, cx: &mut Context<Self>) { for tab in &self.tabs { for leaf in tab.pane.terminals() { @@ -96,13 +54,6 @@ impl Tty7App { } } - /// Hide the badges and invalidate any pending reveal. Called on every real - /// keypress (the interceptor in `Tty7App::with_session`) so a chord like ⌘C never - /// shows them, and on every window-activation flip (the observer next to - /// it) because deactivating mid-hold — ⌘-Tab, Spotlight, a click into - /// another app — sends the modifier release to whatever app is key by - /// then, so this window would otherwise show the badges forever. - /// Re-arming always requires releasing and holding ⌘ afresh. pub(crate) fn dismiss_mod_hint(&mut self, cx: &mut Context<Self>) { self.mod_hint_gen = self.mod_hint_gen.wrapping_add(1); if self.mod_hint_badges { @@ -116,9 +67,6 @@ impl Tty7App { mod tests { use super::*; - /// Digit-only on every platform: the held modifier is implied, and a - /// single digit is what keeps the badge inside the close button's exact - /// footprint (the no-jitter guarantee). #[test] fn tab_badge_label_is_the_bare_digit() { assert_eq!(tab_badge_label(0), "1"); @@ -126,10 +74,6 @@ mod tests { } } -/// gpui-harness tests: a real (headless) App + Window around a `Tty7App` -/// restored to the zero-tab home page — no terminal panes, so no daemon — -/// with the modifiers listener, the reveal timer, and the window-activation -/// wiring running exactly as in production. #[cfg(test)] mod gpui_tests { use crate::core::config::Config; @@ -138,25 +82,14 @@ mod gpui_tests { use gpui::{Modifiers, TestAppContext, VisualTestContext, WindowHandle}; fn harness(cx: &mut TestAppContext) -> (WindowHandle<Tty7App>, VisualTestContext) { - // The badge reveal is a real `smol::Timer` on smol's reactor thread — - // outside the deterministic executor — so waiting on it parks the - // test thread, exactly what `allow_parking` exists for. cx.executor().allow_parking(); cx.update(|cx| { - // Same globals `main` installs: the component theme and the - // user config. gpui_component::init(cx); cx.set_global(Config::default()); }); - // Inject the zero-tab session (the persisted home-page state) so the - // app builds without spawning a terminal — and without reading the - // on-disk view store. let window = cx.add_window(|window, cx| { Tty7App::with_session(None, Some(Session::default()), window, cx) }); - // `add_window` alone doesn't make this the platform's active window, - // and `deactivate_window` below is a no-op on a non-active one — so - // activate it for real, like the OS does when the app opens. window .update(cx, |_, window, _| window.activate_window()) .unwrap(); @@ -171,8 +104,6 @@ mod gpui_tests { .expect("the app window stays open") } - /// The reveal timer is real time (~200ms), so poll — bounded — until the - /// badges land. fn wait_for_badges(window: &WindowHandle<Tty7App>, cx: &mut TestAppContext) { let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); loop { @@ -188,19 +119,12 @@ mod gpui_tests { } } - /// Regression: ⌘-Tab, Spotlight, or a click into another app steals key - /// status mid-hold, so the ⌘ release lands in the other app and this - /// window never sees the `ModifiersChanged`. The activation flip is the - /// only signal left — it must dismiss the badges, or they stick until - /// some later keypress. #[gpui::test] fn deactivation_dismisses_visible_badges(cx: &mut TestAppContext) { let (window, mut vcx) = harness(cx); vcx.simulate_modifiers_change(Modifiers::secondary_key()); wait_for_badges(&window, cx); - // The window deactivates with the modifier still down; its eventual - // release is delivered to whatever app is key by then, not to us. vcx.deactivate_window(); assert!( @@ -209,16 +133,12 @@ mod gpui_tests { ); } - /// Same steal, faster: the modifier goes down and the window deactivates - /// within the reveal delay. The pending timer must not pop the badges up - /// in a window the user has already left. #[gpui::test] fn deactivation_cancels_a_pending_reveal(cx: &mut TestAppContext) { let (window, mut vcx) = harness(cx); vcx.simulate_modifiers_change(Modifiers::secondary_key()); vcx.deactivate_window(); - // Give the now-stale reveal timer ample real time to fire. std::thread::sleep(std::time::Duration::from_millis(400)); cx.background_executor.run_until_parked(); diff --git a/src/ui/home.rs b/src/ui/home.rs index 6c961e7f..d946905a 100644 --- a/src/ui/home.rs +++ b/src/ui/home.rs @@ -1,13 +1,3 @@ -//! The home page: what the window shows when zero tabs are open. -//! -//! Zero tabs is a legitimate state, not an error — closing the last tab lands -//! here (and quitting from here restores here). The body renders the tty7 -//! logotype drawn in half-block characters plus a keyboard-shortcut watermark -//! in the VS Code empty-workspace tradition. The logo uses the terminal's own -//! font and theme colors, so it re-skins with everything else; the shortcuts -//! resolve through the live keymap (`effective_key`), so a user remap shows up -//! here automatically. Enter, a click, or ⌘T spawns a fresh terminal. - use std::time::Duration; use gpui::{ @@ -21,9 +11,6 @@ use gpui_component::{ActiveTheme as _, IconName, Sizable as _, h_flex, v_flex}; use crate::core::session::{SessionPane, SessionTab}; use crate::ui::app::Tty7App; -/// The "tty7" logotype in half-block characters. Rendered line-by-line in the -/// terminal font with a 1.0 line height so the blocks stack seamlessly; the -/// trailing blinking cursor is appended to the last line at render time. const LOGO: [&str; 4] = [ " ▄▄▄ ▄▄▄ ▄ ▄ ▄▄▄▄", " █ █ █ █ █", @@ -31,34 +18,20 @@ const LOGO: [&str; 4] = [ " ▀▄ ▀▄ ▄▄▄▀ █ ", ]; -/// Logo cell size (px). Text size == line height so half-blocks join vertically. const LOGO_PX: f32 = 20.0; -/// The curated shortcuts taught on the home page: (action name, label). A -/// deliberate subset — the full table lives in Settings → Keybindings; this is -/// a watermark, not documentation. const HOME_SHORTCUTS: [(&str, &str); 7] = [ ("NewTab", "New Tab"), ("ReopenClosedTab", "Reopen Closed Tab"), - // The way to another workspace — or another machine — now that this page - // no longer lists them. Without this row an empty window says nothing about - // where the rest of the user's work went. ("ToggleSwitcher", "Switch Workspace"), ("TogglePalette", "Command Palette"), ("SplitRight", "Split Right"), ("SplitDown", "Split Down"), - // "Settings…" everywhere: the menu bar, the tray, the palette and this page - // used to offer four different names for the same destination. ("OpenSettings", "Settings…"), ]; -/// Longest label shown for a recently-closed tab before ellipsizing, matching -/// the tab strip's clamp spirit (a runaway title must not stretch the page). const CLOSED_LABEL_MAX: usize = 20; -/// Display label for a recently-closed tab: the user-set name if present, -/// otherwise the directory name of its first leaf's saved cwd. `None` when -/// neither is known (an unnamed tab that never reported a cwd). fn closed_tab_label(tab: &SessionTab) -> Option<String> { if let Some(name) = tab.name.as_ref() { let name = name.trim(); @@ -71,7 +44,6 @@ fn closed_tab_label(tab: &SessionTab) -> Option<String> { .map(|s| clamp_label(&s.to_string_lossy())) } -/// The first leaf (in layout order) that saved a cwd, depth-first. fn first_leaf_cwd(pane: &SessionPane) -> Option<&std::path::PathBuf> { match pane { SessionPane::Leaf { cwd, .. } => cwd.as_ref(), @@ -87,14 +59,8 @@ fn clamp_label(s: &str) -> String { } } -/// Longest workspace path shown before the front is elided. Named for the -/// picker this page used to hold; the switcher inherited both the constant and -/// the reason for it. pub(crate) const PICKER_PATH_MAX: usize = 34; -/// Now, in Unix seconds — the clock every "2 minutes ago" in the app is -/// measured against. A clock that cannot be read reads as the epoch, which -/// [`relative_time`] renders as "just now" rather than as a negative age. pub(crate) fn now_secs() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -102,12 +68,7 @@ pub(crate) fn now_secs() -> u64 { .unwrap_or(0) } -/// Human-readable age of a workspace's last activity. Coarse on purpose — the -/// user is picking between "the one from lunchtime" and "the one from last -/// week", not reading a log. pub(crate) fn relative_time(now: u64, then: u64) -> String { - // A future timestamp (clock change, edited file) reads as current rather - // than rendering a negative age. if then == 0 || then >= now { return "just now".to_string(); } @@ -123,9 +84,6 @@ pub(crate) fn relative_time(now: u64, then: u64) -> String { } } -/// A workspace's directory, shortened for the picker's dim subtitle: `$HOME` -/// collapses to `~`, and a still-too-long path keeps its tail (the part that -/// identifies the project) with an elided front. pub(crate) fn display_path(path: &std::path::Path) -> String { let text = path.to_string_lossy(); let shortened = match std::env::var("HOME") { @@ -144,12 +102,6 @@ pub(crate) fn display_path(path: &std::path::Path) -> String { format!("…{tail}") } -/// The display string ("⌘T") for an action's effective (default or -/// user-remapped) binding. Formatted by gpui-component's `Kbd` so platform -/// conventions stay consistent app-wide — but rendered as bare text, not the -/// `Kbd` element: its keycap chrome (filled box + border) reads far heavier -/// than this watermark page on dark themes. Multi-chord specs show their -/// first chord — enough for a hint. fn key_hint(action: &str, cx: &App) -> Option<String> { let spec = crate::ui::keymap::effective_key(action, cx)?; let first = spec.split_whitespace().next()?; @@ -158,14 +110,10 @@ fn key_hint(action: &str, cx: &App) -> Option<String> { } impl Tty7App { - /// Render the home page (called by `render` when `tabs` is empty). pub(crate) fn render_home(&self, cx: &mut Context<Self>) -> impl IntoElement + use<> { let theme = cx.theme(); let (muted, foreground, accent) = (theme.muted_foreground, theme.foreground, theme.primary); - // The logotype: quiet muted lines in the terminal's own font, with a - // blinking block cursor after the last line — the page's only motion - // and only accent color, as a terminal's resting state should be. let mut logo = v_flex() .font_family(self.font_family.clone()) .text_size(px(LOGO_PX)) @@ -179,14 +127,10 @@ impl Tty7App { div().text_color(accent).child("▌").with_animation( "home-cursor-blink", Animation::new(Duration::from_millis(1200)).repeat(), - // A terminal cursor snaps, it doesn't fade: hard on/off. |cursor, delta| cursor.opacity(if delta < 0.5 { 1.0 } else { 0.0 }), ), )); - // Shortcut watermark. The Reopen row doubles as the undo affordance: - // when something was just closed it names it and brightens, so an - // accidental ⌘W on the last tab reads its own rescue on arrival. let closed_hint = self.closed.last().and_then(closed_tab_label); let mut list = v_flex().gap_2().w(px(300.)).text_sm().text_color(muted); for (action, label) in HOME_SHORTCUTS { @@ -200,8 +144,6 @@ impl Tty7App { .justify_between() .when(emphasized, |row| row.text_color(foreground)) .child(label) - // Bare key glyphs in the terminal's own mono font: quiet, - // and visibly "of the terminal" rather than UI chrome. .children( key_hint(action, cx) .map(|keys| div().font_family(self.font_family.clone()).child(keys)), @@ -209,12 +151,6 @@ impl Tty7App { ); } - // Nothing in the middle any more. The picker and the "connect to - // another machine" wizard both used to live here, and both were - // answering the question `ui::switcher` now owns — from the title-bar - // chip, which is on screen in *every* window rather than only in an - // empty one. Keeping a second copy here would mean two surfaces to keep - // in step and two places to learn. let status = self.render_remote_status_strip(cx); v_flex() @@ -224,8 +160,6 @@ impl Tty7App { .items_center() .justify_center() .gap(px(48.)) - // The empty window's whole job is to hand out a shell: a bare click - // or Enter spawns one, no target to aim for. .on_mouse_down( MouseButton::Left, cx.listener(|this, _: &MouseDownEvent, window, cx| this.new_tab(window, cx)), @@ -238,8 +172,6 @@ impl Tty7App { .child(logo) .children(status) .child(list) - // Ease the page in rather than popping it — closing the last tab - // should feel like arriving somewhere, not like a glitch. .with_animation( "home-fade-in", Animation::new(Duration::from_millis(150)), @@ -247,15 +179,6 @@ impl Tty7App { ) } - // ----- connect to another machine -------------------------- - - /// The status strip a remote window wears when it is not attached. - /// - /// One sits at the top of the window in every state that is not - /// `Attached`, and this is why: a window that has lost its machine must keep - /// showing what it had and say so, rather than close or empty itself. A - /// local window and a healthy remote one say nothing — a permanent "you are - /// fine" banner is noise. fn render_remote_status_strip( &self, cx: &mut Context<Self>, @@ -263,10 +186,6 @@ impl Tty7App { let machine = self.remote_machine_label(cx); let status = self.remote_status(cx)?; let message = status.strip_message(&machine)?; - // A failure state is a resting state, so it always offers the next - // move. The button belongs here and not only on a window with tabs — - // this is the *empty* remote window, which is precisely the one with no - // other way out. let action = status.action_label(); let theme = cx.theme(); Some( @@ -290,7 +209,6 @@ impl Tty7App { .ghost() .small() .on_click(cx.listener(|this, _, _window, cx| this.remote_retry(cx))) - // The page spawns a terminal on any bare left click. .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()), ) }), @@ -335,7 +253,6 @@ mod tests { }; assert_eq!(closed_tab_label(&tab).as_deref(), Some("getty")); - // Whitespace-only names don't count as names. let tab = SessionTab { name: Some(" ".into()), tree_id: None, @@ -363,7 +280,6 @@ mod tests { #[test] fn closed_tab_label_is_none_when_nothing_is_known() { - // No name, no cwd — and "/" has no file name either. let unnamed = SessionTab { name: None, tree_id: None, @@ -409,15 +325,12 @@ mod tests { #[test] fn relative_time_never_renders_a_negative_age() { let now = 1_000_000u64; - // A never-stamped workspace, and one whose clock ran ahead (a system - // time change, or a hand-edited session file). assert_eq!(relative_time(now, 0), "just now"); assert_eq!(relative_time(now, now + 5_000), "just now"); } #[test] fn display_path_collapses_home_and_elides_from_the_front() { - // SAFETY: single-threaded test; HOME is restored right after. let saved = std::env::var("HOME").ok(); unsafe { std::env::set_var("HOME", "/Users/tester") }; @@ -425,10 +338,8 @@ mod tests { display_path(std::path::Path::new("/Users/tester/repo/tty7")), "~/repo/tty7" ); - // Outside home, the path is left alone. assert_eq!(display_path(std::path::Path::new("/opt/work")), "/opt/work"); - // A long path keeps its *tail* — the part that names the project. let long = display_path(std::path::Path::new( "/Users/tester/very/deeply/nested/projects/area/thing", )); @@ -444,9 +355,6 @@ mod tests { #[test] fn logo_rows_never_exceed_the_first_row_width() { - // The logotype renders as stacked left-aligned text lines; the first - // row spans the full logotype, so a longer row below it would poke out - // of the block and skew the art. let width = LOGO[0].chars().count(); for row in &LOGO { assert!(row.chars().count() <= width, "row {row:?} exceeds {width}"); diff --git a/src/ui/host_ops.rs b/src/ui/host_ops.rs index 8cb7395a..e27dcd70 100644 --- a/src/ui/host_ops.rs +++ b/src/ui/host_ops.rs @@ -1,46 +1,3 @@ -//! [`HostOps`] — the only place in the GUI that is allowed to touch a -//! [`Host`]. -//! -//! Every [`Host`] method blocks (`tty7_core::host` explains why), and a blocked -//! UI thread is a frozen window. On a local host that is a stutter nobody -//! notices; on a remote one it is a quarter-second freeze per directory -//! expanded. So the rule is absolute: **no view calls a host directly.** Views -//! hand the work to `HostOps`, which runs it on the background executor and -//! lands the result back on the UI thread. -//! -//! That makes this module a chokepoint on purpose. Being the single door is -//! what lets in-flight de-duplication, staleness checks, error notification and -//! the "don't ask a disconnected host" rule live in one place instead of being -//! re-derived, differently, at every call site. It is also what makes the CI -//! grep enforceable: `src/ui/` and `src/terminal/` may not contain `std::fs::`, -//! `Command::new("git")`, `.canonicalize()` or `.is_absolute()` anywhere but -//! here. -//! -//! # The shape every call takes -//! -//! ```ignore -//! HostOps::run( -//! host, -//! cx, -//! move |h| h.read_dir(&dir, Some(&root)), // background: blocking -//! move |view, listed, cx| { /* UI thread */ }, // land it -//! ); -//! ``` -//! -//! The closure gets `&dyn Host` rather than the `Arc`, so it cannot stash the -//! host somewhere that outlives the request. The landing closure runs on the UI -//! thread with the view borrowed mutably — and, because it may run arbitrarily -//! later, is where the staleness check belongs. -//! -//! # Four things a landing closure has to get right -//! -//! | Concern | What to do | -//! |---|---| -//! | **De-duplication** | Keep an [`InFlight`] per request kind. Render is called every frame and will re-ask for the same directory until an answer lands. | -//! | **Staleness** | Re-check the precondition before landing: the text the completion was for, the directory the listing was of. `InFlight::finish` answers this for keyed requests. | -//! | **Errors** | [`HostOps::notify_err`] unless the pre-existing behaviour was deliberately silent (the git probes are). Silence is how a failed save becomes a lost file. | -//! | **Disconnection** | `!host.is_connected()` means don't ask — keep showing the last good answer rather than replacing it with an error. | - use std::borrow::Borrow; use std::collections::{HashMap, HashSet}; use std::hash::Hash; @@ -48,31 +5,11 @@ use std::hash::Hash; use gpui::{App, Context, Window}; use gpui_component::WindowExt as _; -// The host vocabulary, re-exported so a view imports everything it needs from -// the one module it is allowed to import hosts from. `allow`, because in a -// binary crate a re-export nothing has consumed *yet* reads as unused. #[allow(unused_imports)] pub use tty7_core::host::{ Entry, Host, HostId, MTime, Meta, Output, SearchHit, SharedHost, WatchSub, }; -/// Where blocking [`Host`] calls actually run. -/// -/// **Not gpui's background executor.** On Linux that is a fixed pool of -/// `available_parallelism().max(2)` worker threads with no separate blocking -/// tier, so N stalled host calls on an N-core client occupy every worker there -/// is. Everything else that uses `background_executor` then queues behind them -/// — including the reconnect in `remote_workspace::launch_attempt`, which is -/// the one thing that would clear the stall. Expanding a subtree on a link that -/// has gone silent is enough: one call per directory, each parked for its -/// deadline (5s for a `ReadDir`, 30s for a `ReadFile`). macOS is far less -/// exposed, since libdispatch grows its global queues when their threads block, -/// which is why this does not show up in development. -/// -/// Elastic and its own: a thread per concurrent call, reused while warm and -/// retired after [`LINGER`], capped at [`MAX_THREADS`]. A `Host` call is -/// user-driven — a directory expanded, a file opened — not per-frame, so the -/// steady state is one or two threads. mod blocking { use std::collections::VecDeque; use std::sync::{Arc, Condvar, Mutex, OnceLock}; @@ -80,13 +17,8 @@ mod blocking { type Job = Box<dyn FnOnce() + Send + 'static>; - /// Ceiling on threads. Deliberately well above any core count: these are - /// parked on a socket rather than competing for CPU, and what has to fit is - /// the number of host calls in flight — one per expanded directory in a - /// burst, plus whatever the editor and the git probes are doing. const MAX_THREADS: usize = 64; - /// How long an idle worker waits for more work before retiring. const LINGER: Duration = Duration::from_secs(30); struct Inner { @@ -97,18 +29,10 @@ mod blocking { struct State { jobs: VecDeque<Job>, threads: usize, - /// Workers parked in `wait_timeout`, counted from before they park - /// until after they have re-acquired the lock on the way out. idle: usize, } impl State { - /// Whether a job just queued needs a thread spawned for it. - /// - /// Compares the backlog against the parked workers rather than asking - /// whether *any* worker is parked: `idle` still counts a worker that - /// has been handed a job but has not yet woken, and the job meant for - /// it is still in `jobs`, so counting both sides cancels the window out. fn wants_another_thread(&self) -> bool { self.jobs.len() > self.idle && self.threads < MAX_THREADS } @@ -128,8 +52,6 @@ mod blocking { }) } - /// Queue `job`. Never refuses: a dropped job is a `Host` call whose caller - /// waits forever, and at this cap the backlog is a better failure than that. pub(super) fn submit(job: impl FnOnce() + Send + 'static) { let inner = pool(); let mut st = inner.state.lock().unwrap_or_else(|e| e.into_inner()); @@ -143,10 +65,6 @@ mod blocking { { Ok(_) => return, Err(e) => { - // Out of threads: leave the job for whoever is already - // running. With nobody at all there is no one to run it, so - // run it here — blocking this caller, which is the lesser - // harm against never answering. st.threads -= 1; log::warn!("could not start a host-op thread: {e}"); if st.threads == 0 @@ -189,11 +107,6 @@ mod blocking { } } -/// Run `f` on the blocking pool and await its result. -/// -/// `None` means the job was dropped without running, which happens only when -/// the process is going down — the caller lands nothing rather than inventing -/// an answer. async fn off_thread<T, F>(f: F) -> Option<T> where T: Send + 'static, @@ -206,21 +119,9 @@ where rx.recv().await.ok() } -/// The GPUI-facing facade over [`Host`]. -/// -/// A unit struct rather than a value: there is no per-instance state, and -/// making it a namespace keeps every call site reading `HostOps::run(host, …)`, -/// which is what the CI grep and code review look for. pub struct HostOps; impl HostOps { - /// Run `f` against `host` on the background executor, then hand its result - /// to `land` on the UI thread. - /// - /// The task detaches: if the view is gone by the time the answer arrives, - /// `land` simply never runs. Nothing here can fail in a way the caller - /// needs to handle — a `Host` method's own failure is part of `T`, usually - /// as an `io::Result`. pub fn run<T, E, F, L>(host: SharedHost, cx: &mut Context<E>, f: F, land: L) where E: 'static, @@ -228,10 +129,6 @@ impl HostOps { F: FnOnce(&dyn Host) -> T + Send + 'static, L: FnOnce(&mut E, T, &mut Context<E>) + 'static, { - // Whoever is calling this is, by construction, on the UI thread — which - // makes this the natural place to teach the core's debug guard what the - // UI thread is. Idempotent, so paying for it per call is a store the - // optimizer sees through after the first. tty7_core::host::register_ui_thread(); cx.spawn(async move |this, cx| { let Some(out) = off_thread(move || f(&*host)).await else { @@ -242,21 +139,6 @@ impl HostOps { .detach(); } - /// [`HostOps::run`] for work whose result belongs to the *app*, not to the - /// view that asked for it — so it lands even if that view is gone. - /// - /// [`run`](HostOps::run) drops its landing closure when the entity dies, - /// which is right for anything that updates the view and wrong for anything - /// that releases a shared claim. The git probe is the latter: its in-flight - /// entry in the process-wide `GitStatusCache` is keyed by `(host, cwd)`, not - /// by pane, so a pane closed mid-probe that never released its claim would - /// wedge the branch line of every *other* pane in that directory — - /// permanently, since nothing else ever clears it. - /// - /// The landing closure gets `&mut App` and no view. When it needs one - /// anyway, it captures a `WeakEntity` and updates through it, which makes - /// "the shared part always happens, the view part happens if there is a - /// view" explicit instead of accidental. pub fn run_detached<T, E, F, L>(host: SharedHost, cx: &mut Context<E>, f: F, land: L) where E: 'static, @@ -274,8 +156,6 @@ impl HostOps { .detach(); } - /// [`HostOps::run`] for work that lands with a window — anything that - /// notifies, focuses, or opens a dialog on completion. pub fn run_in<T, E, F, L>(host: SharedHost, window: &Window, cx: &mut Context<E>, f: F, land: L) where E: 'static, @@ -293,14 +173,6 @@ impl HostOps { .detach(); } - /// Land an `io::Result`, notifying on failure and calling `land` on success. - /// - /// The overwhelmingly common shape for a *write*: create, rename, delete, - /// save. Failing silently is how a rejected save turns into a file the user - /// believes they wrote, so the error path is not optional here — a call - /// site that genuinely wants silence uses [`HostOps::run`] and says so. - /// - /// `context` prefixes the message ("Delete failed", "Could not rename"). pub fn run_or_notify<T, E, F, L>( host: SharedHost, window: &Window, @@ -327,27 +199,11 @@ impl HostOps { ); } - /// Show a host failure to the user. - /// - /// One funnel so the wording stays consistent and so there is a single place - /// to special-case a disconnected host later (a reconnect banner reads - /// better than twelve "Connection reset" toasts). pub fn notify_err(window: &mut Window, cx: &mut App, context: &str, err: &std::io::Error) { window.push_notification(format!("{context}: {err}"), cx); } } -/// Which requests are out, and which of those were superseded while they flew. -/// -/// Two problems, one structure. Render re-asks for the same missing directory -/// every frame until an answer lands, so without `in_flight` a slow listing -/// becomes a listing per frame. And a filesystem change arriving mid-request -/// would otherwise let the pre-change answer win the race, so `stale` makes -/// that answer discard itself and the next render ask again. -/// -/// Lifted out of the file tree, where it was `Loads`, because every consumer of -/// a remote host needs it: a round trip is long enough that *every* request kind -/// can be superseded before it lands. pub struct InFlight<K: Eq + Hash + Clone> { in_flight: HashSet<K>, stale: HashSet<K>, @@ -363,74 +219,42 @@ impl<K: Eq + Hash + Clone> Default for InFlight<K> { } impl<K: Eq + Hash + Clone> InFlight<K> { - /// `true` when the caller should spawn — nothing is out for `key` yet. pub fn begin(&mut self, key: K) -> bool { self.in_flight.insert(key) } - /// Record that something superseded whatever is in flight for `key`. A - /// no-op when nothing is. pub fn invalidate(&mut self, key: &K) { if self.in_flight.contains(key) { self.stale.insert(key.clone()); } } - /// Every outstanding answer is now stale — for when a whole cache goes. pub fn invalidate_all(&mut self) { self.stale.extend(self.in_flight.iter().cloned()); } - /// Retire the request for `key`: `true` when its answer is still current and - /// may be used, `false` when it must be thrown away. pub fn finish(&mut self, key: &K) -> bool { self.in_flight.remove(key); !self.stale.remove(key) } - /// Whether a request for `key` is outstanding. pub fn is_pending(&self, key: &K) -> bool { self.in_flight.contains(key) } - /// The keys with a request outstanding, for callers that have to reason - /// about the set rather than about one key. pub fn pending_keys(&self) -> impl Iterator<Item = &K> { self.in_flight.iter() } - /// How many requests are outstanding. pub fn len(&self) -> usize { self.in_flight.len() } - /// Whether nothing is outstanding. pub fn is_empty(&self) -> bool { self.in_flight.is_empty() } } -/// A per-host cache keyed by whatever the consumer needs. -/// -/// The reason this exists rather than a bare `HashMap<PathBuf, V>`: two -/// workspaces on two machines can hold the identical path (`/home/me/proj` is -/// on both), and a map keyed by path alone would serve one machine's listing -/// for the other's directory. Keying by host as well makes that impossible by -/// construction rather than by everyone remembering. -/// -/// # Why a map of maps rather than one map keyed by `(HostId, K)` -/// -/// Because a tuple key cannot be *borrowed*. `HashMap<(HostId, PathBuf), V>` -/// can only be probed with a real `(HostId, PathBuf)`, so every read would have -/// to clone the `PathBuf` just to ask a question — and these caches are read -/// from `render`: the sidebar asks for every tab's branch line and grouping key -/// on every frame, which turned into thousands of throwaway allocations a -/// second. Nesting keeps the inner map a plain `HashMap<K, V>`, so lookups go -/// through [`Borrow`] and cost nothing: `cache.get(host, path)` takes a -/// `&Path` against a `PathBuf` key. -/// -/// It also makes `clear_host` a single `remove` instead of a full-table -/// `retain`. pub struct ByHost<K: Eq + Hash, V> { map: HashMap<HostId, HashMap<K, V>>, } @@ -444,10 +268,6 @@ impl<K: Eq + Hash, V> Default for ByHost<K, V> { } impl<K: Eq + Hash, V> ByHost<K, V> { - /// Look up `key` on `host`. - /// - /// Generic over the borrowed form of the key, like [`HashMap::get`] itself, - /// so a `ByHost<PathBuf, _>` can be probed with a `&Path`. pub fn get<Q>(&self, host: HostId, key: &Q) -> Option<&V> where K: Borrow<Q>, @@ -456,12 +276,10 @@ impl<K: Eq + Hash, V> ByHost<K, V> { self.map.get(&host)?.get(key) } - /// Insert `value` for `key` on `host`. pub fn insert(&mut self, host: HostId, key: K, value: V) -> Option<V> { self.map.entry(host).or_default().insert(key, value) } - /// Remove `key` on `host`. pub fn remove<Q>(&mut self, host: HostId, key: &Q) -> Option<V> where K: Borrow<Q>, @@ -470,34 +288,24 @@ impl<K: Eq + Hash, V> ByHost<K, V> { self.map.get_mut(&host)?.remove(key) } - /// Every `(host, key)` currently cached. - /// - /// For the invalidations that reach the whole cache without emptying it: - /// marking each entry for a refresh needs the keys, and dropping them - /// instead is what makes a remote tree blink. pub fn keys(&self) -> impl Iterator<Item = (HostId, &K)> { self.map .iter() .flat_map(|(host, inner)| inner.keys().map(move |k| (*host, k))) } - /// Drop everything belonging to `host` — what a disconnect or a closed - /// workspace does, without disturbing the other machines' entries. pub fn clear_host(&mut self, host: HostId) { self.map.remove(&host); } - /// Drop everything. pub fn clear(&mut self) { self.map.clear(); } - /// How many entries are cached across all hosts. pub fn len(&self) -> usize { self.map.values().map(HashMap::len).sum() } - /// Whether nothing is cached. pub fn is_empty(&self) -> bool { self.map.values().all(HashMap::is_empty) } @@ -508,9 +316,6 @@ mod tests { use super::*; use std::path::{Path, PathBuf}; - /// The three-state dance the file tree's loads have always done: one - /// request out per key, an invalidation mid-flight makes the answer - /// discardable, and a clean finish keeps it. #[test] fn in_flight_tracks_supersession() { let mut loads: InFlight<PathBuf> = InFlight::default(); @@ -522,28 +327,21 @@ mod tests { assert!(loads.is_pending(&a)); assert_eq!(loads.len(), 1); - // Nothing superseded it: the answer is good. assert!(loads.finish(&a)); assert!(!loads.is_pending(&a)); assert!(loads.is_empty()); - // Superseded while in flight: the answer must be dropped, and the key - // is clean again afterwards so the next request is not poisoned. assert!(loads.begin(a.clone())); loads.invalidate(&a); assert!(!loads.finish(&a)); assert!(loads.begin(a.clone())); assert!(loads.finish(&a)); - // Invalidating something that is not out is a no-op, not a booby trap - // for the next request. loads.invalidate(&b); assert!(loads.begin(b.clone())); assert!(loads.finish(&b)); } - /// A whole-cache invalidation reaches every outstanding request, and only - /// the outstanding ones. #[test] fn invalidate_all_covers_everything_in_flight() { let mut loads: InFlight<u32> = InFlight::default(); @@ -554,14 +352,10 @@ mod tests { assert!(!loads.finish(&2)); assert!(loads.is_empty()); - // And a request started *after* the invalidation is not stale. loads.begin(3); assert!(loads.finish(&3)); } - /// The same path on two machines is two entries. This is the bug the type - /// exists to make unrepresentable: `/home/me/proj` is a real path on the - /// laptop *and* on the remote box. #[test] fn by_host_keys_by_machine_as_well_as_path() { let remote = HostId::from_connection_key("ssh-direct:me@box:22"); @@ -574,7 +368,6 @@ mod tests { assert_eq!(cache.get(remote, &p), Some(&"remote listing")); assert_eq!(cache.len(), 2); - // A disconnect drops one machine's entries and leaves the other's. cache.clear_host(remote); assert_eq!(cache.get(remote, &p), None); assert_eq!(cache.get(HostId::LOCAL, &p), Some(&"local listing")); @@ -583,27 +376,16 @@ mod tests { assert!(cache.is_empty()); } - /// A `ByHost<PathBuf, _>` is probed with a borrowed `&Path`, no allocation. - /// - /// Not a micro-optimisation: `GitStatusCache::status_for` and - /// `known_repo_for` are read from `render`, once per tab per frame, so a - /// key that had to be cloned to be asked about meant thousands of throwaway - /// `PathBuf`s a second. This pins the borrowed lookup so a future - /// "simplification" back to a `(HostId, K)` tuple key fails here. #[test] fn lookups_borrow_the_key_rather_than_cloning_it() { let mut cache: ByHost<PathBuf, u32> = ByHost::default(); cache.insert(HostId::LOCAL, PathBuf::from("/a/b"), 1); - // `&Path`, not `&PathBuf` — this is the line that would stop compiling. assert_eq!(cache.get(HostId::LOCAL, Path::new("/a/b")), Some(&1)); assert_eq!(cache.remove(HostId::LOCAL, Path::new("/a/b")), Some(1)); assert!(cache.is_empty()); } } -/// The one `HostOps` guarantee that cannot be checked by reading the code: that -/// a `run_detached` result still lands after the view that asked for it is -/// gone. #[cfg(test)] mod gpui_tests { use crate::ui::host_ops::{Host, HostOps}; @@ -611,18 +393,8 @@ mod gpui_tests { use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; - /// Stand-in for a pane: something to own the request and then stop existing. struct Pane; - /// `run_detached` exists for exactly this: the git probe releases its - /// in-flight claim in the landing closure, and that claim is keyed by - /// `(host, cwd)` rather than by pane. If closing a pane mid-probe could - /// swallow the landing, the claim would never be released and the branch - /// line of every *other* pane in that directory would stop updating — - /// permanently, since nothing else clears it. - /// - /// The contrast with `run` is the point of having both, so both are - /// asserted here: `run` is view-scoped and *should* drop its landing. #[gpui::test] fn a_detached_result_lands_after_its_view_is_dropped(cx: &mut TestAppContext) { cx.executor().allow_parking(); @@ -651,23 +423,13 @@ mod gpui_tests { ); }); - // The pane goes away before either answer can land — a tab closed - // while its probe was in flight. drop(pane); - // Pumped rather than parked once: a `Host` call runs on `HostOps`' own - // thread pool, not on gpui's executor, so `run_until_parked` has - // nothing to wait for until the answer has already crossed back. The - // deadline is generous because what is being timed is a closure that - // returns a constant. let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); while detached.load(Ordering::SeqCst) == 0 && std::time::Instant::now() < deadline { cx.background_executor.run_until_parked(); std::thread::sleep(std::time::Duration::from_millis(1)); } - // Both were submitted together, so the detached one landing means the - // view-scoped one has had its chance too. A few more turns, in case it - // is merely slower. for _ in 0..20 { cx.background_executor.run_until_parked(); std::thread::sleep(std::time::Duration::from_millis(1)); diff --git a/src/ui/host_registry.rs b/src/ui/host_registry.rs index c02f4e08..25a0d516 100644 --- a/src/ui/host_registry.rs +++ b/src/ui/host_registry.rs @@ -1,26 +1,3 @@ -//! [`HostRegistry`] — the process's map from [`HostId`] to the live -//! [`SharedHost`] it names. -//! -//! A workspace holds its own `Arc<dyn Host>`, so most code never needs this. -//! The registry exists for the places that *cannot* hold an `Arc`: the git -//! status cache's path tables, pane records, anything persisted or keyed. Those -//! store a [`HostId`] — small, `Copy`, `Hash` — and come here when they need the -//! machine behind it. -//! -//! One entry per machine, not per workspace. Two workspaces on the same remote -//! box share a connection, share an id and share the host object; that is the -//! same granularity the SSH connection itself is pooled at, and it is why the -//! git cache can serve both from one probe. -//! -//! # Lifetime -//! -//! [`HostId::LOCAL`] is always present — the registry constructs itself with it, -//! so there is no initialization order to get wrong and no window in which a -//! local lookup can fail. Remote hosts are registered when their workspace -//! connects and dropped when the last workspace using them closes; a lookup for -//! an id that has gone away returns `None`, which call sites read as "that -//! machine is no longer around" and use to drop their cached rows. - use std::collections::HashMap; use gpui::{App, Global}; @@ -28,7 +5,6 @@ use tty7_core::host::local::LocalHost; pub use tty7_core::host::{Host, HostId, SharedHost}; -/// Every host this process currently knows, by id. pub struct HostRegistry { hosts: HashMap<HostId, SharedHost>, } @@ -37,9 +13,6 @@ impl Global for HostRegistry {} impl Default for HostRegistry { fn default() -> Self { - // The local host is not registered by anyone — it is a precondition. A - // registry that could be missing it would force every `local()` call - // site to handle an impossible `None`. let mut hosts = HashMap::new(); hosts.insert(HostId::LOCAL, LocalHost::shared()); HostRegistry { hosts } @@ -47,22 +20,10 @@ impl Default for HostRegistry { } impl HostRegistry { - /// The host `id` names, or `None` when it is no longer registered. - /// - /// `None` is not an error: a remote workspace that closed takes its host - /// with it, and a cache entry still holding that id is simply stale. pub fn get(cx: &mut App, id: HostId) -> Option<SharedHost> { cx.default_global::<HostRegistry>().hosts.get(&id).cloned() } - /// [`get`](Self::get) for the read-only half of the frame — the render and - /// menu paths, which hold a `&App` and cannot create the global. - /// - /// A registry that does not exist yet answers for [`HostId::LOCAL`] anyway: - /// [`LocalHost::shared`] is a process singleton, so the host handed back is - /// the same object `default()` would have put in the map, gitignore cache - /// and all. Every other id answers `None` until something registers it, - /// which is the truth — nothing has connected to that machine. pub fn lookup(cx: &App, id: HostId) -> Option<SharedHost> { match cx.try_global::<HostRegistry>() { Some(reg) => reg.hosts.get(&id).cloned(), @@ -70,24 +31,15 @@ impl HostRegistry { } } - /// This machine. Always present, so this cannot fail. pub fn local(cx: &mut App) -> SharedHost { HostRegistry::get(cx, HostId::LOCAL).expect("the local host is always registered") } - /// Register `host` under its own id, replacing any previous host with that - /// id (a reconnect mints a new host object for the same machine). - /// - /// Returns the host that was there before, so a caller can tell a reconnect - /// from a first connection. pub fn insert(cx: &mut App, host: SharedHost) -> Option<SharedHost> { let id = host.id(); cx.default_global::<HostRegistry>().hosts.insert(id, host) } - /// Forget `id`. Refuses to forget the local host — nothing in the process - /// can function without it, and a stray `remove` would turn every later - /// local lookup into a panic far from the cause. pub fn remove(cx: &mut App, id: HostId) -> Option<SharedHost> { if id.is_local() { return None; @@ -95,7 +47,6 @@ impl HostRegistry { cx.default_global::<HostRegistry>().hosts.remove(&id) } - /// Every registered id. Diagnostics and teardown; not a hot path. pub fn ids(cx: &mut App) -> Vec<HostId> { let mut ids: Vec<HostId> = cx .default_global::<HostRegistry>() @@ -107,7 +58,6 @@ impl HostRegistry { ids } - /// How many hosts are registered, local included. pub fn len(cx: &mut App) -> usize { cx.default_global::<HostRegistry>().hosts.len() } @@ -117,8 +67,6 @@ impl HostRegistry { mod tests { use super::*; - /// The registry is usable the instant it is touched: no init call, no - /// ordering constraint, and the local host already in it. #[test] fn a_default_registry_already_holds_the_local_host() { let reg = HostRegistry::default(); @@ -127,13 +75,9 @@ mod tests { assert_eq!(reg.hosts.len(), 1); } - /// Registering, replacing and removing a remote host — and the guarantee - /// that `remove` cannot take the local one out from under the process. #[test] fn remote_hosts_come_and_go_but_local_stays() { let mut reg = HostRegistry::default(); - // A stand-in for a remote host: `LocalHost` under a remote id is enough - // to exercise the map, and does not need a server to talk to. let id = HostId::from_connection_key("ssh-direct:me@box:22"); reg.hosts.insert(id, LocalHost::new()); assert_eq!(reg.hosts.len(), 2); diff --git a/src/ui/keymap.rs b/src/ui/keymap.rs index 03ac1478..09314b8b 100644 --- a/src/ui/keymap.rs +++ b/src/ui/keymap.rs @@ -1,8 +1,3 @@ -//! Keymap and global-action wiring: the default action→keystroke table, merging -//! the user's config overrides on top, and the one-time install of keybindings, -//! the menu bar, and global actions at startup. Kept separate from the window -//! shell so `app.rs` stays focused on tab/pane orchestration. - use gpui::{App, Global, KeyBinding, Keystroke, NoAction}; use crate::core::actions::*; @@ -12,29 +7,14 @@ use crate::terminal::view::{ }; use crate::ui::theme::set_menus; -/// The set of keystrokes currently installed for app actions, remembered so a -/// later [`rebind`] can neutralize them with `NoAction` bindings instead of -/// clearing the whole keymap (which would also wipe gpui-component's own input / -/// list / menu bindings). Each entry carries the key context its binding was -/// installed in (see [`action_context`]) so the `NoAction` can be scoped the -/// same way. Stored as a GPUI global. #[derive(Default)] struct BoundKeystrokes(Vec<(String, Option<&'static str>)>); impl Global for BoundKeystrokes {} -/// Install the application menu bar, keybindings, and global actions. -/// Call once at startup with the app context. pub fn init(cx: &mut App) { let effective = effective_bindings(cx); let mut bindings = action_bindings(&effective); - // `+` arrives as `=`, so keep a fixed `secondary-+` alias for zoom-in - // alongside whatever IncreaseFontSize is bound to. bindings.push(KeyBinding::new("secondary-+", IncreaseFontSize, None)); - // Tab / Shift-Tab must reach the shell (completion, back-tab) — but - // gpui-component's `Root` binds them to focus navigation in the global "Root" - // context, which would otherwise swallow the key before it hits the terminal. - // We rebind them in the deeper "Terminal" context so GPUI's depth-ordered - // dispatch picks ours first; the handlers in `terminal::view` write to the PTY. bindings.push(KeyBinding::new("tab", SendTab, Some("Terminal"))); bindings.push(KeyBinding::new("shift-tab", SendBackTab, Some("Terminal"))); cx.bind_keys(bindings); @@ -44,12 +24,6 @@ pub fn init(cx: &mut App) { set_menus(cx); } -/// Re-apply keybindings after the effective table changes (an edit in Settings, -/// a preset switch). Appends a `NoAction` binding for every previously-installed -/// keystroke — which suppresses the earlier binding of that keystroke in GPUI's -/// depth-then-index dispatch — then re-adds the current effective bindings, which -/// win because they're added last. The keymap only grows (bounded per process), -/// but we never `clear()` it, so gpui-component's bindings survive untouched. pub fn rebind(cx: &mut App) { let previous = cx .try_global::<BoundKeystrokes>() @@ -57,15 +31,6 @@ pub fn rebind(cx: &mut App) { .unwrap_or_default(); let effective = effective_bindings(cx); - // Neutralize each old keystroke with a `NoAction` in the same context its - // binding was installed in. The scope matters: GPUI's `binding_enabled` - // gives a *context-less* binding the maximum match depth (`contexts.len()`), - // so a global `NoAction` outranks every context-scoped binding on that - // chord — and a matched `NoAction` discards the rest of the matches — which - // would kill bindings we never installed (gpui-component's own Input-scoped - // `shift-enter` while the search field has focus). Scoped the same way, the - // `NoAction` still suppresses our own old binding: that one sits at the - // same depth with a lower index, and index breaks the tie. let mut bindings: Vec<KeyBinding> = previous .iter() .filter(|(k, _)| keystroke_is_valid(k)) @@ -75,42 +40,13 @@ pub fn rebind(cx: &mut App) { cx.bind_keys(bindings); cx.set_global(BoundKeystrokes(bound_keystrokes(&effective))); - // Rebuild the menu bar so its macOS key equivalents track the new keymap. - // AppKit dispatches a menu shortcut (e.g. ⌘W → Close) *before* GPUI's keymap, - // so a stale equivalent would fire the old action even though we suppressed - // its keybinding with `NoAction`. `set_menus` re-resolves each item's - // equivalent from the current keymap (via `bindings_for_action`, which skips - // the suppressed bindings), so a rebound action loses its old ⌘-shortcut and - // gains the new one. set_menus(cx); } -/// `InsertNewline`'s primary default: Windows Terminal's chord for a soft -/// newline in the prompt editor, and the one the table in [`default_bindings`] -/// carries. const INSERT_NEWLINE_DEFAULT: &str = "shift-enter"; -/// `InsertNewline`'s second default: iTerm2's chord for the same gesture. Both -/// have inserted a newline since the multi-line editor landed, and both must -/// keep doing so — but a binding spec can't express alternatives (whitespace in -/// a spec means a *sequence*, `ctrl-b n`-style), and the effective table holds -/// exactly one keystroke per action. So this chord is installed alongside the -/// table's, and only while `InsertNewline` still sits on its primary default: -/// rebinding the action in Settings or `config.json` retires both old chords, -/// which is what a user who moves the binding expects. -/// -/// Only these two. GPUI matches a binding on exact modifier equality -/// (`Keystroke::should_match`), so Shift+Alt+Enter — which the old hardcoded -/// `(m.shift || m.alt)` test happened to catch — maps to no action and submits -/// like any other Enter. That matches the reference: Warp's key table is a -/// `(ctrl, alt, shift, key)` tuple whose newline arms are exactly Shift+Enter, -/// Alt+Enter and Ctrl+J, so its `(false, true, true, "enter")` falls through -/// too, and its GUI compares whole keystrokes for equality just as gpui does. const INSERT_NEWLINE_ALT_DEFAULT: &str = "alt-enter"; -/// The extra keystroke an effective table installs beyond its one-per-action -/// rows — today only [`INSERT_NEWLINE_ALT_DEFAULT`]. `None` once the action has -/// been rebound off its default. fn extra_keystrokes(effective: &[(String, String)]) -> Vec<(&'static str, &'static str)> { let on_default = effective .iter() @@ -122,12 +58,6 @@ fn extra_keystrokes(effective: &[(String, String)]) -> Vec<(&'static str, &'stat } } -/// The extra `(action, keystroke)` pairs the current effective table installs -/// beyond its one-row-per-action rows. [`effective_bindings`] stays one row per -/// action because Settings renders from it, so anything that has to reason -/// about which chords are actually *live* — conflict detection when the user -/// records a shortcut — must consult this alongside it, or it would miss the -/// extra chord and silently drop it. pub(crate) fn extra_bindings(cx: &App) -> Vec<(String, String)> { extra_keystrokes(&effective_bindings(cx)) .into_iter() @@ -135,8 +65,6 @@ pub(crate) fn extra_bindings(cx: &App) -> Vec<(String, String)> { .collect() } -/// Build the `KeyBinding`s for an effective table, skipping unbound rows (empty -/// keystroke) and any that fail validation. fn action_bindings(effective: &[(String, String)]) -> Vec<KeyBinding> { let mut bindings = Vec::new(); for (action, key) in extra_keystrokes(effective) { @@ -146,7 +74,7 @@ fn action_bindings(effective: &[(String, String)]) -> Vec<KeyBinding> { } for (action, key) in effective { if key.is_empty() { - continue; // an action with no assigned key + continue; } if !keystroke_is_valid(key) { log::warn!("ignoring keybinding for '{action}': invalid keystroke '{key}'"); @@ -160,9 +88,6 @@ fn action_bindings(effective: &[(String, String)]) -> Vec<KeyBinding> { bindings } -/// The valid, non-empty keystrokes an effective table actually installs, each -/// paired with the key context it is installed in — the list [`rebind`] -/// remembers so it can suppress them, in that same context, on the next change. fn bound_keystrokes(effective: &[(String, String)]) -> Vec<(String, Option<&'static str>)> { let extras = extra_keystrokes(effective); effective @@ -174,41 +99,23 @@ fn bound_keystrokes(effective: &[(String, String)]) -> Vec<(String, Option<&'sta .collect() } -/// The built-in action → default-keystroke table. The single source of truth for -/// the default keymap, the names the user can override, and the rows the Settings -/// list renders. An empty keystroke means "no default key" (bind one in Settings -/// or config); it's shown as "—" and never installed. pub(crate) fn default_bindings() -> Vec<(&'static str, &'static str)> { - // `secondary-` is gpui's cross-platform modifier: ⌘ on macOS, Ctrl elsewhere - // (see `Keystroke::parse`). Using it keeps the same muscle memory on Windows - // and Linux without binding to the Win/Super key, which the OS reserves. vec![ ("NewTab", "secondary-t"), ("NewWorkspace", "secondary-shift-n"), ("CloseActiveTab", "secondary-w"), - // Tab operations promoted out of the tab context menu (see - // `core::actions`). No default chords: the menu bar, the palette and the - // right-click menu all reach them, and none is frequent enough to earn a - // reflexive shortcut — but they're bindable here like anything else. ("RenameTab", ""), ("NewWorktreeTab", ""), ("CloseOtherTabs", ""), ("CloseTabsToTheRight", ""), ("CopyWorkingDirectory", ""), ("MarkTabUnread", ""), - // Fork: the bare action opens a new tab; the four directional ones are - // the pane right-click menu's placement pick, bindable here for anyone - // who wants a chord straight to one direction. ("ForkAgentSession", ""), ("ForkAgentSessionRight", ""), ("ForkAgentSessionLeft", ""), ("ForkAgentSessionDown", ""), ("ForkAgentSessionUp", ""), ("CopyAgentSessionId", ""), - // No default chord on purpose: this is the one action that kills running - // sessions, and it must not sit one slip away from ⌘W. Reachable from - // the Shell menu and the palette; bindable in Settings for anyone who - // wants it. ("StopWorkspace", ""), ("DeleteWorkspace", ""), ("RenameWorkspace", ""), @@ -217,21 +124,16 @@ pub(crate) fn default_bindings() -> Vec<(&'static str, &'static str)> { ("SplitDown", "secondary-shift-d"), ("FocusNextPane", "secondary-]"), ("FocusPrevPane", "secondary-["), - // Directional pane focus: ⌘⌥ / Ctrl+Alt + arrow. ("FocusPaneLeft", "secondary-alt-left"), ("FocusPaneRight", "secondary-alt-right"), ("FocusPaneUp", "secondary-alt-up"), ("FocusPaneDown", "secondary-alt-down"), - // Resize / swap have no default chord — they're reachable from the - // command palette and bindable in Settings (and the tmux preset). ("ResizePaneLeft", ""), ("ResizePaneRight", ""), ("ResizePaneUp", ""), ("ResizePaneDown", ""), ("SwapPaneNext", ""), ("SwapPanePrev", ""), - // Relative tab nav. Ctrl+Tab is free of an OS/terminal meaning on the - // platforms we ship; rebind if a given setup disagrees. ("NextTab", "ctrl-tab"), ("PrevTab", "ctrl-shift-tab"), ("ActivateTab1", "secondary-1"), @@ -243,10 +145,6 @@ pub(crate) fn default_bindings() -> Vec<(&'static str, &'static str)> { ("ActivateTab7", "secondary-7"), ("ActivateTab8", "secondary-8"), ("ActivateTab9", "secondary-9"), - // Workspace slots in the Window menu's order. No default chord: ⌘1–9 is - // already the tab row's, and a workspace switch is a rarer move than a - // tab switch. Clickable in the Window menu and the title-bar chip, and - // bindable here for anyone who wants the chord. ("SelectWorkspace1", ""), ("SelectWorkspace2", ""), ("SelectWorkspace3", ""), @@ -261,18 +159,9 @@ pub(crate) fn default_bindings() -> Vec<(&'static str, &'static str)> { ("ResetFontSize", "secondary-0"), ("TogglePalette", "secondary-p"), ("ReopenClosedTab", "secondary-shift-t"), - // ⌘⏎ toggles window fullscreen and ⌘⇧⏎ zooms the focused pane, matching - // Ghostty's and iTerm2's defaults — pane zoom deliberately does NOT own - // the bare ⌘⏎, which users expect to affect the whole window. ("ToggleMaximizePane", "secondary-shift-enter"), ("ToggleFullscreen", "secondary-enter"), - // No default chord (a layout toggle rarely wants a reflexive shortcut, and - // this steers clear of collisions) — reachable from the command palette - // and Settings → Window & Tabs, and bindable there like any other action. ("ToggleTabSidebar", ""), - // Collapse/expand the left rail, on the ⌘B every editor uses for it. - // Off macOS `secondary-b` is Ctrl+B, which is the tmux preset's default - // prefix — leave it unbound there rather than fight the prefix. ( "ToggleLeftPanel", if cfg!(target_os = "macos") { @@ -281,12 +170,7 @@ pub(crate) fn default_bindings() -> Vec<(&'static str, &'static str)> { "" }, ), - // The right detail panel, on the ⌘J every editor uses for a dock. ("ToggleRightPanel", "secondary-j"), - // Buffer search. ⌘F on macOS; elsewhere `secondary-f` (Ctrl+F) is - // readline's forward-char, so follow the GUI-terminal convention and open - // find on Ctrl+Shift+F, leaving Ctrl+F to the shell. Find-again is ⌘G/⌘⇧G - // on macOS and F3/Shift+F3 elsewhere (the Windows/Linux norm). ( "FindInTerminal", if cfg!(target_os = "macos") { @@ -311,17 +195,9 @@ pub(crate) fn default_bindings() -> Vec<(&'static str, &'static str)> { "shift-f3" }, ), - // Like Terminal.app / iTerm2 / Ghostty ⌘K: wipe the screen + scrollback. ("ClearScrollback", "secondary-k"), - // Soft newline in the prompt editor: author a multi-line command without - // submitting it. Shift+Enter is Windows Terminal's chord and the primary - // default; Alt+Enter (iTerm2's) is installed alongside it — see - // `INSERT_NEWLINE_ALT_DEFAULT` for why it can't live in this table. ("InsertNewline", INSERT_NEWLINE_DEFAULT), ("OpenSettings", "secondary-,"), - // Help → Keyboard Shortcuts, on the ⌘/ that editors and browsers use for - // "show me the shortcuts". Off macOS `secondary-/` is Ctrl+/, which some - // shells bind to undo, so leave it unbound there. ( "ShowKeyboardShortcuts", if cfg!(target_os = "macos") { @@ -330,16 +206,11 @@ pub(crate) fn default_bindings() -> Vec<(&'static str, &'static str)> { "" }, ), - // Menu-bar-only entries: real actions so the palette and Settings can see - // them, but nothing here wants a chord by default. ("About", ""), ("CheckForUpdates", ""), ("OpenDocumentation", ""), ("OpenDiscord", ""), ("ReportIssue", ""), - // macOS supplies these chords itself for a standard App/Window menu; we - // list them so they show up in Settings → Keybindings rather than looking - // like undocumented magic, but bind them only where they exist. ( "HideApp", if cfg!(target_os = "macos") { @@ -366,58 +237,37 @@ pub(crate) fn default_bindings() -> Vec<(&'static str, &'static str)> { }, ), ("ZoomWindow", ""), - // No default chord — reachable from the command palette ("SSH: Remote - // Files") and bindable in Settings like any other action. ("ToggleSftp", ""), - // No default chord either — the palette ("SSH: Port Forwarding") and the - // Info tab's own `+` are the primary ways in. ("ShowSshForwards", ""), - // The code panel (file tree + editor overlay), on VS Code's explorer - // chord. ⌘⇧E is free (no existing binding or preset uses it). ("ToggleCodePanel", "secondary-shift-e"), - // Save the editor's active file. ⌘S is free — the terminal has no save. ("EditorSave", "secondary-s"), - // No default chord — reachable from the command palette ("SSH: Manage - // Profiles…") and bindable in Settings. ("OpenSshProfiles", ""), - // Reconnect a dropped native-SSH pane (PRD FR-E4). ⌘⇧R is free (no - // existing binding uses it). ("RestartSshSession", "secondary-shift-r"), ("Quit", "secondary-q"), ] } -/// The full effective action → keystroke table: the built-in defaults, with the -/// active preset layered on top (tmux prefix sequences), then the user's config -/// overrides last. The single source of truth for installing the keymap and for -/// what the Settings list shows. Empty keystrokes (unbound actions) are kept. pub(crate) fn effective_bindings(cx: &App) -> Vec<(String, String)> { let cfg = cx.global::<Config>(); let mut effective: Vec<(String, String)> = default_bindings() .into_iter() .map(|(a, k)| (a.to_string(), k.to_string())) .collect(); - // Preset layer: remaps the actions it covers onto prefix-led sequences. for (action, key) in preset_bindings(&cfg.keybinding_preset, &cfg.prefix) { set_binding(&mut effective, &action, key); } - // User overrides win. Unknown action names (typos, stale keys) are ignored. for (action, key) in &cfg.keybindings { set_binding(&mut effective, action, key.clone()); } effective } -/// Update the keystroke of an existing action in the effective table. Unknown -/// action names are ignored so a bad preset/override entry can't inject a row. fn set_binding(effective: &mut [(String, String)], action: &str, key: String) { if let Some(slot) = effective.iter_mut().find(|(a, _)| a == action) { slot.1 = key; } } -/// The keybinding overlay a preset contributes: `(action, keystroke)` pairs with -/// the prefix already substituted in. The `default` preset contributes nothing. fn preset_bindings(preset: &str, prefix: &str) -> Vec<(String, String)> { match preset { "tmux" => tmux_preset(prefix), @@ -425,13 +275,7 @@ fn preset_bindings(preset: &str, prefix: &str) -> Vec<(String, String)> { } } -/// The tmux-style preset: pane/tab actions mapped onto `prefix key` sequences -/// (e.g. `ctrl-b c` → New Tab). GPUI plays the prefix through to the shell after -/// a 1s timeout if no sequence completes, so a bare prefix still reaches readline. fn tmux_preset(prefix: &str) -> Vec<(String, String)> { - // `p("c")` → "<prefix> c". The trailing key can be shifted punctuation - // (`%`, `"`, `{`, `}`): GPUI matches those via the typed key's `key_char`, - // so binding the literal glyph works without spelling out `shift-…`. let p = |key: &str| format!("{prefix} {key}"); [ ("NewTab", p("c")), @@ -468,9 +312,6 @@ fn tmux_preset(prefix: &str) -> Vec<(String, String)> { .collect() } -/// The effective keystroke for an action, from the merged table. `None` when the -/// action has no binding at all (unbound). Used to surface shortcut hints in the -/// UI (command palette, settings). pub(crate) fn effective_key(action: &str, cx: &App) -> Option<String> { effective_bindings(cx) .into_iter() @@ -479,13 +320,7 @@ pub(crate) fn effective_key(action: &str, cx: &App) -> Option<String> { .filter(|k| !k.is_empty()) } -/// Serialize a recorded keystroke into a config spec string (the inverse of -/// `Keystroke::parse`), normalizing the platform's primary modifier to the -/// portable `secondary` so a recorded shortcut stays cross-platform. Returns -/// `None` for a lone modifier press (nothing to bind yet). pub(crate) fn spec_from_keystroke(ks: &Keystroke) -> Option<String> { - // A modifier-only keystroke has one of these as its `key`; there's no real - // key to bind, so keep recording. if matches!( ks.key.as_str(), "shift" | "control" | "alt" | "platform" | "function" | "cmd" | "ctrl" @@ -530,21 +365,11 @@ pub(crate) fn spec_from_keystroke(ks: &Keystroke) -> Option<String> { Some(spec) } -/// Split a keybinding spec into its whitespace-separated chords, each rendered -/// as its own list of display tokens. A single chord ("secondary-t") yields one -/// group; a tmux-style sequence ("ctrl-b n") yields two, so the UI can draw them -/// as distinct keycap clusters (`⌃B` then `N`). pub(crate) fn key_chords(spec: &str) -> Vec<Vec<String>> { spec.split_whitespace().map(key_tokens).collect() } -/// Split one keybinding chord ("secondary-shift-d", "secondary--") into display -/// tokens, mapping modifiers to per-platform labels (mac glyphs vs. Windows/Linux -/// words). Modifiers always lead; whatever remains is the key itself — which may -/// be "-", so we can't simply split on '-'. pub(crate) fn key_tokens(spec: &str) -> Vec<String> { - // `secondary` is gpui's portable modifier (⌘ on mac, Ctrl elsewhere); `cmd` - // is the literal platform key (⌘ on mac, the Win/Super key elsewhere). #[cfg(target_os = "macos")] const MODS: [(&str, &str); 6] = [ ("secondary", "⌘"), @@ -568,8 +393,6 @@ pub(crate) fn key_tokens(spec: &str) -> Vec<String> { 'outer: loop { for (name, glyph) in MODS { let prefix = format!("{name}-"); - // Only consume a modifier if something non-empty follows it, so the - // trailing key (even "-") is always preserved as the final token. if let Some(stripped) = rest.strip_prefix(&prefix) { if !stripped.is_empty() { tokens.push(glyph.to_string()); @@ -584,8 +407,6 @@ pub(crate) fn key_tokens(spec: &str) -> Vec<String> { tokens } -/// Map a bare (non-modifier) key to its display glyph: word keys to symbols, -/// single letters uppercased, punctuation passed through. fn key_glyph(key: &str) -> String { match key { "enter" | "return" => "⏎".into(), @@ -597,14 +418,11 @@ fn key_glyph(key: &str) -> String { "down" => "↓".into(), "left" => "←".into(), "right" => "→".into(), - "-" => "−".into(), // typographic minus, not the separator hyphen + "-" => "−".into(), other => other.to_uppercase(), } } -/// True if every whitespace-separated chord in `s` parses as a gpui keystroke. -/// We pre-validate so `KeyBinding::new` (which panics on a parse error) is only -/// ever handed strings we know are good. fn keystroke_is_valid(s: &str) -> bool { let mut any = false; for token in s.split_whitespace() { @@ -616,15 +434,6 @@ fn keystroke_is_valid(s: &str) -> bool { any } -/// The key context an action's binding is installed in, or `None` for a global -/// one. The single source of truth for that decision: [`make_binding`] builds -/// the binding with it and [`bound_keystrokes`] records it, so the `NoAction` -/// [`rebind`] later uses to retire a chord lands in the same scope as the -/// binding it retires. -/// -/// Terminal-scoped means the handler lives on the terminal surface, so the -/// "Terminal" context keeps the chord inert on the settings / home pages -/// instead of binding a dead global chord there. fn action_context(action: &str) -> Option<&'static str> { match action { "FindInTerminal" | "FindNext" | "FindPrevious" | "ClearScrollback" | "InsertNewline" => { @@ -634,8 +443,6 @@ fn action_context(action: &str) -> Option<&'static str> { } } -/// Build a `KeyBinding` for a known action name + (already-validated) keystroke. -/// Returns `None` for an unrecognized action name. fn make_binding(action: &str, keystroke: &str) -> Option<KeyBinding> { Some(match action { "NewTab" => KeyBinding::new(keystroke, NewTab, None), @@ -701,15 +508,10 @@ fn make_binding(action: &str, keystroke: &str) -> Option<KeyBinding> { "ToggleTabSidebar" => KeyBinding::new(keystroke, ToggleTabSidebar, None), "ToggleLeftPanel" => KeyBinding::new(keystroke, ToggleLeftPanel, None), "ToggleRightPanel" => KeyBinding::new(keystroke, ToggleRightPanel, None), - // Right-panel tab jumps. No entry in the default table above — they ship - // unbound and exist so a user *can* bind them; the palette reaches them - // either way. "ShowRightPanelInfo" => KeyBinding::new(keystroke, ShowRightPanelInfo, None), "ShowRightPanelOutline" => KeyBinding::new(keystroke, ShowRightPanelOutline, None), "ShowRightPanelChanges" => KeyBinding::new(keystroke, ShowRightPanelChanges, None), "ShowRightPanelFiles" => KeyBinding::new(keystroke, ShowRightPanelFiles, None), - // Terminal-scoped — see `action_context`, which owns that decision so the - // context here and the one `rebind` neutralizes with can't drift apart. "FindInTerminal" => KeyBinding::new(keystroke, FindInTerminal, action_context(action)), "FindNext" => KeyBinding::new(keystroke, FindNext, action_context(action)), "FindPrevious" => KeyBinding::new(keystroke, FindPrevious, action_context(action)), @@ -742,8 +544,6 @@ fn make_binding(action: &str, keystroke: &str) -> Option<KeyBinding> { mod tests { use super::*; - // The `secondary` modifier renders differently per platform: ⌘ on macOS, - // "Ctrl" elsewhere. Pick the expected label for the host running the test. #[cfg(target_os = "macos")] const SECONDARY: &str = "⌘"; #[cfg(not(target_os = "macos"))] @@ -752,7 +552,6 @@ mod tests { const SHIFT: &str = "⇧"; #[cfg(not(target_os = "macos"))] const SHIFT: &str = "Shift"; - // Literal `ctrl` renders as ⌃ on macOS, "Ctrl" elsewhere. #[cfg(target_os = "macos")] const CTRL: &str = "⌃"; #[cfg(not(target_os = "macos"))] @@ -767,8 +566,6 @@ mod tests { #[test] fn key_tokens_keeps_the_minus_key_as_the_final_token() { - // "secondary--" is the secondary key + the "-" key; a naive split on '-' - // would drop the trailing key. assert_eq!(key_tokens("secondary--"), vec![SECONDARY, "−"]); assert_eq!(key_tokens("secondary-="), vec![SECONDARY, "="]); assert_eq!(key_tokens("secondary-,"), vec![SECONDARY, ","]); @@ -776,7 +573,6 @@ mod tests { #[test] fn key_chords_splits_a_sequence_into_keycap_groups() { - // A tmux-style sequence renders as two distinct clusters. assert_eq!( key_chords("ctrl-b n"), vec![ @@ -784,15 +580,11 @@ mod tests { vec!["N".to_string()] ] ); - // A single chord is one group. assert_eq!(key_chords("secondary-t"), vec![vec![SECONDARY, "T"]]); } #[test] fn every_default_action_has_a_binding_builder_or_is_unbound() { - // Every action the defaults name must be constructible by `make_binding` - // (a missing arm would silently drop the binding), and each default key - // must be empty (unbound) or a valid keystroke. for (action, key) in default_bindings() { if !key.is_empty() { assert!( @@ -809,9 +601,6 @@ mod tests { #[test] fn tmux_preset_keystrokes_all_parse_and_map_to_actions() { - // Every preset row must produce an installable binding: a parseable - // sequence (including the shifted punctuation `% " { }`) and a known - // action. A silent parse failure would leave the preset key dead. for (action, key) in tmux_preset("ctrl-b") { assert!( keystroke_is_valid(&key), @@ -826,9 +615,6 @@ mod tests { #[test] fn insert_newline_ships_both_default_chords() { - // Shift+Enter and Alt+Enter have both inserted a soft newline since the - // multi-line editor landed; exposing the gesture as an action must not - // cost either one. Shift+Enter is the table row, Alt+Enter the extra. let effective: Vec<(String, String)> = default_bindings() .into_iter() .map(|(a, k)| (a.to_string(), k.to_string())) @@ -844,8 +630,6 @@ mod tests { extra_keystrokes(&effective), vec![("InsertNewline", "alt-enter")] ); - // Both are real, installable bindings, and both are remembered so a - // later `rebind` can neutralize them. for key in ["shift-enter", "alt-enter"] { assert!(keystroke_is_valid(key), "{key} does not parse"); assert!(make_binding("InsertNewline", key).is_some()); @@ -859,9 +643,6 @@ mod tests { #[test] fn rebinding_insert_newline_retires_both_default_chords() { - // Move the action off its default and the second chord goes with it — - // otherwise Alt+Enter would keep inserting newlines behind the user's - // back after they deliberately moved the binding. let effective = vec![("InsertNewline".to_string(), "ctrl-o".to_string())]; assert!(extra_keystrokes(&effective).is_empty()); assert_eq!( @@ -869,7 +650,6 @@ mod tests { vec![("ctrl-o".to_string(), Some("Terminal"))] ); - // Unbinding it entirely (an empty override) installs nothing at all. let unbound = vec![("InsertNewline".to_string(), String::new())]; assert!(extra_keystrokes(&unbound).is_empty()); assert!(action_bindings(&unbound).is_empty()); @@ -877,12 +657,6 @@ mod tests { #[test] fn bound_keystrokes_remember_the_context_each_binding_was_installed_in() { - // `rebind` retires an old chord with a `NoAction`, and a context-less - // one gets GPUI's *maximum* match depth — it would outrank, and discard, - // deeper bindings on that chord that we never installed (gpui-component - // binds `shift-enter` in its own "Input" context, which is what steps to - // the previous match in the terminal's search field). So each remembered - // keystroke carries the scope its binding actually had. let effective = vec![ ("InsertNewline".to_string(), "shift-enter".to_string()), ("NewTab".to_string(), "secondary-t".to_string()), @@ -892,7 +666,6 @@ mod tests { vec![ ("shift-enter".to_string(), Some("Terminal")), ("secondary-t".to_string(), None), - // The extra chord is scoped by its action, like any other row. ("alt-enter".to_string(), Some("Terminal")), ] ); @@ -900,9 +673,6 @@ mod tests { #[test] fn action_context_matches_the_scope_make_binding_installs() { - // The two must agree for every action, or `rebind` would neutralize a - // chord in a scope the binding never had — leaving the old binding live - // (too narrow) or shadowing unrelated ones (too wide). let extra_actions = extra_keystrokes( &default_bindings() .into_iter() @@ -927,8 +697,6 @@ mod tests { #[test] fn secondary_enter_chords_are_distinct_from_insert_newline() { - // The window bindings the reporter called out (#182) must stay put: - // ⌘⏎ / ⌘⇧⏎ are different keystrokes from the bare ⇧⏎ and ⌥⏎. let defaults = default_bindings(); let key_of = |action: &str| { defaults @@ -943,9 +711,6 @@ mod tests { assert_ne!(window_chord, INSERT_NEWLINE_DEFAULT); assert_ne!(window_chord, INSERT_NEWLINE_ALT_DEFAULT); } - // Modifier matching is exact, so the three-key chord is nobody's: it - // inserts nothing and submits, as it does in Warp. Spelled out here so - // a future reader doesn't "restore" it as a missing default. for chord in [INSERT_NEWLINE_DEFAULT, INSERT_NEWLINE_ALT_DEFAULT] { assert_ne!(chord, "shift-alt-enter"); assert_ne!(chord, "alt-shift-enter"); @@ -954,8 +719,6 @@ mod tests { #[test] fn spec_from_keystroke_round_trips_through_parse() { - // A recorded keystroke → spec string → parsed keystroke must be stable, - // and the platform primary modifier must normalize to `secondary`. for spec in [ "secondary-t", "secondary-shift-t", @@ -976,8 +739,6 @@ mod tests { #[test] fn spec_from_keystroke_ignores_a_lone_modifier() { - // Parsing "secondary" yields a keystroke whose *key* is the modifier; - // there's nothing to bind yet, so recording keeps waiting. let ks = Keystroke::parse("secondary").unwrap(); assert_eq!(spec_from_keystroke(&ks), None); } @@ -989,11 +750,6 @@ mod gpui_tests { use crate::core::config::Config; use gpui::TestAppContext; - // Install the keymap for real (init → edit config → rebind) against a live - // `App`. Every effective keystroke goes through `KeyBinding::new`, which - // panics on a bad spec — so this catches a preset/default that only *looks* - // valid, and confirms the three-layer merge (default → tmux preset → user - // override) resolves as expected. #[gpui::test] fn init_then_rebind_installs_the_merged_table(cx: &mut TestAppContext) { cx.update(|cx| { @@ -1001,7 +757,6 @@ mod gpui_tests { cx.set_global(Config::default()); init(cx); - // Turn on the tmux preset and override one action on top of it. { let cfg = cx.global_mut::<Config>(); cfg.keybinding_preset = "tmux".to_string(); @@ -1017,14 +772,10 @@ mod gpui_tests { .map(|(_, k)| k.clone()) .unwrap() }; - // User override beats the preset's `prefix c` for NewTab. assert_eq!(key_of("NewTab"), "secondary-shift-n"); - // A preset-only remap surfaces its prefix sequence. assert_eq!(key_of("SplitRight"), "ctrl-b %"); - // An action the preset doesn't touch keeps its default. assert_eq!(key_of("TogglePalette"), "secondary-p"); - // Switching back to the default preset drops the sequences. cx.global_mut::<Config>().keybinding_preset = "default".to_string(); rebind(cx); let eff = effective_bindings(cx); diff --git a/src/ui/local_link.rs b/src/ui/local_link.rs index c63b173e..5e314052 100644 --- a/src/ui/local_link.rs +++ b/src/ui/local_link.rs @@ -1,53 +1,3 @@ -//! The GUI's control link to **this machine's own daemon**. -//! -//! Local and remote machines are the same thing seen from different distances: -//! one machine, one daemon, one workspace tree, one control link. The remote -//! machines' links live in [`crate::ui::remote_connect::HostLinks`]; this -//! module is the local machine's — the link over which the GUI receives the -//! local daemon's pushes (`ControlEvent::Layout` deltas, `Preempted`) and -//! sends its semantic tree operations. -//! -//! # Not a `HostLinks` entry -//! -//! `HostLinks` doubles as the [`crate::ui::host_registry::HostRegistry`] -//! feeder: inserting there would register a *wire-backed* `Host` for this -//! machine, while the local file tree and git must keep going through the -//! in-process [`LocalHost`](tty7_core::host::local::LocalHost) — a socket -//! round trip per `stat` on the machine you are sitting at would be absurd. It -//! also keeps the `HostId::LOCAL`-never-holds-a-control-connection invariant -//! untouched: this link lives in its own global, not in any host table. -//! -//! # Not routed -//! -//! A remote control connection dials the local daemon's *pane* socket and asks -//! it to route (the GUI never speaks SSH). This machine needs no routing — the -//! daemon's control endpoint is right here, so the link is a plain connect plus -//! a `ControlHello`. -//! -//! # Its own pump -//! -//! The remote supervisor's pump deliberately stops when the last remote -//! workspace closes; this link must outlive that — a purely local session is -//! the *common* case — so [`LocalLink::install`] runs its own forever loop at -//! the same cadence. Each turn supervises the connection (reconnecting on the -//! same 1/2/4/…/30 s backoff a remote machine gets — the daemon may be -//! restarting or upgrading, and the GUI auto-spawns it, so "down" is always -//! transient) and drains the shared event queue, so local pushes are delivered -//! even when the remote pump is parked. Events land in that queue under -//! [`HostId::LOCAL`](tty7_core::host::HostId::LOCAL): the pump drains one -//! queue and machines differ only by id, which is the same-shape-everywhere -//! the whole design is after. -//! -//! # Both platforms -//! -//! The dial is the one part that differs, and only in its first line: a Unix -//! socket where there are Unix sockets, and the same token-checked loopback -//! endpoint the pane dialect uses on Windows (see -//! [`tty7_core::daemon::transport`]). Everything above `connect_blocking` — -//! supervision, backoff, the event queue, the tree sync that rides this link — -//! is one code path, because a machine's tree is what a window's layout *is* -//! and a platform without it is a platform where tabs do not come back. - use std::sync::Arc; use gpui::{App, Global}; @@ -55,27 +5,19 @@ use tty7_core::daemon::control::ControlClient; use crate::ui::remote_workspace::Backoff; -/// The link, and the schedule for getting it back. #[derive(Default)] pub struct LocalLink { client: Option<Arc<ControlClient>>, backoff: Backoff, - /// When the next attempt is due. `None` while the link is up or an - /// attempt is in flight. next_attempt: Option<std::time::Instant>, attempting: bool, - /// Whether the forever loop is already running, so `install` is idempotent. pumping: bool, } impl Global for LocalLink {} impl LocalLink { - /// Start supervising the local link. Called once at startup; safe to call - /// again (the loop is a singleton). pub fn install(cx: &mut App) { - // Local pushes need the same somewhere-to-go the remote ones have, - // and this loop may be the only one draining it. crate::ui::remote_workspace::install_event_observer(); let link = cx.default_global::<LocalLink>(); if link.pumping { @@ -96,19 +38,11 @@ impl LocalLink { .detach(); } - /// The live control client for this machine's daemon, if there is one. - /// - /// `None` is always transient — the supervisor is already reconnecting — - /// so callers treat it exactly like an unreachable remote: skip the - /// operation, or queue nothing and rely on the full pull that follows a - /// reconnect. pub fn client(cx: &mut App) -> Option<Arc<ControlClient>> { let link = cx.default_global::<LocalLink>(); link.client.as_ref().filter(|c| c.is_connected()).cloned() } - /// One supervision step: notice a dead link, drop it, and schedule or - /// launch the next attempt on the backoff. fn tick(cx: &mut App) { let now = std::time::Instant::now(); let link = cx.default_global::<LocalLink>(); @@ -123,9 +57,6 @@ impl LocalLink { link.client = None; } match link.next_attempt { - // Never attempted at all: due now. The daemon is normally already - // up (main spawns it before the first window), so the first tick - // should connect, not start a schedule. None if link.backoff.attempt() == 0 => {} None => { link.next_attempt = Some(now + link.backoff.delay()); @@ -152,21 +83,13 @@ impl LocalLink { link.client = Some(client); link.backoff.reset(); link.next_attempt = None; - // Every fresh link starts with a full pull — deltas - // only advance a mirror that has a base to advance. crate::ui::machine_mirror::MachineMirrors::refresh( cx, tty7_core::host::HostId::LOCAL, ); - // …and re-runs every local window's sync: a window - // built while this link was still dialing is parked - // `Unprimed { dirty }` with nothing else scheduled to - // wake it (see `tree_sync::on_link_up`). crate::ui::tree_sync::on_link_up(cx, tty7_core::host::HostId::LOCAL); } Err(e) => { - // The next tick schedules the following attempt off - // the already-advanced backoff. log::debug!("local control link attempt failed: {e}"); } } @@ -176,41 +99,24 @@ impl LocalLink { } } -/// Dial the local daemon's control endpoint and shake hands. **Blocking**; runs -/// on the background executor. -/// -/// `ensure_running` first, because the daemon is the GUI's own child in the -/// common case: on a cold start this races the daemon binding its listener, -/// and the backoff absorbs the one or two attempts that lose the race. fn connect_blocking() -> std::io::Result<Arc<ControlClient>> { use tty7_core::daemon::control::ControlHello; crate::daemon::spawn::ensure_running().map_err(std::io::Error::other)?; - let hello = ControlHello::host_rpc( - uuid::Uuid::new_v4().to_string(), - // Its own label rather than this machine's hostname: if this session - // is ever preempted, "this computer" is the useful thing to show — - // the hostname would name the machine the user is already at. - "this computer", - ); + let hello = ControlHello::host_rpc(uuid::Uuid::new_v4().to_string(), "this computer"); let sink: tty7_core::daemon::control::EventSink = Box::new(local_event_sink); - // The one platform difference: which kind of stream carries the dialect. #[cfg(unix)] let client = ControlClient::over_unix( std::os::unix::net::UnixStream::connect(tty7_core::host::server::control_socket_path()?)?, &hello, sink, )?; - // Loopback TCP with the daemon's token as a preamble — the access boundary - // Windows has instead of socket permissions; `connect_control` presents it. #[cfg(windows)] let client = ControlClient::over_tcp(tty7_core::host::server::connect_control()?, &hello, sink)?; Ok(Arc::new(client)) } -/// Local daemon pushes land in the same process-wide observer as every remote -/// machine's, attributed to [`HostId::LOCAL`](tty7_core::host::HostId::LOCAL). fn local_event_sink(event: tty7_core::daemon::control::ControlEvent) { tty7_core::daemon::control::observe_event(tty7_core::host::HostId::LOCAL, event); } diff --git a/src/ui/machine_mirror.rs b/src/ui/machine_mirror.rs index 8ea8911f..9e7ac0ad 100644 --- a/src/ui/machine_mirror.rs +++ b/src/ui/machine_mirror.rs @@ -1,34 +1,3 @@ -//! A per-machine mirror of each daemon's workspace tree, for the surfaces that -//! read *about* workspaces without showing them. -//! -//! The machine's tree is the layout authority, so anything the client used to -//! answer from its own saved layout — a picker row's name, the "3 panes" -//! count, which pane ids a workspace claims — has to come from the tree now. -//! The windows that *show* a workspace already hold a per-window mirror -//! ([`crate::ui::tree_sync`]); this global is the read model for everything -//! else: the switcher, the Window menu, the title bar, the liveness sweep. -//! -//! # How it stays current -//! -//! One [`Machine`] per [`HostId`], filled by a `MachineGet` when a machine's -//! control link comes up and advanced from there by the same -//! [`LayoutDelta`] stream the windows consume — plus -//! [`note_synced_workspace`], because origin exclusion means this client never -//! hears its **own** operations back, and the per-window mirror they advanced -//! is the only other record of what they did. -//! -//! A delta that will not apply (a machine the pull has not answered for yet, a -//! tab it never heard of) marks nothing broken: the mirror re-pulls the whole -//! machine, exactly like a drifted window does. -//! -//! # It may be behind, and that is allowed -//! -//! Against the local daemon the first pull lands within milliseconds of -//! launch, so the picker's loading gap is about one frame. A machine that is -//! unreachable keeps its last pulled state for the rest of the process — stale -//! names beat no names — and a machine never reached this session simply has -//! no entry, which readers render as the not-knowing they are in. - use std::collections::HashMap; use gpui::{App, Global}; @@ -38,38 +7,24 @@ use tty7_core::host::HostId; use crate::core::session::WorkspaceId; -/// Every machine's last known tree, by the machine. #[derive(Default)] pub struct MachineMirrors { machines: HashMap<HostId, Machine>, - /// Hosts with a `MachineGet` in flight, so a burst of triggers costs one - /// round trip. pulling: Vec<HostId>, } impl Global for MachineMirrors {} impl MachineMirrors { - /// The last pulled tree for `host`, or `None` when no pull has answered - /// yet this session. Read-only; renders may call it every frame. pub fn machine(cx: &App, host: HostId) -> Option<&Machine> { cx.try_global::<Self>()?.machines.get(&host) } - /// Whether `host`'s tree has been pulled at all — the "loading" / - /// "known but empty" distinction a picker wants to draw. pub fn ready(cx: &App, host: HostId) -> bool { Self::machine(cx, host).is_some() } - /// Pull `host`'s whole tree in the background and install it. Cheap to - /// call whenever a link comes up or a delta refuses to apply; concurrent - /// triggers coalesce into one round trip. pub fn refresh(cx: &mut App, host: HostId) { - // A peer that does not advertise `machine-tree` (a server with no - // home directory for one) has no tree to pull; asking anyway costs a - // round trip per trigger to hear the same refusal. Reads keep their - // "never pulled" answer, which renders as not knowing. let client = match crate::ui::tree_sync::tree_control_for(cx, host) { crate::ui::tree_sync::TreeLink::Ready(client) => client, crate::ui::tree_sync::TreeLink::Unserved => { @@ -112,23 +67,11 @@ impl MachineMirrors { .detach(); } - /// Install a freshly pulled tree — for the paths that already hold one - /// (a window's hydration pulls `MachineGet` anyway). - /// - /// Repaints, like [`refresh`](Self::refresh)'s landing does: every workspace - /// name, pane count and liveness dot on screen reads this global, and a - /// pull that lands without a repaint leaves the chrome a frame (or, on a - /// quiet screen, indefinitely) behind the tree it is describing. pub fn install(cx: &mut App, host: HostId, machine: Machine) { cx.default_global::<Self>().machines.insert(host, machine); cx.refresh_windows(); } - /// Advance `host`'s mirror by one delta about the workspace `key` names. - /// - /// A delta that names state the mirror does not hold re-pulls the machine - /// whole; a delta arriving before the first pull is dropped, because that - /// pull's answer already includes it. pub fn apply_delta(cx: &mut App, host: HostId, key: &str, delta: &LayoutDelta) { let Ok(id) = key.parse::<WorkspaceId>() else { return; @@ -143,10 +86,6 @@ impl MachineMirrors { } } - /// Record the post-state of this client's own operations on `machine_ws` — - /// the half of the history origin exclusion keeps out of the delta stream. - /// A workspace the mirror has not seen is created; `None` tabs leave the - /// structure alone (a label-only op). pub fn note_synced_workspace( cx: &mut App, host: HostId, @@ -171,9 +110,6 @@ impl MachineMirrors { ws.active_tab = active; } - /// Fold in a workspace-level operation this client just fired - /// ([`crate::ui::tree_sync::fire_workspace_op`]) — same reason as - /// [`note_synced_workspace`]: the writer never hears its own echo. pub fn note_workspace_op(cx: &mut App, host: HostId, request: &ControlRequest) { let Some(machine) = cx.default_global::<Self>().machines.get_mut(&host) else { return; @@ -197,10 +133,7 @@ impl MachineMirrors { } } -/// Advance one machine's copy by one delta. `false` means the delta names -/// state the mirror does not hold and the caller should re-pull. fn apply(machine: &mut Machine, workspace: WorkspaceId, delta: &LayoutDelta) -> bool { - // The two deltas that do not require the workspace to exist yet. match delta { LayoutDelta::WorkspaceCreated { workspace: ws } => { machine.workspaces.retain(|w| w.id != ws.id); @@ -211,9 +144,6 @@ fn apply(machine: &mut Machine, workspace: WorkspaceId, delta: &LayoutDelta) -> machine.workspaces.retain(|w| w.id != workspace); return true; } - // Facts about a pane are registry-wide; the workspace key only says - // who referenced it. Upserted rather than matched, because the record - // may have been born from another client's op this mirror never saw. LayoutDelta::PaneFacts { pane } => { match machine.panes.iter_mut().find(|p| p.id == pane.id) { Some(record) => *record = pane.clone(), @@ -243,10 +173,6 @@ fn apply(machine: &mut Machine, workspace: WorkspaceId, delta: &LayoutDelta) -> true } LayoutDelta::TabCreated { at, tab } => { - // Deltas and full pulls have no ordering barrier: a create that - // straddles a pull arrives *after* the snapshot that already - // carries its tab. Replace-by-id (the `WorkspaceCreated` retain - // above is the precedent) rather than insert twice. ws.tabs.retain(|t| t.id != tab.id); let at = (*at).min(ws.tabs.len()); ws.tabs.insert(at, tab.clone()); @@ -310,13 +236,6 @@ fn apply(machine: &mut Machine, workspace: WorkspaceId, delta: &LayoutDelta) -> } } -// --------------------------------------------------------------------------- -// Reading a client entry's display facts off its machine's mirror -// --------------------------------------------------------------------------- - -/// The tree workspace a client entry points at, with the pane registry it -/// reads records from. `None` while the machine has not been pulled (or no -/// longer lists the workspace). fn view_of<'a>( cx: &'a App, entry: &crate::core::session::WindowView, @@ -327,16 +246,6 @@ fn view_of<'a>( Some((ws, &machine.panes)) } -/// What the picker and the window title call `entry`: the user-set name, else -/// derived from the tree's repo groups and cwds. -/// -/// Falls back to -/// [`WindowView::label`](crate::core::session::WindowView::label) — what the -/// machine last said, before it -/// stopped answering. The tree wins whenever it answers; the hint is for the -/// rows the picker exists to offer, on machines that are asleep. `None` only -/// when this client has never seen the workspace named at all, which is a -/// brand-new entry and nothing a user is choosing between. pub fn display_name(cx: &App, entry: &crate::core::session::WindowView) -> Option<String> { match view_of(cx, entry) { Some((ws, panes)) => Some(display_name_of(ws, panes)), @@ -344,8 +253,6 @@ pub fn display_name(cx: &App, entry: &crate::core::session::WindowView) -> Optio } } -/// A tree workspace's label: the user-set name, else the repository most of -/// its tabs live in, else the first pane's directory, else `"Untitled"`. pub fn display_name_of(ws: &Workspace, panes: &[PaneRecord]) -> String { if let Some(name) = ws.name.as_deref().map(str::trim).filter(|n| !n.is_empty()) { return name.to_string(); @@ -360,9 +267,6 @@ pub fn display_name_of(ws: &Workspace, panes: &[PaneRecord]) -> String { .unwrap_or_else(|| "Untitled".to_string()) } -/// The path a workspace is *about*: the repo group most tabs belong to (ties -/// toward the earliest tab), else the first pane's cwd. What the picker's dim -/// subtitle shows, and what [`display_name_of`] takes the basename of. pub fn subject_path_of(ws: &Workspace, panes: &[PaneRecord]) -> Option<String> { let mut counts: Vec<(&str, usize)> = Vec::new(); for group in ws.tabs.iter().filter_map(|t| t.sidebar_group.as_deref()) { @@ -385,15 +289,11 @@ pub fn subject_path_of(ws: &Workspace, panes: &[PaneRecord]) -> Option<String> { dominant.or(first_cwd).map(str::to_string) } -/// [`display_name`] looked up by the client's workspace id, with the shared -/// not-knowing fallback — for the sites that hold an id rather than an entry. pub fn display_name_for(cx: &App, client_ws: WorkspaceId) -> Option<String> { let entry = crate::core::session::WorkspaceStore::all(cx).get(client_ws)?; display_name(cx, entry) } -/// [`subject_path_of`] for a client entry, falling back to the stamped hint for -/// the same reason [`display_name`] does. pub fn subject_path(cx: &App, entry: &crate::core::session::WindowView) -> Option<String> { match view_of(cx, entry) { Some((ws, panes)) => subject_path_of(ws, panes).or_else(|| entry.subject.clone()), @@ -401,10 +301,6 @@ pub fn subject_path(cx: &App, entry: &crate::core::session::WindowView) -> Optio } } -/// The pair a client entry should carry on file, read off its machine's mirror — -/// for [`WorkspaceStore::record_geometry`](crate::core::session::WorkspaceStore::record_geometry) -/// to stamp. `None` for a machine that has not answered: a hint is only ever -/// replaced by something better, never blanked by not knowing. pub fn display_hint( cx: &App, entry: &crate::core::session::WindowView, @@ -413,22 +309,15 @@ pub fn display_hint( Some((display_name_of(ws, panes), subject_path_of(ws, panes))) } -/// Every pane id `entry`'s tree claims on its machine. `None` when the -/// machine's tree has not been pulled — which a caller about to state a fact -/// ("3 running sessions will be ended") must render as not knowing, not as -/// zero. pub fn pane_ids(cx: &App, entry: &crate::core::session::WindowView) -> Option<Vec<u64>> { let (ws, _) = match view_of(cx, entry) { Some(view) => view, - // A pulled machine that no longer lists the workspace *is* an answer: - // it claims nothing. None if MachineMirrors::ready(cx, entry.host_id()) => return Some(Vec::new()), None => return None, }; Some(ws.tabs.iter().flat_map(|t| t.root.pane_ids()).collect()) } -/// How many terminals `entry` holds across every tab, per its machine's tree. pub fn pane_count(cx: &App, entry: &crate::core::session::WindowView) -> Option<usize> { pane_ids(cx, entry).map(|ids| ids.len()) } @@ -450,11 +339,6 @@ mod tests { Tab::leaf(pane) } - /// A machine that is not answering still has to produce a row a user can - /// choose: the picker's whole job is offering workspaces on machines that - /// are asleep, and "Untitled" with a blank subtitle is not an offer. So the - /// stamped hint stands in until a pull lands, and the tree wins the moment - /// one does. #[gpui::test] fn an_unpulled_machine_falls_back_to_the_stamped_label(cx: &mut gpui::TestAppContext) { use crate::core::session::{WindowView, WindowViews, WorkspaceStore}; @@ -473,7 +357,6 @@ mod tests { }, ); - // Nothing pulled: the hint is what the row says. assert_eq!(display_name(cx, &entry).as_deref(), Some("api")); assert_eq!(subject_path(cx, &entry).as_deref(), Some("/repo/api")); assert!( @@ -481,7 +364,6 @@ mod tests { "and a machine that has not answered contributes no new hint" ); - // The tree answers, and outranks it. let mut tree = Workspace { id, name: Some("web".into()), @@ -553,9 +435,6 @@ mod tests { ); } - /// Deltas and full pulls have no ordering barrier: a `TabCreated` that - /// straddles a `MachineGet` arrives after a snapshot that already carries - /// its tab. Applying it must replace by id, not insert a second copy. #[test] fn a_tab_created_delta_that_straddled_a_pull_lands_once() { let ws = Workspace::default(); @@ -590,7 +469,6 @@ mod tests { ), "an unappliable delta must say so, so the caller re-pulls" ); - // …and so does one about a workspace the machine does not list. assert!(!apply( &mut machine, WorkspaceId::new(), @@ -620,8 +498,6 @@ mod tests { assert!(machine.panes[0].live); } - /// The precedence `Workspace::display_name` always had, read off the tree: - /// user name, then the dominant repo group, then the first pane's cwd. #[test] fn display_names_derive_from_the_tree_with_the_session_precedence() { let mut ws = Workspace::default(); diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 49ff7c2d..41806060 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,10 +1,3 @@ -//! The GPUI view layer: the window shell (`app`), the split-pane tree (`pane`), -//! the command palette (`palette`), the settings panel (`settings`), and the -//! menu-bar / keymap / theme wiring (`keymap`, `theme`). -//! -//! Everything here may depend on `core` and `terminal`; nothing in those layers -//! depends back on `ui`. - pub mod app; pub mod assets; pub mod code_editor; @@ -13,10 +6,6 @@ pub mod file_tree; pub mod forwards; pub mod hints; pub mod home; -// The `Host` layer's GUI half. The facade and the registry land ahead of the -// call sites that consume -// them — the six views move over to `HostOps` as a separate change — so they -// read as dead code until that merges. #[allow(dead_code)] pub mod host_ops; #[allow(dead_code)] diff --git a/src/ui/palette.rs b/src/ui/palette.rs index fa176a6d..0b5de8ec 100644 --- a/src/ui/palette.rs +++ b/src/ui/palette.rs @@ -1,38 +1,3 @@ -//! The command palette: a centered overlay (Cmd+P) that lists runnable -//! commands, fuzzy-filters them as you type, and runs the selected one. -//! -//! This module owns the palette's *data* — the command catalog and the -//! `ListDelegate` that filters it. The heavy lifting (search input, virtual -//! list, keyboard navigation, Enter/Esc handling) is supplied by -//! gpui-component's `list::ListState`, so we don't reimplement any of it. -//! [`PaletteView`] wraps that list with the overlay chrome (scrim + card) and -//! emits a [`PaletteEvent`] on confirm/dismiss; command *execution* lives in -//! `app.rs`, where it can touch `Tty7App`'s tab/pane operations. -//! -//! ## The naming grammar -//! -//! Every title in [`Command::base_commands`] follows one shape, because a -//! palette is read by scanning and a list written in three different styles -//! can't be scanned. The rules, in order of precedence: -//! -//! 1. **`Verb Object`** — "New Tab", "Split Right", "Clear Scrollback". The -//! verb comes first because that is what the user is searching for. -//! 2. **`Namespace: Verb Object`** when the command belongs to an enumerable -//! subsystem — `SSH:`, `Agent:`, `Right Panel:`. If one command in a group -//! carries the prefix, *all* of them do; a single bare sibling (this list -//! used to have "Reconnect SSH Session" sitting beside four `SSH:` rows) is -//! what makes a namespace look accidental. -//! 3. **A trailing `…`** means "this asks for something else before it acts" — -//! another list, a text field, a confirmation. Not "this opens a panel". -//! 4. **No `Toggle`.** A toggle names the mechanism; the user wants the result. -//! Titles read "Hide Left Sidebar" or "Show Left Sidebar" depending on where -//! the sidebar currently is, which also removes the guesswork about what a -//! toggle would do from the current state. -//! 5. **Two commands that could be confused must not merely differ by a word.** -//! "Toggle Tab Sidebar" and "Toggle Left Sidebar" were, respectively, moving -//! the tab bar and collapsing the rail; they now read "Tab Bar: Move to Left -//! Sidebar" and "Hide Left Sidebar". - use gpui::{ App, Context, Entity, EventEmitter, MouseButton, MouseDownEvent, SharedString, Subscription, Task, Window, div, prelude::*, px, @@ -48,38 +13,23 @@ use uuid::Uuid; use crate::core::config::{Config, RightPanelTab, TabBarPosition}; use crate::core::ssh_profile::parse_quick_connect; -/// What a command actually does. Most variants map to an existing `Tty7App` -/// operation dispatched in `app.rs` (so it can touch tabs/panes); submenu -/// openers are handled inside [`PaletteView`] and never reach the host. #[derive(Clone, PartialEq, Eq)] pub enum CommandKind { NewTab, NewWorkspace, - /// Submenu opener: swap the palette to the list of known workspaces. - /// Handled inside `PaletteView`; never reaches the host. OpenWorkspacePicker, RenameWorkspace, - /// Stop this window's workspace: kill its sessions and close the window, - /// keeping the layout so it can be started again. The counterpart to - /// closing a window, which only detaches. StopWorkspace, - /// Stop it *and* discard the layout. The only irreversible one. DeleteWorkspace, SplitRight, SplitDown, ClosePane, - // Tab operations that used to live only in the tab context menu, so the - // palette could not reach what a right-click could. RenameTab, NewWorktreeTab, CloseOtherTabs, CloseTabsToTheRight, CopyWorkingDirectory, MarkTabUnread, - /// Branch this tab's agent session into a second, independent one, opened - /// in a new tab. The pane right-click menu offers the split placements; - /// the palette's ask isn't a spatial one, so it takes the tab-level - /// meaning. ForkAgentSession, CopyAgentSessionId, ResetFontSize, @@ -102,24 +52,18 @@ pub enum CommandKind { ToggleTabSidebar, ToggleLeftPanel, ToggleRightPanel, - /// Switch the right panel to a specific tab, opening it if it was closed — - /// so the palette can land you on Changes without a toggle-then-click. ShowRightPanel(RightPanelTab), ClearTerminal, FindInTerminal, FindNext, FindPrevious, - // The clipboard trio + Select All. Dispatched to the focused terminal, the - // same actions the Edit menu and the right-click menu use. CopyText, CutText, PasteText, SelectAllText, ReopenClosedTab, OpenSettings, - /// Settings, opened straight on its Keybindings section. ShowKeyboardShortcuts, - /// Settings, opened straight on its About section. About, CheckForUpdates, OpenDocumentation, @@ -127,49 +71,25 @@ pub enum CommandKind { ReportIssue, Quit, RestartDaemon, - /// Show the focused native-SSH pane's remote filesystem — the detail - /// panel's Files tab, which browses over SFTP for a remote pane (WS5). ToggleSftp, - /// Show the focused native-SSH pane's forwards in the detail panel's Info - /// tab, add form open (WS4). ShowSshForwards, - /// Toggle the code panel (file tree + editor overlay over the terminal). ToggleCodePanel, - /// Reconnect a dead native-SSH pane in place (WS6, FR-E4). RestartSshSession, - /// Send the focused pane's selection to a running CLI coding agent's pane - /// as a prompt (build error → agent, the review-feed idea). SendSelectionToAgent, - /// Send the repo's uncommitted `git diff` (from the focused pane's cwd) to - /// a running CLI coding agent's pane as a review prompt. SendGitDiffToAgent, - /// Opens the theme sub-list (a nested palette). Handled in `PaletteView`. OpenThemePicker, - /// Opens a typed SSH connection sub-list. Handled in `PaletteView`. OpenSshConnectInput, - /// Open a native SSH tab from a typed target/options line. OpenSshConnect(String), - /// Apply the preset at this index in `presets::all()`. Emitted from the - /// theme sub-list. SetTheme(usize), - /// Switch to the tab at this index in `Tty7App::tabs`. ActivateTab(usize), - /// Connect a saved SSH profile by id (over the native engine). ConnectSavedProfile(Uuid), - /// Open the profile editor focused on this saved profile (⌘⏎ / → on a row). EditSavedProfile(Uuid), - /// QuickConnect to a typed `user@host[:port]` target via the native path. QuickConnect(String), - /// Open the profile editor pre-filled from a typed QuickConnect target - /// ("save as profile" from a quick connect). SaveQuickConnect(String), - /// Open the full-window SSH profile manager/editor page. OpenSshProfiles, } impl CommandKind { - /// The "edit" counterpart of a connect-style command, for the ⌘⏎ / → gesture - /// (PRD §6.2 ①). `None` for commands that have no editor. pub fn edit_variant(&self) -> Option<CommandKind> { match self { CommandKind::ConnectSavedProfile(id) => Some(CommandKind::EditSavedProfile(*id)), @@ -178,10 +98,6 @@ impl CommandKind { } } - /// A stable key for [`Config::command_frecency`], or `None` for commands - /// that aren't a repeatable "thing you run" — a specific tab index, a theme - /// slot, a typed host. Recording those would fill the Recent group with - /// entries that mean something different next launch. pub fn id(&self) -> Option<&'static str> { use CommandKind::*; Some(match self { @@ -253,8 +169,6 @@ impl CommandKind { OpenThemePicker => "change-theme", OpenSshConnectInput => "ssh-add-connection", OpenSshProfiles => "ssh-manage-profiles", - // Instance-specific: a tab index, a theme slot, a profile id, a - // typed host. Not stable across sessions, so not tracked. OpenSshConnect(_) | SetTheme(_) | ActivateTab(_) @@ -265,19 +179,8 @@ impl CommandKind { }) } - /// The keystroke shown beside this command, as a config keyspec. - /// - /// Most commands resolve through the live keymap, so a user remap shows up - /// here automatically. The clipboard trio and Select All are the exception: - /// they're handled inline in `terminal::view::handle_cmd_shortcut` rather - /// than as registered bindings (⌃C has to fall through to SIGINT with - /// nothing selected, which a registered binding would swallow), so their - /// chords are stated literally — the same thing the right-click menu does. fn key_spec(&self, cx: &App) -> Option<String> { use CommandKind::*; - // Inline-handled chords, macOS-only: off macOS these live on Ctrl and - // Ctrl+A / Ctrl+F keep their readline meaning, so advertising them - // would be a lie. let inline = |spec: &str| -> Option<String> { cfg!(target_os = "macos").then(|| spec.to_string()) }; match self { @@ -331,10 +234,6 @@ impl CommandKind { RightPanelTab::Files => "ShowRightPanelFiles", }, ClearTerminal => "ClearScrollback", - // Previously grouped with the hint-less commands even though it has - // shipped a default ⌘F for as long as the binding has existed — so - // the one command whose shortcut users most want to learn was the - // one the palette refused to teach. FindInTerminal => "FindInTerminal", FindNext => "FindNext", FindPrevious => "FindPrevious", @@ -353,7 +252,6 @@ impl CommandKind { ToggleCodePanel => "ToggleCodePanel", RestartSshSession => "RestartSshSession", OpenSshProfiles => "OpenSshProfiles", - // No global binding, by design or by nature. CopyText | CutText | PasteText @@ -375,10 +273,6 @@ impl CommandKind { } } -/// The band a command is filed under in the unfiltered palette. Groups exist so -/// the resting list reads as a map of the app rather than 60 undifferentiated -/// rows; while a search is running they're dropped and the results rank purely -/// by match quality. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum CommandGroup { TabsPanes, @@ -391,8 +285,6 @@ pub enum CommandGroup { } impl CommandGroup { - /// Display order of the groups, which is roughly "how often you reach for - /// this": the tab/pane verbs first, the app-level chores last. const ORDER: [CommandGroup; 7] = [ CommandGroup::TabsPanes, CommandGroup::Workspaces, @@ -416,30 +308,15 @@ impl CommandGroup { } } -/// The chrome state the stateful titles ("Hide Left Sidebar", "Show Right -/// Panel") read, passed in by the window opening the palette. -/// -/// Deliberately *not* read off `Config`: both of these are per-window state -/// living on `Tty7App`, and their config copies only record whichever window -/// toggled them last. Reading the config would label the row by another -/// window's rail — and clicking it would then do the opposite of what it said. #[derive(Clone, Copy)] pub struct ChromeState { - /// This window's rail collapse flag (`Tty7App::sidebar_collapsed`), not the - /// config's. Note this is the *toggle's* state, not whether the rail is on - /// screen: on the home page there are no tabs to list, but the command - /// still flips this flag, so the title has to describe that. pub rail_collapsed: bool, - /// This window's `Tty7App::right_panel_visible`. pub right_panel_visible: bool, } -/// A single palette entry: a label plus the action it triggers. #[derive(Clone)] pub struct Command { pub title: String, - /// Optional dimmed secondary text on the right of the title (e.g. a saved - /// profile's `user@host`, or `(~/.ssh/config)` for an alias). pub subtitle: Option<String>, pub kind: CommandKind, pub group: CommandGroup, @@ -451,43 +328,23 @@ impl Command { title: title.into(), subtitle: None, kind, - // Overwritten by `base_commands`, which files every entry; the - // default only matters for the sub-lists, which render ungrouped. group: CommandGroup::Application, } } - /// Attach a dimmed subtitle rendered to the right of the title. pub fn with_subtitle(mut self, subtitle: impl Into<String>) -> Self { self.subtitle = Some(subtitle.into()); self } - /// File this command under a group. The dynamic entries the host appends - /// (saved SSH profiles, "Switch to Tab: …") have to say where they belong - /// or they'd all land in the default band. pub fn in_group(mut self, group: CommandGroup) -> Self { self.group = group; self } - /// The static commands available regardless of how many tabs exist. The - /// caller appends the dynamic SSH-profile and "Switch to Tab: …" entries. - /// - /// Titles follow the grammar documented at the top of this module. Several - /// are *stateful*: a command that flips something reads as the outcome it - /// will produce right now ("Hide Left Sidebar" when the rail is out), which - /// is why this needs `cx` and the calling window's [`ChromeState`]. - /// - /// The held-key font zoom (⌘+/⌘−) is deliberately absent — stepping it needs - /// a re-open per press, so it makes a poor palette citizen; only the - /// one-shot Reset is worth a slot. pub fn base_commands(cx: &App, chrome: ChromeState) -> Vec<Command> { use CommandKind::*; let cfg = cx.global::<Config>(); - // The tab bar's side is a genuine app-wide setting, so it comes off the - // config; the rail's collapse flag and the right panel's visibility do - // not (see `ChromeState`). let tab_bar_left = cfg.tab_bar_position == TabBarPosition::Left; let sidebar_hidden = chrome.rail_collapsed || !tab_bar_left; let right_panel_open = chrome.right_panel_visible; @@ -537,8 +394,6 @@ impl Command { ]; let view = [ - // Stateful titles: what the command will do from here, not the name - // of the switch it throws. Command::new( if sidebar_hidden { "Show Left Sidebar" @@ -556,8 +411,6 @@ impl Command { ToggleRightPanel, ), Command::new("Show Code Panel", ToggleCodePanel), - // Was "Toggle Tab Sidebar", one row away from "Toggle Left Sidebar" - // and indistinguishable from it. Command::new( if tab_bar_left { "Tab Bar: Move to Top" @@ -582,7 +435,6 @@ impl Command { ]; let terminal = [ - // Was "Clear", which never said what it cleared. Command::new("Clear Scrollback", ClearTerminal), Command::new("Find in Terminal…", FindInTerminal), Command::new("Find Next", FindNext), @@ -596,7 +448,6 @@ impl Command { let ssh = [ Command::new("SSH: Add Connection…", OpenSshConnectInput), Command::new("SSH: Manage Profiles…", OpenSshProfiles), - // Was "Reconnect SSH Session" — the one bare sibling among five. Command::new("SSH: Reconnect Session", RestartSshSession), Command::new("SSH: Remote Files", ToggleSftp), Command::new("SSH: Port Forwarding", ShowSshForwards), @@ -610,8 +461,6 @@ impl Command { ]; let application = [ - // Was "Open Settings" while the menu bar, the tray and the home page - // all said "Settings" — four names for one destination. Command::new("Settings…", OpenSettings), Command::new("Keyboard Shortcuts", ShowKeyboardShortcuts), Command::new("About tty7", About), @@ -638,10 +487,6 @@ impl Command { out } - /// The theme-picker sub-list: one entry per built-in preset, in the presets' - /// display order. Confirming one emits `SetTheme(i)`, which applies that - /// preset. The active theme is marked with a check so the list doubles as a - /// "which theme am I on?" indicator. pub fn theme_commands(cx: &App) -> Vec<Command> { let active = crate::ui::theme::effective_preset_id(cx); crate::ui::presets::all(cx) @@ -668,15 +513,6 @@ impl Command { } } -/// Score how well `query` matches `text`, or `None` when it doesn't match at -/// all. Higher is better; an empty query scores every candidate 0. -/// -/// The rule is still "every character of the query appears in order", but the -/// old boolean version left results in catalog order, so typing `sr` put "New -/// Tab" (**s**plit… no — the first row whose letters happened to line up) above -/// "Split Right". Scoring adds what makes a palette feel like it read your -/// mind: matches on word boundaries and runs of adjacent characters count for -/// much more than letters scattered through a long title. pub fn fuzzy_score(query: &str, text: &str) -> Option<i32> { let needle: Vec<char> = query .chars() @@ -705,9 +541,6 @@ pub fn fuzzy_score(query: &str, text: &str) -> Option<i32> { continue; } score += 1; - // Start of the string, or of a word: "sr" → "**S**plit **R**ight" is - // what the user meant, and it must outrank the same letters buried - // mid-word somewhere else. let word_start = i == 0 || !hay[i - 1].is_alphanumeric(); if word_start { score += 12; @@ -733,15 +566,10 @@ pub fn fuzzy_score(query: &str, text: &str) -> Option<i32> { } else if hay.starts_with(&needle) { score += 50; } - // Among equally good matches, prefer the shorter title: "Copy" should beat - // "Copy Working Directory" for the query "copy". score -= (hay.len() as i32) / 6; Some(score) } -/// The best score for a command against `query`: its title, or its subtitle at -/// a discount (a subtitle hit is a weaker signal of intent than a title hit, -/// but typing a hostname should still find the profile row it belongs to). fn command_score(query: &str, cmd: &Command) -> Option<i32> { let title = fuzzy_score(query, &cmd.title); let subtitle = cmd @@ -755,29 +583,17 @@ fn command_score(query: &str, cmd: &Command) -> Option<i32> { } } -/// One rendered band of the list: an optional header plus its rows. A search -/// collapses everything into a single header-less section ranked by score. #[derive(Clone)] struct Section { title: Option<SharedString>, commands: Vec<Command>, } -/// Feeds the command catalog to gpui-component's `ListState`. It keeps the full -/// catalog plus the sections matching the current query, re-filtering in -/// `perform_search` whenever the search input changes. pub struct PaletteDelegate { - /// The full catalog: static commands followed by per-tab switch entries. commands: Vec<Command>, - /// Exactly what the list renders, in render order. sections: Vec<Section>, input: Option<PaletteInput>, - /// Whether this is the root catalog: grouped when idle, and a query that - /// parses as `user@host[:port]` injects live "Connect to …" / "Save … as - /// profile" rows so QuickConnect shares the one entry box (PRD §6.2 ①). quick_connect_root: bool, - /// Index of the highlighted row, mirrored from the list's own selection so - /// `render_item` can mark it. `None` when nothing matches. selected: Option<IndexPath>, } @@ -800,8 +616,6 @@ impl PaletteDelegate { } } - /// The root delegate: grouped headers while idle, QuickConnect rows on a - /// host-like query. pub fn root(commands: Vec<Command>, cx: &App) -> Self { let mut this = Self { quick_connect_root: true, @@ -811,13 +625,6 @@ impl PaletteDelegate { this } - /// The idle (empty-query) layout: a Recent band built from - /// [`Config::command_frecency`], then one band per [`CommandGroup`]. - /// - /// Recent exists because the catalog's order is authored, not personal: the - /// first screenful used to be whatever was typed first — four Focus Pane - /// directions and four Resize Pane directions — while Change Theme and - /// Settings sat below the fold. fn grouped_sections(&self, cx: &App) -> Vec<Section> { let cfg = cx.global::<Config>(); let now = crate::core::config::unix_now(); @@ -859,14 +666,6 @@ impl PaletteDelegate { sections } - /// The QuickConnect rows for a query at the root, if it parses as a target. - /// - /// Beyond parsing, the query must *look like* a connect target — contain - /// `@`, `:` or `.` (`user@host`, `host:port`, an FQDN/IP; `ssh://` and - /// bracketed IPv6 both carry a `:`). A bare word like "java" parses as a - /// valid hostname too, but injecting these rows for every word would pin - /// them above all command searches; bare short names keep the SSH Connect - /// input as their path. fn quick_connect_commands(query: &str) -> Vec<Command> { if !query.contains(['@', ':', '.']) { return Vec::new(); @@ -902,8 +701,6 @@ impl PaletteDelegate { } } - /// The command kind at the given index path, if any. Called by `app.rs` - /// when the list confirms a selection. pub fn command_at(&self, ix: IndexPath) -> Option<CommandKind> { self.sections .get(ix.section)? @@ -912,12 +709,10 @@ impl PaletteDelegate { .map(|c| c.kind.clone()) } - /// The currently highlighted command, if any (for the ⌘⏎ / → edit gesture). pub fn selected_command(&self) -> Option<CommandKind> { self.selected.and_then(|ix| self.command_at(ix)) } - /// The first selectable row, or `None` when nothing matched. fn first_row(&self) -> Option<IndexPath> { let section = self.sections.iter().position(|s| !s.commands.is_empty())?; Some(IndexPath::new(0).section(section)) @@ -938,8 +733,6 @@ impl ListDelegate for PaletteDelegate { .unwrap_or(0) } - /// Re-filter the catalog against the live query and reset the highlight to - /// the first match. fn perform_search( &mut self, query: &str, @@ -952,7 +745,6 @@ impl ListDelegate for PaletteDelegate { commands: vec![Command::ssh_connect_command(query)], }]; } else if query.trim().is_empty() { - // Idle: the grouped map of the app (root), or the sub-list as-is. self.sections = if self.quick_connect_root { self.grouped_sections(cx) } else { @@ -962,19 +754,13 @@ impl ListDelegate for PaletteDelegate { }] }; } else { - // Searching: one flat, header-less band ranked by match quality. - // Headers would only get in the way of "type three letters, hit - // Enter", and the ranking already puts the best row first. let mut scored: Vec<(i32, Command)> = self .commands .iter() .filter_map(|c| command_score(query, c).map(|s| (s, c.clone()))) .collect(); - // Stable sort, so equal scores keep catalog order. scored.sort_by_key(|(score, _)| std::cmp::Reverse(*score)); let mut commands: Vec<Command> = Vec::new(); - // At the root, a query that parses as a connect target leads with - // QuickConnect rows (PRD §6.2 ①), above the ranked catalog. if self.quick_connect_root { commands.extend(Self::quick_connect_commands(query)); } @@ -996,9 +782,6 @@ impl ListDelegate for PaletteDelegate { ) -> Option<impl IntoElement> { let title = self.sections.get(section)?.title.clone()?; Some( - // Same fixed height as a row: the card's viewport is sized to a - // whole number of `PALETTE_ROW_H` units, and a header of any other - // height would leave the last visible row sliced by the card edge. h_flex() .h(px(PALETTE_ROW_H)) .px(px(11.)) @@ -1014,8 +797,6 @@ impl ListDelegate for PaletteDelegate { _window: &mut Window, cx: &mut Context<ListState<Self>>, ) -> impl IntoElement { - // A blank card reads as a hang. Name the miss and point at the one - // thing this box does besides run commands. v_flex() .py_8() .gap_1() @@ -1038,23 +819,16 @@ impl ListDelegate for PaletteDelegate { ) -> Option<Self::Item> { let cmd = self.sections.get(ix.section)?.commands.get(ix.row)?.clone(); - // Read the colours we need as Copy values, then release the theme borrow - // so we can borrow `cx` again for the keybinding lookup below. let (kbd_bg, border, muted) = { let t = cx.theme(); (t.secondary.opacity(0.6), t.border, t.muted_foreground) }; - // Shortcut hint: the effective keystroke for this command, rendered as - // small keycaps on the right — the Raycast/VSCode convention that makes a - // command palette feel professional and teaches the shortcut in passing. let keys = cmd .kind .key_spec(cx) .map(|spec| crate::ui::keymap::key_tokens(&spec)); - // Title, with an optional dimmed subtitle to its right (a profile's - // `user@host`, or `(~/.ssh/config)` for an alias). let mut left = h_flex().items_center().gap_2().child(cmd.title.clone()); if let Some(subtitle) = cmd.subtitle.clone() { left = left.child(div().text_xs().text_color(muted).child(subtitle)); @@ -1065,8 +839,6 @@ impl ListDelegate for PaletteDelegate { .items_center() .justify_between() .child(left); - // Editable rows (saved profiles, quick-connect) advertise the ⌘⏎ / → - // edit gesture with a subtle trailing hint (PRD §6.2 ①). if cmd.kind.edit_variant().is_some() { row = row.child(div().text_xs().text_color(muted).child("→ edit")); } @@ -1090,15 +862,8 @@ impl ListDelegate for PaletteDelegate { } Some( - // Keyed by section *and* row: with grouped sections a bare row index - // repeats across bands, and duplicate element ids make the list - // reuse the wrong row's state. ListItem::new(("palette-row", ix.section * 1000 + ix.row)) .selected(Some(ix) == self.selected) - // Fixed-height, dense rows (see `PALETTE_ROW_H`: the card's - // list viewport is sized to a whole number of rows). The 5px - // side margin + 6px radius turn the highlight into the same - // inset pill the context menu / dropdown use. .h(px(PALETTE_ROW_H)) .mx(px(5.)) .rounded(px(6.)) @@ -1118,11 +883,8 @@ impl ListDelegate for PaletteDelegate { } } -/// What the palette tells its host (`Tty7App`) when the user acts on it. pub enum PaletteEvent { - /// A command was chosen; the host should close the palette and run it. Confirm(CommandKind), - /// The palette was dismissed (Esc or click outside) with nothing chosen. Dismiss, } @@ -1133,23 +895,10 @@ enum PaletteMenu { SshConnect, } -/// The command palette as a self-contained view. It owns the `ListState` -/// (search input, fuzzy filter, keyboard nav) and the scrim/card overlay -/// chrome, and emits a [`PaletteEvent`] when the user confirms or dismisses. -/// The host builds the root catalog and executes the chosen command, so this -/// view stays ignorant of what most commands do. Submenus are two-level flows -/// the palette drives internally: picking an opener swaps the list to that -/// catalog, Esc steps back to the root, and only the final command reaches the -/// host. pub struct PaletteView { list: Entity<ListState<PaletteDelegate>>, - /// The root catalog, kept so Esc inside a sub-list can restore it instead - /// of dismissing the whole palette. root: Vec<Command>, - /// Which catalog the palette is currently showing. menu: PaletteMenu, - /// Keeps the *current* list's event subscription alive. Replaced on every - /// [`show`](Self::show) (root ⇄ sub-list) so it always targets the live list. _sub: Subscription, } @@ -1165,10 +914,6 @@ impl PaletteView { } } - /// Build a fresh `ListState` for `commands` and focus its search input. - /// gpui-component supplies the search box, fuzzy filtering, ↑/↓ navigation - /// and Enter/Esc; focusing the input keeps keystrokes off the terminal PTY - /// until the palette closes. fn build_list( commands: Vec<Command>, window: &mut Window, @@ -1177,8 +922,6 @@ impl PaletteView { Self::build_list_with_delegate(PaletteDelegate::new(commands), window, cx) } - /// The root list: grouped while idle, and its delegate injects live - /// QuickConnect rows for a host-like query. fn build_root_list( commands: Vec<Command>, window: &mut Window, @@ -1198,10 +941,6 @@ impl PaletteView { list } - /// Swap the visible list to `commands` (root ⇄ sub-list). Recreating - /// the `ListState` from scratch — rather than mutating the delegate in - /// place — hands us a cleared search box, reset selection and fresh row - /// cache for free, sidestepping the list's internal query/selection caching. fn show(&mut self, commands: Vec<Command>, window: &mut Window, cx: &mut Context<Self>) { let list = Self::build_list(commands, window, cx); self._sub = cx.subscribe_in(&list, window, Self::on_list_event); @@ -1224,8 +963,6 @@ impl PaletteView { } } - /// Read the currently highlighted command's "edit" variant, if any — the - /// target of the ⌘⏎ / → gesture on a profile / quick-connect row. fn selected_edit_command(&self, cx: &App) -> Option<CommandKind> { self.list .read(cx) @@ -1234,8 +971,6 @@ impl PaletteView { .and_then(|k| k.edit_variant()) } - /// Translate the current list's confirm/cancel into either a host-facing - /// event or an in-place transition into/out of a sub-list. fn on_list_event( &mut self, list: &Entity<ListState<PaletteDelegate>>, @@ -1247,8 +982,6 @@ impl PaletteView { ListEvent::Confirm(ix) => { let kind = list.read(cx).delegate().command_at(*ix); match kind { - // A submenu opener never reaches the host: it swaps this - // palette to another command catalog and stays open. Some(CommandKind::OpenThemePicker) => { self.menu = PaletteMenu::Theme; let themes = Command::theme_commands(cx); @@ -1258,11 +991,6 @@ impl PaletteView { self.menu = PaletteMenu::SshConnect; self.show_ssh_connect(window, cx); } - // Unlike the other openers, this one *leaves*: switching - // workspace has its own surface now (`ui::switcher`), which - // groups by machine and carries per-row actions a palette - // list cannot. Emitting hands the host the job of closing - // this and opening that. Some(kind @ CommandKind::OpenWorkspacePicker) => { cx.emit(PaletteEvent::Confirm(kind)) } @@ -1271,8 +999,6 @@ impl PaletteView { None => cx.emit(PaletteEvent::Dismiss), } } - // Esc: from the sub-list, step back to the root catalog; from the - // root, dismiss the palette. ListEvent::Cancel => { if self.menu != PaletteMenu::Root { self.menu = PaletteMenu::Root; @@ -1292,51 +1018,26 @@ impl PaletteView { impl EventEmitter<PaletteEvent> for PaletteView {} -/// Fixed command-row height (see `render_item`). The list viewport must hold a -/// whole number of rows, or the card's bottom edge cuts the last one mid-height. -/// Section headers are pinned to the same height for the same reason. const PALETTE_ROW_H: f32 = 30.; -/// Rows visible before the list scrolls. const PALETTE_VISIBLE_ROWS: f32 = 12.; -/// How many entries the idle "Recent" band shows. Small on purpose: it's a -/// shortcut to the two or three things you actually repeat, not a history log. const RECENT_ROWS: usize = 5; impl Render for PaletteView { - /// The centered overlay: a dim full-window scrim plus the command card. The - /// card just frames gpui-component's `List`, which renders its own search - /// input and the filtered, scrollable, keyboard-driven rows. fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { let theme = cx.theme(); let (border, popover) = (theme.border, theme.popover); - // The scrim is the shared one (see `presets::Surfaces::scrim`), the same - // dim the workspace switcher opens over. What it replaces was a wash of - // the window's *own* colour, which barely moved the window — so a card - // lifted 5% off it landed at nearly the same value, and both overlays - // read as a hole in the screen rather than a card over it. let scrim = crate::ui::presets::scrim_fill(cx); - // The list viewport holds exactly `PALETTE_VISIBLE_ROWS` fixed-height - // rows plus the list's own 4px top padding (`py_1` below, which scrolls - // with the content) — any other height leaves the last visible row cut - // mid-height at the card's bottom edge. The card wraps its content (no - // max_h of its own) and adds `pb_1` so that row still clears the - // rounded corners. let list_max_h = px(PALETTE_ROW_H * PALETTE_VISIBLE_ROWS + 4.); let card = v_flex() .w(px(560.)) .bg(popover) .border_1() .border_color(border) - // 10px radius + the floatier shadow match the context menu / - // dropdown panel (see the fork's `PopupMenu`). .rounded(px(10.)) .shadow_xl() .overflow_hidden() .pb_1() - // `py_1` (not `p_1`): the search input keeps its full-bleed width; - // the rows inset themselves into rounded pills (see `render_item`), - // Spotlight-style, matching the context menu's highlight language. .child( List::new(&self.list) .search_placeholder(self.search_placeholder()) @@ -1344,8 +1045,6 @@ impl Render for PaletteView { .max_h(list_max_h), ); - // Full-window scrim; clicking the empty area dismisses the palette (the - // card itself is occluded so its clicks don't bubble here). div() .absolute() .inset_0() @@ -1354,10 +1053,6 @@ impl Render for PaletteView { .justify_center() .pt(px(120.)) .bg(scrim) - // ⌘⏎ or → on a highlighted profile / quick-connect row opens its - // editor instead of connecting (PRD §6.2 ①). Captured on the scrim - // (an ancestor of the focused search box) so it fires before the list - // acts on a bare Enter. Plain Enter / navigation keys fall through. .on_key_down(cx.listener(|this, ev: &gpui::KeyDownEvent, _window, cx| { let ks = &ev.keystroke; let is_edit_gesture = (ks.key == "enter" && ks.modifiers.platform) @@ -1383,7 +1078,6 @@ impl Render for PaletteView { mod tests { use super::*; - /// Titles of the QuickConnect rows injected for a root-palette query. fn row_titles(query: &str) -> Vec<String> { PaletteDelegate::quick_connect_commands(query) .into_iter() @@ -1421,7 +1115,6 @@ mod tests { #[test] fn host_like_but_unparsable_gets_no_rows() { - // Contains ':' but the port segment is invalid → parse fails. assert!(row_titles("java:99999").is_empty()); assert!(row_titles("@").is_empty()); } @@ -1437,13 +1130,9 @@ mod tests { assert_eq!(fuzzy_score("thgir", "Split Right"), None); } - /// The ranking's whole job: word-initials and prefixes beat letters - /// scattered through a longer title. #[test] fn word_initials_outrank_scattered_letters() { let target = fuzzy_score("sr", "Split Right").expect("matches"); - // "Se...r" — an s and a later r, neither on a word boundary after the - // first, in a longer title. let scattered = fuzzy_score("sr", "SSH: Manage Profiles…").expect("matches"); assert!( target > scattered, @@ -1461,8 +1150,6 @@ mod tests { ); } - /// A subtitle hit still finds the row, but never outranks a title hit — - /// typing a hostname should reach the profile whose subtitle carries it. #[test] fn subtitle_matches_are_found_but_discounted() { let cmd = Command::new("prod-web", CommandKind::NewTab) @@ -1473,9 +1160,6 @@ mod tests { assert!(title_hit > subtitle_hit); } - /// Every command that can be filed under Recent needs a stable id, and no - /// two commands may share one — a collision would make the Recent band - /// promote the wrong row. #[test] fn stable_ids_are_unique() { let mut seen = std::collections::HashSet::new(); @@ -1502,7 +1186,6 @@ mod tests { } } - /// Instance-specific commands must stay out of the frecency store. #[test] fn dynamic_commands_have_no_id() { assert!(CommandKind::ActivateTab(2).id().is_none()); diff --git a/src/ui/pane.rs b/src/ui/pane.rs index 7d07a0a0..6852576e 100644 --- a/src/ui/pane.rs +++ b/src/ui/pane.rs @@ -1,9 +1,3 @@ -//! A binary split-pane tree for a single tab. Each leaf is a terminal; splits -//! divide the available space along an axis at an adjustable ratio (default -//! 50/50, draggable via the divider between the two children). The tree is small -//! and mutated in place (split / close-and-collapse), and rendered recursively -//! with flex. - use std::cell::Cell; use std::rc::Rc; @@ -14,38 +8,17 @@ use gpui_component::ActiveTheme as _; use crate::terminal::view::TerminalView; use crate::ui::pending_pane::PendingPane; -/// Legal band for a split's `a`-child ratio; keeps both panes usable. const MIN_RATIO: f32 = 0.1; const MAX_RATIO: f32 = 0.9; -/// Thickness (px) of the draggable divider between two split children. const DIVIDER_THICKNESS: f32 = 5.; -/// What occupies one leaf of the tree. -/// -/// A pane is a terminal in every state the user cares about — but a *remote* -/// one is not a terminal for the first few hundred milliseconds of its life, -/// because building it means waiting on another computer. Rather than block the -/// window for that (which is what happened before; see -/// [`ui::pending_pane`](crate::ui::pending_pane)), the slot goes into the tree -/// straight away and holds the wait. -/// -/// The tree itself is indifferent to which variant a leaf holds: splitting, -/// closing, geometry and directional focus all work off identity and shape. -/// Only [`render`](Pane::render) and the handful of places that genuinely need -/// a *terminal* — writing input, saving the session, killing a pane — care, and -/// those ask with [`terminal`](PaneSlot::terminal). #[derive(Clone)] pub enum PaneSlot { Ready(Entity<TerminalView>), - /// Still connecting, or failed and offering a retry. Connecting(Entity<PendingPane>), } impl PaneSlot { - /// Identity, for the by-identity tree operations. Both variants are gpui - /// entities, so one id space covers them and a slot keeps the same identity - /// across the swap only if the caller asks for it (it does not — the swap - /// is `replace_leaf`, matched on the *pending* id). pub fn entity_id(&self) -> gpui::EntityId { match self { PaneSlot::Ready(v) => v.entity_id(), @@ -53,12 +26,6 @@ impl PaneSlot { } } - /// The terminal, or `None` while this slot is still connecting. - /// - /// Deliberately an `Option` rather than something that waits: every caller - /// of this is answering a question about *now* (what is focused, what to - /// save, where to send this keystroke), and "there is no terminal here yet" - /// is a real answer to all of them. pub fn terminal(&self) -> Option<&Entity<TerminalView>> { match self { PaneSlot::Ready(v) => Some(v), @@ -66,13 +33,6 @@ impl PaneSlot { } } - /// Whether focus is inside this slot. - /// - /// `contains_focused`, not `is_focused`: a leaf is "active" when its - /// terminal surface *or any descendant* holds focus. The inline input - /// editor is a child with its own focus handle, so while the shell idles at - /// its prompt focus lives there, not on the terminal's own handle — an - /// exact `is_focused` check would miss the active pane. pub fn contains_focused(&self, window: &Window, cx: &App) -> bool { match self { PaneSlot::Ready(v) => v.read(cx).focus_handle.contains_focused(window, cx), @@ -80,9 +40,6 @@ impl PaneSlot { } } - /// This slot's focus handle, so a connecting pane can be focused like any - /// other — a tab whose only pane is still connecting must not be a tab with - /// nowhere for focus to go. pub fn focus_handle(&self, cx: &App) -> gpui::FocusHandle { match self { PaneSlot::Ready(v) => v.read(cx).focus_handle.clone(), @@ -91,28 +48,18 @@ impl PaneSlot { } } -/// The leaf payload is generic (defaulting to the real pane slot) so the pure -/// tree logic can be exercised in tests with plain values; at runtime `Pane` is -/// always `Pane<PaneSlot>`. pub enum Pane<L = PaneSlot> { Leaf(L), Split { axis: Axis, a: Box<Pane<L>>, b: Box<Pane<L>>, - /// Fraction of the split occupied by `a` (clamped to `MIN..=MAX_RATIO`). - /// Stored in a shared cell so the divider's drag closure can update it - /// without having to locate this node by path in the tree. ratio: Rc<Cell<f32>>, - /// Whether the divider is currently being dragged. Lives in the node so - /// the in-progress drag survives the re-renders it triggers. dragging: Rc<Cell<bool>>, }, - /// Transient placeholder used only while collapsing a split; never rendered. Empty, } -/// A direction for pane focus / resize, mapped from the arrow-key actions. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum Dir { Left, @@ -122,8 +69,6 @@ pub enum Dir { } impl Dir { - /// The split axis this direction operates along: Left/Right divide width - /// (a horizontal split), Up/Down divide height (a vertical split). fn axis(self) -> Axis { match self { Dir::Left | Dir::Right => Axis::Horizontal, @@ -131,16 +76,11 @@ impl Dir { } } - /// Whether this direction *grows* the focused pane (Right/Down) as opposed - /// to shrinking it (Left/Up). fn grows(self) -> bool { matches!(self, Dir::Right | Dir::Down) } } -/// A leaf's normalized rectangle within the tab (the whole tab is the unit -/// square `0,0 → 1,1`). Derived purely from split axes and ratios, so directional -/// focus is a geometry query independent of the actual pixel layout. #[derive(Clone, Copy, Debug, PartialEq)] pub struct Rect { pub x: f32, @@ -149,35 +89,21 @@ pub struct Rect { pub h: f32, } -/// Overlap length of two 1-D intervals `[a0, a0+alen)` and `[b0, b0+blen)` -/// (0 when they don't overlap). Used to score how well two panes line up on the -/// axis perpendicular to a move. fn overlap_1d(a0: f32, alen: f32, b0: f32, blen: f32) -> f32 { ((a0 + alen).min(b0 + blen) - a0.max(b0)).max(0.0) } -/// Result of attempting to close the focused leaf. pub enum CloseOutcome { - /// No focused leaf in this subtree. NotFound, - /// A leaf was removed and the tree collapsed around it. Collapsed, - /// This node *is* the focused leaf; the caller should drop it (e.g. close - /// the whole tab when it was the tab's only pane). RemoveSelf, } -/// Structural tree operations, independent of what a leaf holds. Matching a -/// specific leaf is expressed as a predicate so the focus- and identity-based -/// public API (below) can share one implementation with the tests. impl<L: Clone> Pane<L> { pub fn leaf(view: L) -> Self { Pane::Leaf(view) } - /// Construct a split node from two already-built children. Used when - /// rebuilding a saved session tree from disk: `ratio` is clamped to the - /// legal band and the divider starts un-dragged. pub fn split_node(axis: Axis, ratio: f32, a: Pane<L>, b: Pane<L>) -> Self { Pane::Split { axis, @@ -213,10 +139,6 @@ impl<L: Clone> Pane<L> { } } - /// The leaf satisfying `pred`, or the first leaf if none does. Used to - /// restore a remembered pane (matched by identity) when switching back to a - /// tab, gracefully degrading to the first leaf when that pane has since - /// closed. pub fn leaf_matching_or_first(&self, pred: impl Fn(&L) -> bool) -> Option<L> { self.leaves() .into_iter() @@ -224,10 +146,6 @@ impl<L: Clone> Pane<L> { .or_else(|| self.first_leaf()) } - /// Split the first leaf matching `is_target` along `axis`, inserting `new` - /// as the second child — or as the *first* when `before`, which is what - /// puts a pane to the left of / above its source rather than right of / - /// below it. Returns whether a matching leaf was found. fn split_leaf_where( &mut self, is_target: &impl Fn(&L) -> bool, @@ -255,9 +173,6 @@ impl<L: Clone> Pane<L> { } } - /// Replace the first leaf matching `is_target` with `new`, keeping the tree - /// shape (used for in-place SSH reconnect: the dead pane's slot gets a fresh - /// connection). Returns whether a match was found. fn replace_leaf_where(&mut self, is_target: &impl Fn(&L) -> bool, new: L) -> bool { match self { Pane::Leaf(v) => { @@ -275,8 +190,6 @@ impl<L: Clone> Pane<L> { } } - /// Remove the first leaf matching `is_target` (depth-first, `a` before - /// `b`), collapsing its parent split into the sibling. fn close_leaf_where(&mut self, is_target: &impl Fn(&L) -> bool) -> CloseOutcome { match self { Pane::Leaf(v) => { @@ -287,7 +200,6 @@ impl<L: Clone> Pane<L> { } } Pane::Split { .. } => { - // Recurse into `a` first (borrow scoped to this block). let a_outcome = if let Pane::Split { a, .. } = self { a.close_leaf_where(is_target) } else { @@ -295,7 +207,6 @@ impl<L: Clone> Pane<L> { }; match a_outcome { CloseOutcome::RemoveSelf => { - // Collapse: replace self with its `b` child. if let Pane::Split { b, .. } = std::mem::replace(self, Pane::Empty) { *self = *b; } @@ -324,8 +235,6 @@ impl<L: Clone> Pane<L> { } } - /// Push a mutable reference to every leaf payload, depth-first (`a` before - /// `b`), matching `leaves()` order. Used by `swap_leaf_indices`. fn collect_leaves_mut<'a>(&'a mut self, out: &mut Vec<&'a mut L>) { match self { Pane::Leaf(v) => out.push(v), @@ -337,10 +246,6 @@ impl<L: Clone> Pane<L> { } } - /// Swap the payloads of the leaves at ordered indices `i` and `j` (indices - /// into `leaves()`), leaving the tree *structure* untouched — only the two - /// terminals trade places. Returns whether the swap happened (false for - /// `i == j` or an out-of-range index). pub fn swap_leaf_indices(&mut self, i: usize, j: usize) -> bool { if i == j { return false; @@ -351,17 +256,11 @@ impl<L: Clone> Pane<L> { if hi >= refs.len() { return false; } - // Split so the two `&mut L` come from disjoint slices — the borrow - // checker won't let us index the same slice mutably twice. let (left, right) = refs.split_at_mut(hi); std::mem::swap(&mut *left[lo], &mut *right[0]); true } - /// The normalized rectangle of every leaf within the unit-square tab, in - /// `leaves()` order. A horizontal split divides width at its ratio (`a` left, - /// `b` right); a vertical split divides height (`a` top, `b` bottom) — the - /// same geometry `render` lays out with flex. pub fn leaf_rects(&self) -> Vec<(L, Rect)> { let mut out = Vec::new(); self.collect_rects( @@ -414,16 +313,11 @@ impl<L: Clone> Pane<L> { } } - /// The ordered index of the pane adjacent to leaf `from` in direction `dir`, - /// or `None` at the edge. tmux semantics: among panes whose edge sits on the - /// far side of `from` in that direction and which overlap it on the - /// perpendicular axis, pick the nearest edge, breaking ties by the largest - /// overlap. pub fn neighbor_in_direction(&self, from: usize, dir: Dir) -> Option<usize> { let rects = self.leaf_rects(); let f = rects.get(from)?.1; const EPS: f32 = 1e-4; - let mut best: Option<(usize, f32, f32)> = None; // (index, edge distance, overlap) + let mut best: Option<(usize, f32, f32)> = None; for (i, (_, c)) in rects.iter().enumerate() { if i == from { continue; @@ -434,8 +328,6 @@ impl<L: Clone> Pane<L> { Dir::Up => (f.y - (c.y + c.h), overlap_1d(f.x, f.w, c.x, c.w)), Dir::Down => (c.y - (f.y + f.h), overlap_1d(f.x, f.w, c.x, c.w)), }; - // Must lie in the requested direction (distance ≥ 0) and share some - // perpendicular extent, or it isn't a real neighbor. if dist < -EPS || overlap <= EPS { continue; } @@ -450,25 +342,15 @@ impl<L: Clone> Pane<L> { best.map(|(i, _, _)| i) } - /// Grow or shrink the focused pane along `dir` by `step`, by nudging the - /// ratio of its nearest enclosing split whose axis matches `dir`. `step` - /// grows the focused pane when `dir` is Right/Down and shrinks it when - /// Left/Up, regardless of which side of the split it sits on. Ratios stay - /// clamped to the legal band. Returns whether a matching split was found. - /// Takes `&self`: split ratios live in shared `Cell`s, so no `&mut` needed. pub fn resize_focused(&self, is_focused: &impl Fn(&L) -> bool, dir: Dir, step: f32) -> bool { let mut path: Vec<(&Pane<L>, bool)> = Vec::new(); if !self.focus_path(is_focused, &mut path) { return false; } let target_axis = dir.axis(); - // Nearest enclosing matching-axis split = deepest entry in the path. for (node, went_a) in path.iter().rev() { if let Pane::Split { axis, ratio, .. } = node { if *axis == target_axis { - // ratio is `a`'s share; +step enlarges `a`. Growing the - // focused pane means +step when it's in `a` and we grow, or - // in `b` and we shrink (== moves the divider toward `b`). let delta = if *went_a == dir.grows() { step } else { -step }; let r = (ratio.get() + delta).clamp(MIN_RATIO, MAX_RATIO); ratio.set(r); @@ -479,9 +361,6 @@ impl<L: Clone> Pane<L> { false } - /// Record the path of splits from the root down to the focused leaf, each - /// tagged with whether the leaf lies in the split's `a` (true) or `b` - /// (false) child. Returns whether the focused leaf was found. fn focus_path<'a>( &'a self, is_focused: &impl Fn(&L) -> bool, @@ -507,9 +386,7 @@ impl<L: Clone> Pane<L> { } } -/// Focus- and render-aware operations on the concrete pane tree. impl Pane<PaneSlot> { - /// The currently focused leaf, if any. pub fn focused_leaf(&self, window: &Window, cx: &App) -> Option<PaneSlot> { match self { Pane::Leaf(v) => v.contains_focused(window, cx).then(|| v.clone()), @@ -520,26 +397,15 @@ impl Pane<PaneSlot> { } } - /// The operation target as a *slot*: the focused leaf, or the first leaf if - /// none is focused. For the operations that work on a pane regardless of - /// whether its terminal has arrived — focusing it, closing it. pub fn focused_or_first_slot(&self, window: &Window, cx: &App) -> Option<PaneSlot> { self.focused_leaf(window, cx).or_else(|| self.first_leaf()) } - /// The operation target's *terminal*: the standard "act on the current - /// pane" selection rule, skipping a slot that has not finished connecting. - /// - /// Still the name every caller had, because it is still what they meant. - /// A pane that is mid-connect has no terminal, and every one of these - /// operations — write input, resize, kill, read the cwd — is a no-op on it - /// rather than something to queue up and replay. pub fn focused_or_first(&self, window: &Window, cx: &App) -> Option<Entity<TerminalView>> { self.focused_or_first_slot(window, cx) .and_then(|slot| slot.terminal().cloned()) } - /// Every leaf that is a live terminal, in `leaves()` order. pub fn terminals(&self) -> Vec<Entity<TerminalView>> { self.leaves() .iter() @@ -547,9 +413,6 @@ impl Pane<PaneSlot> { .collect() } - /// The pane adjacent to the focused one in direction `dir`, matched by - /// normalized geometry (tmux directional focus). `None` when nothing is - /// focused or the focused pane is already at that edge. pub fn neighbor_in_dir(&self, dir: Dir, window: &Window, cx: &App) -> Option<PaneSlot> { let focused = self.focused_leaf(window, cx)?; let leaves = self.leaves(); @@ -560,8 +423,6 @@ impl Pane<PaneSlot> { leaves.get(target).cloned() } - /// Resize the focused pane along `dir` by `step` (see the generic - /// `resize_focused`). Returns whether a matching split was adjusted. pub fn resize_focused_pane(&self, dir: Dir, step: f32, window: &Window, cx: &App) -> bool { let Some(focused) = self.focused_leaf(window, cx) else { return false; @@ -569,8 +430,6 @@ impl Pane<PaneSlot> { self.resize_focused(&|v| v.entity_id() == focused.entity_id(), dir, step) } - /// The ordered index of the focused leaf within `leaves()`, if any. Lets the - /// shell pick the swap partner (`index ± 1`) without re-walking the tree. pub fn focused_index(&self, window: &Window, cx: &App) -> Option<usize> { let focused = self.focused_leaf(window, cx)?; self.leaves() @@ -578,11 +437,6 @@ impl Pane<PaneSlot> { .position(|l| l.entity_id() == focused.entity_id()) } - /// Split a specific leaf (matched by entity identity) along `axis`, - /// inserting `new` as the second child — or the first when `before`, which - /// is how "Split Left" / "Split Up" differ from their opposites. The target - /// must be captured *before* creating `new`, since constructing a terminal - /// steals window focus. pub fn split_leaf( &mut self, target: gpui::EntityId, @@ -593,36 +447,18 @@ impl Pane<PaneSlot> { self.split_leaf_where(&|v| v.entity_id() == target, axis, before, new) } - /// Replace the leaf with entity id `target` with `new`, preserving the tree - /// shape. - /// - /// Two callers, and the second is why this takes a bare id rather than a - /// slot: the in-place SSH reconnect (PRD FR-E4) has the dead pane in hand, - /// but a pane that finished connecting has only the id of the placeholder - /// it is replacing — the placeholder entity may already be dropped by the - /// time the terminal lands. pub fn replace_leaf(&mut self, target: gpui::EntityId, new: PaneSlot) -> bool { self.replace_leaf_where(&|v| v.entity_id() == target, new) } - /// Remove the focused leaf, collapsing its parent split into the sibling. pub fn close_focused(&mut self, window: &Window, cx: &App) -> CloseOutcome { self.close_leaf_where(&|v| v.contains_focused(window, cx)) } - /// Remove a specific leaf (matched by entity identity), collapsing its - /// parent split into the sibling. Used when a pane closes for a reason - /// other than user focus — its child exited on its own — so the leaf to - /// remove is the exited one, wherever focus happens to be. pub fn close_leaf(&mut self, target: gpui::EntityId) -> CloseOutcome { self.close_leaf_where(&|v| v.entity_id() == target) } - /// Render the subtree. `dim_inactive` fades every leaf but the focused one; - /// the caller decides it — it is off for an unsplit tab (nothing to - /// distinguish) and off when the user turned `dim_inactive_panes` off. Kept - /// a parameter rather than a `Config` global read here so the tree stays - /// renderable without one, as the rest of this module is. pub fn render( &self, dim_inactive: bool, @@ -633,17 +469,10 @@ impl Pane<PaneSlot> { Pane::Empty => div().into_any_element(), Pane::Leaf(v) => { let focused = v.contains_focused(window, cx); - // No full border (it reads as a hard rectangle). div() .size_full() .relative() .overflow_hidden() - // Inactive panes fade back so the focused terminal reads as - // foreground without a hard border. Element opacity multiplies - // through the whole subtree (terminal glyphs + cell fills), - // unlike a background-tinted scrim which is near-invisible on a - // light theme (white on white). Applied to the container, so a - // click still lands on the terminal and focuses it. .when(dim_inactive && !focused, |d| d.opacity(0.55)) .map(|d| match v { PaneSlot::Ready(t) => d.child(t.clone()), @@ -659,21 +488,13 @@ impl Pane<PaneSlot> { dragging, } => { let row = *axis == Axis::Horizontal; - // Current ratio for `a`, always within the legal band. let r = ratio.get().clamp(MIN_RATIO, MAX_RATIO); let idle = cx.theme().border; let active = cx.theme().drag_border; - // Per-frame cell carrying the split container's pixel bounds. It - // is filled by the backing canvas during prepaint and read by - // the drag listener to convert a pointer position into a ratio. - // Recreated each frame; only `dragging`/`ratio` persist. let container: Rc<Cell<Option<Bounds<Pixels>>>> = Rc::new(Cell::new(None)); - // Backing canvas: measures the container and installs - // window-level mouse listeners so a drag keeps tracking even - // when the pointer outruns the thin divider. let backing = canvas( { let container = container.clone(); @@ -684,7 +505,6 @@ impl Pane<PaneSlot> { let ratio = ratio.clone(); let dragging = dragging.clone(); move |_bounds, _state, window, _cx| { - // Track the pointer while the divider is held. window.on_mouse_event({ let container = container.clone(); let ratio = ratio.clone(); @@ -696,13 +516,7 @@ impl Pane<PaneSlot> { let Some(b) = container.get() else { return; }; - // Map the pointer onto a 0..1 ratio along - // the split axis (Pixels / Pixels -> f32). let span = if row { b.size.width } else { b.size.height }; - // A transiently zero-measured container would make - // the division `NaN`; `f32::clamp` passes `NaN` - // through (NaN comparisons are false), poisoning the - // stored ratio and `flex_grow(NaN)`. Skip instead. if span.as_f32() <= 0.0 { return; } @@ -716,11 +530,6 @@ impl Pane<PaneSlot> { window.refresh(); } }); - // End the drag on release — and persist the ratio - // it landed on. The drag itself only moves the - // shared cell; without this save the new ratio - // reached disk (and now the machine's tree) only as - // a passenger on some later structural change. window.on_mouse_event({ let dragging = dragging.clone(); move |_ev: &MouseUpEvent, _phase, window, cx| { @@ -741,9 +550,6 @@ impl Pane<PaneSlot> { .absolute() .size_full(); - // The draggable divider: a comfortable invisible hit-area holding - // a centered 1px hairline so the rule reads thin, not as a thick - // band. The line brightens on hover or while dragging. let line_color = if dragging.get() { active } else { idle }; let divider = div() .group("split-divider") @@ -778,7 +584,6 @@ impl Pane<PaneSlot> { .flex() .when(row, |d| d.flex_row()) .when(!row, |d| d.flex_col()) - // Backing measurer/listener sits behind the children. .child(backing) .child( div() @@ -809,20 +614,12 @@ impl Pane<PaneSlot> { mod tests { use super::*; - /// In tests a leaf is just an id: the tree logic only ever clones leaves - /// and asks a predicate whether one is the operation target. type TestPane = Pane<u32>; - /// Predicate matching the leaf with the given id (the test stand-in for - /// "is this the focused terminal" / "is this the split target"). fn is(id: u32) -> impl Fn(&u32) -> bool { move |v| *v == id } - /// Walk the tree asserting the structural invariants the live UI relies - /// on: no transient `Empty` placeholder survives an operation, every - /// split has two real children, and every stored ratio stays inside the - /// legal band. fn assert_well_formed(pane: &TestPane) { match pane { Pane::Leaf(_) => {} @@ -841,8 +638,6 @@ mod tests { } } - /// Split leaf `target`, inserting `new` as its second sibling, asserting - /// the target was found. fn split(pane: &mut TestPane, target: u32, axis: Axis, new: u32) { assert!( pane.split_leaf_where(&is(target), axis, false, new), @@ -850,8 +645,6 @@ mod tests { ); } - // Splitting a lone leaf must turn it into a split on the requested axis, - // with the original terminal kept first and an even 50/50 ratio. #[test] fn split_leaf_replaces_target_with_split_keeping_original_first() { let mut pane = TestPane::leaf(0); @@ -870,12 +663,8 @@ mod tests { assert_well_formed(&pane); } - // `before` is what makes "Split Left" / "Split Up" differ from their - // opposites: same axis, the new pane just takes the first slot. Only the - // targeted leaf moves — its siblings keep their order. #[test] fn split_leaf_before_puts_the_new_pane_first() { - // [0 | 1] -> split 1 horizontally with 2, before -> [0 | [2 | 1]] let mut pane = TestPane::leaf(0); split(&mut pane, 0, Axis::Horizontal, 1); assert!(pane.split_leaf_where(&is(1), Axis::Horizontal, true, 2)); @@ -896,11 +685,8 @@ mod tests { assert_well_formed(&pane); } - // A split must land on exactly the targeted leaf, leaving every other - // subtree untouched (guards against splitting the first leaf found). #[test] fn split_leaf_splits_only_the_matching_leaf() { - // [0 | 1] -> split 1 vertically with 2 -> [0 | [1 / 2]] let mut pane = TestPane::leaf(0); split(&mut pane, 0, Axis::Horizontal, 1); split(&mut pane, 1, Axis::Vertical, 2); @@ -926,8 +712,6 @@ mod tests { assert_well_formed(&pane); } - // A split aimed at a leaf that is not in the tree must report failure and - // leave the tree exactly as it was. #[test] fn split_leaf_reports_missing_target_without_changing_tree() { let mut pane = TestPane::leaf(0); @@ -937,8 +721,6 @@ mod tests { assert_well_formed(&pane); } - // Ratios restored from a saved session may be out of range; split_node - // must clamp them into the legal band so both panes stay usable. #[test] fn split_node_clamps_restored_ratio_into_legal_band() { for (given, expected) in [ @@ -956,11 +738,8 @@ mod tests { } } - // Leaf traversal drives pane cycling and session persistence: it must be - // depth-first with `a` before `b`, and first_leaf must agree with it. #[test] fn leaves_and_first_leaf_follow_depth_first_a_before_b_order() { - // [[0 / 3] | [1 / 2]] let mut pane = TestPane::leaf(0); split(&mut pane, 0, Axis::Horizontal, 1); split(&mut pane, 1, Axis::Vertical, 2); @@ -969,29 +748,19 @@ mod tests { assert_eq!(pane.first_leaf(), Some(0)); } - // Restoring a tab's remembered pane: `leaf_matching_or_first` returns the - // matched leaf when present, and degrades to the first leaf when the - // remembered pane has closed (predicate matches nothing). This is the pure - // core of the "switching tabs keeps the active pane" fix. #[test] fn leaf_matching_or_first_prefers_the_match_then_falls_back_to_first() { - // [0 | [1 / 2]] → leaves = [0, 1, 2] let mut pane = TestPane::leaf(0); split(&mut pane, 0, Axis::Horizontal, 1); split(&mut pane, 1, Axis::Vertical, 2); assert_eq!(pane.leaves(), vec![0, 1, 2]); - // A remembered pane that still exists is restored (not the first leaf). assert_eq!(pane.leaf_matching_or_first(is(2)), Some(2)); assert_eq!(pane.leaf_matching_or_first(is(1)), Some(1)); - // A remembered pane that has since closed falls back to the first leaf. assert_eq!(pane.leaf_matching_or_first(is(99)), Some(0)); - // On an empty tree there is nothing to restore or fall back to. assert_eq!(TestPane::Empty.leaf_matching_or_first(is(0)), None); } - // Closing the tab's only pane must not mutate the tree; the caller reacts - // to RemoveSelf by closing the whole tab. #[test] fn closing_the_root_leaf_defers_removal_to_the_caller() { let mut pane = TestPane::leaf(7); @@ -1002,8 +771,6 @@ mod tests { assert!(matches!(pane, Pane::Leaf(7))); } - // Closing the first child of a split must promote the second child to - // take the split's place, leaving no Empty placeholder behind. #[test] fn closing_first_child_promotes_second_child_to_root() { let mut pane = TestPane::leaf(0); @@ -1015,7 +782,6 @@ mod tests { assert!(matches!(pane, Pane::Leaf(1))); } - // Same as above, mirrored: closing the second child promotes the first. #[test] fn closing_second_child_promotes_first_child_to_root() { let mut pane = TestPane::leaf(0); @@ -1027,11 +793,8 @@ mod tests { assert!(matches!(pane, Pane::Leaf(0))); } - // Closing a nested leaf must collapse only its own parent split; the - // grandparent keeps its axis and (dragged) ratio. #[test] fn closing_nested_leaf_collapses_only_its_parent_split() { - // [1 |(0.3) [2 / 3]] -> close 2 -> [1 |(0.3) 3] let mut pane = TestPane::split_node( Axis::Horizontal, 0.3, @@ -1060,11 +823,8 @@ mod tests { assert_well_formed(&pane); } - // When the surviving sibling is itself a split, the whole subtree must be - // promoted intact, keeping its axis and ratio. #[test] fn closing_a_leaf_promotes_entire_sibling_subtree() { - // [[1 /(0.7) 2] | 3] -> close 3 -> [1 /(0.7) 2] let mut pane = TestPane::split_node( Axis::Horizontal, 0.5, @@ -1089,8 +849,6 @@ mod tests { assert_well_formed(&pane); } - // With no focused/matching leaf anywhere, close must be a no-op reporting - // NotFound (e.g. focus is in another tab). #[test] fn close_reports_not_found_and_leaves_tree_untouched() { let mut pane = TestPane::leaf(0); @@ -1103,8 +861,6 @@ mod tests { assert_well_formed(&pane); } - // Even if the predicate matches several leaves, exactly one close happens: - // the first match in `a`-before-`b` order (guards the short-circuit). #[test] fn close_removes_only_first_match_in_traversal_order() { let mut pane = TestPane::leaf(0); @@ -1118,10 +874,6 @@ mod tests { assert_well_formed(&pane); } - // Drive a deep nested split/close sequence against a flat model of the - // expected leaf order; after every step the tree must stay well-formed - // and agree with the model. (A split inserts the new leaf right after its - // target; a close removes exactly its target.) #[test] fn deep_split_close_sequence_preserves_invariants_and_leaf_order() { enum Op { @@ -1166,8 +918,6 @@ mod tests { } } - // Closing panes one by one must collapse down to a single leaf, and only - // the very last close switches to RemoveSelf (close-the-tab boundary). #[test] fn closing_down_to_the_last_pane_hits_remove_self_boundary() { let mut pane = TestPane::leaf(0); @@ -1195,8 +945,6 @@ mod tests { ); } - // The transient Empty placeholder (also used for the settings tab) must - // ignore every operation instead of panicking. #[test] fn empty_placeholder_ignores_all_operations() { let mut pane: TestPane = Pane::Empty; @@ -1210,7 +958,6 @@ mod tests { assert!(matches!(pane, Pane::Empty)); } - /// The rect for leaf `id` in a pane, by value. fn rect_of(pane: &TestPane, id: u32) -> Rect { pane.leaf_rects() .into_iter() @@ -1219,8 +966,6 @@ mod tests { .unwrap() } - /// Assert two rects match within floating-point tolerance (ratios multiply - /// out to values like 0.39999998, so exact equality is too strict). fn assert_rect(got: Rect, want: Rect) { let close = |a: f32, b: f32| (a - b).abs() < 1e-5; assert!( @@ -1232,12 +977,8 @@ mod tests { ); } - // Nested splits with non-even ratios must tile the unit square exactly: - // a horizontal split divides width, a nested vertical split divides its - // child's height, and the pieces stay gap-free and non-overlapping. #[test] fn leaf_rects_tile_the_unit_square_with_nested_ratios() { - // [0 |(0.25) [1 /(0.6) 2]] let pane = TestPane::split_node( Axis::Horizontal, 0.25, @@ -1271,7 +1012,6 @@ mod tests { h: 0.4, }, ); - // Rects come back in leaves() order. assert_eq!( pane.leaf_rects() .iter() @@ -1281,28 +1021,19 @@ mod tests { ); } - // Directional focus is edge-adjacency: right of 0 is 1, and from 1 the pane - // to the left is 0. A pane with no neighbor in a direction returns None. #[test] fn neighbor_in_direction_finds_the_adjacent_pane() { - // [0 | 1] let mut pane = TestPane::leaf(0); split(&mut pane, 0, Axis::Horizontal, 1); let idx = |id: u32| pane.leaves().iter().position(|v| *v == id).unwrap(); assert_eq!(pane.neighbor_in_direction(idx(0), Dir::Right), Some(idx(1))); assert_eq!(pane.neighbor_in_direction(idx(1), Dir::Left), Some(idx(0))); - // Nothing above/below in a purely horizontal split. assert_eq!(pane.neighbor_in_direction(idx(0), Dir::Up), None); assert_eq!(pane.neighbor_in_direction(idx(1), Dir::Right), None); } - // When several panes sit in the requested direction, the one with the - // largest perpendicular overlap wins (tmux's "line up with the cursor"). #[test] fn neighbor_in_direction_prefers_the_largest_overlap() { - // Left column is 0 (full height); right column is stacked [1 /(0.7) 2]. - // Moving right from 0 should land on 1 — it covers 70% of the shared - // edge versus 2's 30%. let pane = TestPane::split_node( Axis::Horizontal, 0.5, @@ -1313,8 +1044,6 @@ mod tests { assert_eq!(pane.neighbor_in_direction(idx(0), Dir::Right), Some(idx(1))); } - // Resize nudges the nearest matching-axis ancestor's ratio and always grows - // the focused pane on Right/Down, whichever side it's on. #[test] fn resize_grows_the_focused_pane_from_either_side() { let build = || TestPane::split_node(Axis::Horizontal, 0.5, Pane::Leaf(0), Pane::Leaf(1)); @@ -1322,36 +1051,27 @@ mod tests { Pane::Split { ratio, .. } => ratio.get(), _ => unreachable!(), }; - // Focus in `a` (left): Right grows a → ratio up. let p = build(); assert!(p.resize_focused(&is(0), Dir::Right, 0.05)); assert!((ratio(&p) - 0.55).abs() < 1e-6); - // Focus in `b` (right): Right grows b → ratio down. let p = build(); assert!(p.resize_focused(&is(1), Dir::Right, 0.05)); assert!((ratio(&p) - 0.45).abs() < 1e-6); - // Left shrinks the focused pane (focus in a → ratio down). let p = build(); assert!(p.resize_focused(&is(0), Dir::Left, 0.05)); assert!((ratio(&p) - 0.45).abs() < 1e-6); } - // A resize whose axis matches no ancestor split is a no-op: a purely - // horizontal split has no vertical divider to move. #[test] fn resize_without_a_matching_axis_is_a_noop() { let pane = TestPane::split_node(Axis::Horizontal, 0.5, Pane::Leaf(0), Pane::Leaf(1)); assert!(!pane.resize_focused(&is(0), Dir::Up, 0.05)); assert!(!pane.resize_focused(&is(0), Dir::Down, 0.05)); - // An unfocused/absent target also reports no-op. assert!(!pane.resize_focused(&is(99), Dir::Right, 0.05)); } - // Resize with a nested tree targets the *nearest* enclosing matching-axis - // split, not an outer one of the same axis. #[test] fn resize_targets_the_nearest_matching_axis_ancestor() { - // [0 |(0.5) [1 |(0.5) 2]] — two nested horizontal splits. let pane = TestPane::split_node( Axis::Horizontal, 0.5, @@ -1359,7 +1079,6 @@ mod tests { TestPane::split_node(Axis::Horizontal, 0.5, Pane::Leaf(1), Pane::Leaf(2)), ); assert!(pane.resize_focused(&is(1), Dir::Right, 0.05)); - // Inner split moved; outer untouched. match &pane { Pane::Split { ratio, b, .. } => { assert!( @@ -1380,21 +1099,16 @@ mod tests { } } - // Swapping two leaves trades their payloads but keeps the tree shape and - // leaf *positions* — only the values at those positions change. #[test] fn swap_leaf_indices_trades_payloads_in_place() { - // [[0 / 3] | [1 / 2]] → leaves = [0, 3, 1, 2] let mut pane = TestPane::leaf(0); split(&mut pane, 0, Axis::Horizontal, 1); split(&mut pane, 1, Axis::Vertical, 2); split(&mut pane, 0, Axis::Vertical, 3); assert_eq!(pane.leaves(), vec![0, 3, 1, 2]); - // Swap positions 0 and 2 (values 0 and 1). assert!(pane.swap_leaf_indices(0, 2)); assert_eq!(pane.leaves(), vec![1, 3, 0, 2]); assert_well_formed(&pane); - // No-op cases. assert!(!pane.swap_leaf_indices(1, 1)); assert!(!pane.swap_leaf_indices(0, 99)); assert_eq!(pane.leaves(), vec![1, 3, 0, 2]); diff --git a/src/ui/pending_pane.rs b/src/ui/pending_pane.rs index 3a8180b3..a54b037c 100644 --- a/src/ui/pending_pane.rs +++ b/src/ui/pending_pane.rs @@ -1,36 +1,3 @@ -//! The pane that is not a terminal yet. -//! -//! # Why this exists -//! -//! Building a pane is one blocking call — [`TerminalView::spawn_shell_terminal_in`] -//! — and for a *local* pane that is a unix-socket round trip to a daemon on the -//! same machine: microseconds, invisible, and it has been called straight from -//! the UI thread since before remote workspaces existed. -//! -//! For a **remote** pane the identical call opens a connection to the local -//! daemon, hands it a `RouteHeader`, and then waits while the daemon opens an -//! SSH channel to another computer (doing the whole handshake if nothing is -//! pooled yet) and the remote `tty7-server` answers a `Spawn` or `Attach`. -//! `connect_routed` says as much in its own doc — *"blocks for as long as the -//! setup takes … callers are already on a background thread"* — and the caller -//! that mattered, `ui::app::new_terminal`, was not. It runs inside a gpui input -//! callback. Restoring a four-pane remote workspace froze the whole window four -//! times over, and every remote new-tab and split froze it once. -//! -//! So a remote pane is now built in two halves: the slot goes into the tree -//! immediately holding one of these, the blocking half runs on the background -//! executor, and the slot is swapped for the real [`TerminalView`] when it -//! lands. See [`PaneSlot`](crate::ui::pane::PaneSlot). -//! -//! # What it does *not* change -//! -//! **A local pane still takes the old path, synchronously.** Not for lack of -//! generality — for honesty about what the user sees: a pane that is ready in -//! under a millisecond would otherwise paint one frame of a spinner on every -//! ⌘T, which is a flicker introduced to solve a problem local panes do not -//! have. The split is on [`PaneRoute`](crate::terminal::PaneRoute), the same -//! value that decides whether the connection carries a route header at all. - use std::time::Duration; use gpui::{ @@ -43,69 +10,30 @@ use gpui_component::{ActiveTheme as _, Icon, IconName, Sizable as _, h_flex, v_f use crate::daemon::protocol::ShellSpec; use crate::terminal::PaneWorkspace; -/// Everything needed to (re)run the blocking half of a pane spawn. -/// -/// Kept whole rather than as loose fields so a retry runs the *same* attempt -/// the first one did: a retry that quietly spawned a fresh pane where the first -/// tried to re-attach to a running one would silently abandon the user's -/// session — which is the single thing session restore exists to prevent. #[derive(Clone)] pub struct PendingSpawn { pub workspace: Option<PaneWorkspace>, pub working_directory: Option<std::path::PathBuf>, pub restore_pane: Option<u64>, pub shell: Option<ShellSpec>, - /// The coding agent this leaf was last seen running, its native session id - /// and the argv it was launched with — carried verbatim from the saved - /// session. - /// - /// Two jobs, both of which need the *fields* rather than a precomputed - /// resume line: - /// - /// 1. If `restore_pane` turns out to be gone, `land_pane` builds the - /// `--resume` command from them. Whether it is needed is only known on - /// the machine, one round trip away. - /// 2. A save that happens while this pane is still connecting writes them - /// straight back out ([`pane_to_session`](crate::ui::app)). Without - /// that, every such save silently erased the agent from the record — - /// and a workspace whose sessions were then ended had nothing left to - /// resume *from*, which is what made the resume look intermittent. pub agent: Option<crate::core::cli_agent::CLIAgent>, pub agent_session_id: Option<String>, pub agent_launch_argv: Option<Vec<String>>, - /// The workspace this pane is being created for — carried so the spawn that - /// finally lands (and any retry) names the same owner the synchronous local - /// path would have. pub owner: Option<crate::core::session::WorkspaceId>, - /// Inherited by the terminal this becomes, so a pane that arrives late - /// still matches the ones already on screen. pub font_size: f32, } -/// Where a pending pane is up to. pub enum PendingState { Connecting, - /// The attempt failed, with the reason as the user should read it. A - /// resting state: the slot keeps its place in the layout and - /// offers the next move rather than collapsing the split under the user. Failed(SharedString), } -/// Emitted when the user asks a failed pane to try again. The app owns the -/// respawn because only it can put the result back into the tree. pub struct RetryRequested; -/// A pane slot whose terminal has not arrived yet. pub struct PendingPane { - /// Its own handle, so focus, ⌘W and directional pane movement treat this - /// slot exactly like a terminal — the tab is not half-broken while one of - /// its panes is still connecting. pub focus_handle: FocusHandle, - /// What to say we are waiting on. The machine's name, when there is one; - /// a pane with no machine to name is one this view should not be showing. pub machine: SharedString, pub state: PendingState, - /// The attempt, kept for a retry. pub spawn: PendingSpawn, } @@ -125,15 +53,11 @@ impl PendingPane { } } - /// Land a failure. The reason is shown verbatim — it is the one produced by - /// `connect_routed`, which is written to name *which* of the four hops gave - /// up, and paraphrasing it here would throw that away. pub fn fail(&mut self, reason: impl Into<SharedString>, cx: &mut Context<Self>) { self.state = PendingState::Failed(reason.into()); cx.notify(); } - /// Back to waiting, for a retry. pub fn retrying(&mut self, cx: &mut Context<Self>) { self.state = PendingState::Connecting; cx.notify(); @@ -151,9 +75,6 @@ impl Render for PendingPane { let theme = cx.theme(); let (muted, dim) = (theme.muted_foreground, theme.muted_foreground.opacity(0.75)); - // On the window background, not a card: this *is* the pane, and a - // floating panel inside a split would read as a dialog over a terminal - // that isn't there. let body = match &self.state { PendingState::Connecting => v_flex() .items_center() @@ -189,10 +110,6 @@ impl Render for PendingPane { .text_color(theme.foreground) .child(format!("Couldn't reach {}", self.machine)), ) - // The hop that gave up, in full: a failure says which of - // "the daemon isn't running", "that machine refused us" and - // "the server over there is too old" it was, because they want - // completely different things from the user. .child( div() .text_xs() diff --git a/src/ui/perf.rs b/src/ui/perf.rs index e91f18fa..6d69d577 100644 --- a/src/ui/perf.rs +++ b/src/ui/perf.rs @@ -1,38 +1,16 @@ -//! Optional, label-keyed build-time profiling for the UI layer. Disabled unless -//! `TTY7_PROFILE` is set to a non-empty, non-`0` value (e.g. -//! `TTY7_PROFILE=1 cargo run`). -//! -//! Where [`crate::terminal::fps`] measures the *paint* cost of the terminal grid, -//! this measures how long a GPUI view spends *building* its element tree in -//! `render`, and — just as usefully — how often that `render` runs. A view whose -//! build is cheap but fires dozens of times a second (a runaway `cx.notify()` -//! loop) reads as high "calls/s" here even when each call is fast, which is -//! exactly the signal needed to tell "one expensive rebuild" apart from "a cheap -//! rebuild in a tight loop". -//! -//! It reports the CPU-side build cost only (assembling the element tree and -//! returning it); GPU paint is out of scope — pair with `TTY7_FPS` or -//! Instruments for the paint side. - use std::collections::HashMap; use std::sync::{Mutex, OnceLock}; use std::time::{Duration, Instant}; -/// Whether profiling is on. Read once from `TTY7_PROFILE` and cached. pub fn enabled() -> bool { static ON: OnceLock<bool> = OnceLock::new(); *ON.get_or_init(|| flag_enables(std::env::var("TTY7_PROFILE").ok().as_deref())) } -/// Whether a `TTY7_PROFILE` value (or its absence) turns profiling on: any -/// non-empty value except `0`. Split out so the semantics are testable without -/// depending on the ambient process environment. fn flag_enables(value: Option<&str>) -> bool { value.is_some_and(|v| !v.is_empty() && v != "0") } -/// One aggregation window of wall-clock time *in which building happened* — an -/// idle gap just stretches the reported window rather than reading as a low rate. const WINDOW: Duration = Duration::from_secs(1); struct Meter { @@ -52,9 +30,6 @@ impl Meter { } } - /// Fold one build in; when `now` crosses the window boundary, return the - /// aggregate report line and start a fresh window anchored at `now`. The - /// clock is injected so tests can cross windows without sleeping. fn record(&mut self, label: &str, now: Instant, build: Duration) -> Option<String> { self.calls += 1; self.total += build; @@ -82,17 +57,11 @@ fn meters() -> &'static Mutex<HashMap<&'static str, Meter>> { M.get_or_init(|| Mutex::new(HashMap::new())) } -/// Record one build's CPU-side duration under `label`. Emits an aggregate stderr -/// line per `label` roughly once per `WINDOW` of building time. No-op unless -/// [`enabled`]; callers still gate the surrounding `Instant::now()` on `enabled` -/// so a normal run pays nothing. pub fn record(label: &'static str, build: Duration) { let now = Instant::now(); let mut guard = meters().lock().unwrap(); let m = guard.entry(label).or_insert_with(|| Meter::new(now)); if let Some(line) = m.record(label, now, build) { - // Direct to stderr: the app never initialises a `log` backend, so - // `log::info!` here would be silently dropped. eprintln!("{line}"); } } @@ -140,8 +109,6 @@ mod tests { ) .is_none() ); - // Crossing the window boundary flushes the aggregate: 2 calls over 1.0s = - // 2.0 calls/s, build avg (2+6)/2 = 4ms, max 6ms. let flush_at = start + Duration::from_millis(1000); let line = m .record("render", flush_at, Duration::from_millis(6)) diff --git a/src/ui/presets.rs b/src/ui/presets.rs index bb172a31..a8306847 100644 --- a/src/ui/presets.rs +++ b/src/ui/presets.rs @@ -1,29 +1,3 @@ -//! The theme system: the serializable [`Theme`] seed model, the derived -//! shell-chrome [`Neutrals`], the interaction-state [`Surface`] ladders, the -//! [`Themes`] registry, and the loaders that turn built-in tables, user YAML -//! files, and imported iTerm2 schemes into concrete themes. -//! -//! A theme is a **minimal seed** — a background (solid or gradient), a -//! foreground, one accent, an optional cursor/selection, and the ANSI-16 -//! terminal set. Every other shell surface (borders, hover chips, sidebar, -//! command-palette list, selections) is *derived* from those by blending toward -//! the foreground (see [`Theme::neutrals`]), so any valid seed — built-in, -//! hand-written, or imported — yields a complete, internally consistent theme. -//! -//! Themes are **files, not constants**: the built-ins are embedded, but users -//! author their own as `~/.config/tty7/themes/*.yaml` (tty7's own schema) or drop -//! in an iTerm2 `*.itermcolors` scheme, which the loader imports on the fly. A -//! theme's light/dark brightness is *inferred* from its background luminance — -//! there is no `dark` field to set. -//! -//! # Interaction state -//! -//! Resting / hover / selected / pressed are a **first-class part of the theme**, -//! not something each widget re-derives. [`Theme::surface`] returns the state -//! ladder for whatever surface a widget paints on, and every rung is derived to -//! hit a *contrast ratio* against that surface rather than a fixed blend ratio — -//! see [`state`] for why that distinction is the whole point. - use std::path::PathBuf; use alacritty_terminal::vte::ansi::Rgb; @@ -32,9 +6,6 @@ use serde::Deserialize; use crate::terminal::palette::ActivePalette; -/// A background (or accent) paint: a flat color or a two-stop gradient. The -/// window background renders gradients for real (see `theme::window_background`); -/// every other consumer works from the representative [`Fill::color`]. #[derive(Debug, Clone, PartialEq)] pub enum Fill { Solid(u32), @@ -43,9 +14,6 @@ pub enum Fill { } impl Fill { - /// The single representative color used wherever one flat color is needed - /// (chrome derivation, the terminal's default cell background): the solid - /// itself, or a gradient's first stop. pub fn color(&self) -> u32 { match *self { Fill::Solid(c) => c, @@ -55,18 +23,12 @@ impl Fill { } } -/// An optional background image layered under the background fill. #[derive(Debug, Clone, PartialEq)] pub struct Image { pub path: PathBuf, - /// 0.0 (invisible) … 1.0 (opaque). pub opacity: f32, } -/// A single color theme — the seed the whole palette derives from. Colors are -/// `0xRRGGBB` literals. `dark` is *inferred* from `background` luminance (it -/// selects gpui-component's `ThemeMode` and flips how neutrals blend), never -/// authored. #[derive(Debug, Clone)] pub struct Theme { pub id: String, @@ -75,30 +37,15 @@ pub struct Theme { pub background: Fill, pub foreground: u32, pub accent: u32, - /// Cursor color. `None` derives it from `accent`. pub caret: Option<u32>, - /// Text/selection surface. `None` derives it from background/foreground. pub selection: Option<u32>, - /// Window opacity 0.0…1.0. `None` = fully opaque. Carried for the renderer. pub opacity: Option<f32>, - /// Blur the window background behind a translucent theme. Carried. pub blur: bool, - /// Optional background image, composited over the background fill at its own - /// opacity (the terminal and chrome paint on top). pub image: Option<Image>, pub ansi16: [(u8, u8, u8); 16], - /// The file this theme was loaded from, or `None` for a compiled-in built-in. - /// A theme with a path is user-owned and editable (see `fork_to_file` and the - /// in-app color editor); a built-in is read-only until duplicated. pub path: Option<PathBuf>, } -/// The shell-chrome palette derived from a theme's seed. Consumed by -/// `apply_theme` to paint gpui-component's `Theme`. -/// -/// These are the theme's *static* colors — the ones that mean the same thing -/// wherever they appear. Anything that varies with interaction state lives in a -/// [`Surface`] instead. #[derive(Debug, Clone)] pub struct Neutrals { pub background: u32, @@ -112,221 +59,75 @@ pub struct Neutrals { pub selection: u32, pub sidebar: u32, pub sidebar_fg: u32, - /// The seed accent, nudged until it can carry ink — see [`legible_accent`]. pub accent: u32, } -/// One semantic colour in the three shapes the UI actually needs it in. -/// -/// Splitting them is not ceremony: a red that is legible as *text* on the -/// background is a different red from one that works as a filled button, and the -/// text on that button is a third. Collapsing them is how a danger button ends up -/// with unreadable text, or a warning label ends up below AA. #[derive(Debug, Clone, Copy)] pub struct Semantic { - /// Text (or a small solid mark) on the window background. WCAG AA, 4.5:1. pub ink: u32, - /// A filled chip or button. The non-text floor, 3:1. pub fill: u32, - /// Text on top of `fill`. pub on_fill: u32, } -/// The status palette, derived from the theme's **own ANSI-16** rather than from -/// a fixed set of brand colours. -/// -/// Every theme already ships a red, green, yellow and cyan — they are what the -/// terminal in the same window paints with. Until this existed, gpui-component's -/// stock Tailwind values (`red-400`, `yellow-400`, `green-400`) were used -/// instead, which meant two unrelated reds on screen at once — `#ff5555` in the -/// terminal and `#f87171` on the delete button, on Dracula — and, on the light -/// themes, a danger colour at 2.45:1 that cleared no contrast floor at all. -/// -/// Each is conditioned by [`legible_ink`], so a seed too pale or too dark for its -/// role is deepened along its own hue rather than swapped for something foreign. #[derive(Debug, Clone)] pub struct Semantics { pub danger: Semantic, pub warning: Semantic, pub success: Semantic, pub info: Semantic, - /// Links. Distinct from `info` only in intent, but it is the field - /// gpui-component's markdown renderer reads, and left unset it resolves to - /// the body text colour — a link that looks exactly like prose. pub link: Semantic, } -/// The contrast targets that define how loud each interaction state reads. -/// -/// **These five numbers are the app's only knobs for state prominence.** They -/// exist because the alternative — a fixed `mix(bg, fg, t)` per state, which is -/// what this file used to do — makes the *perceived* step depend on the theme. -/// The old ladder (`hover` 0.09, `sidebar_sel` 0.12, `list_active` 0.17) put -/// selected-vs-resting anywhere from 1.20:1 (Catppuccin Latte) to 1.47:1 -/// (Dracula), and the segmented control — which read gpui-component's stock -/// `input` grey instead of the ladder at all — landed at **1.03:1 on Dracula**, -/// i.e. invisible. See issue #197. -/// -/// A ratio target removes the theme from the equation: every theme lands on the -/// same perceived step, so tuning taste here retunes the whole app at once and -/// no theme can be an outlier. -/// -/// # Two selections, not one -/// -/// The old ladder had *three* signed-off values, not one, and folding them into -/// a single `SELECTED` is what made light themes shout: the rail's selected row -/// went from `#E2E2E2` to `#C0C0C0` on white — a silver slab twice the perceived -/// step it had been — because it inherited the number the palette cursor was -/// tuned to. The two are not the same job: -/// -/// * [`SELECTED`] is a **resting state** — a rail row, a lit toggle, a switch -/// track. It sits there for the whole session next to unselected siblings, so -/// it stays quiet and leans on the text channel (see [`super::Surface`]). -/// * [`CURSOR`] is the **one row under the pointer or keyboard** on a menu or -/// the command palette: transient, alone on its surface, and the eye is -/// already following it. It gets the loud rung. -/// -/// Both are anchored to the Dracula values the look was signed off on — -/// `mix(bg, fg, 0.12)` for the resting selection, `0.17` for the cursor — so the -/// theme it was designed on is unmoved and every other theme is pulled onto the -/// same two perceived steps. pub mod state { - /// Pointer feedback. Deliberately a whisper: it answers the mouse without - /// competing with the selection it may be sitting next to. pub const HOVER: f32 = 1.18; - /// The resting selection. Never the *only* signal — see [`super::Surface`]. pub const SELECTED: f32 = 1.30; - /// Held down. One step past selected so pressing a selected item still reads. pub const PRESSED: f32 = 1.55; - /// The transient cursor row on a menu or overlay list. See the module docs - /// for why this is a separate knob from [`SELECTED`]. pub const CURSOR: f32 = 1.70; - /// Resting label text. 4.6:1 keeps a de-emphasised label at WCAG AA on every - /// theme; the fixed `mix(fg, bg, 0.42)` it replaces drifted with the seed. pub const TEXT_RESTING: f32 = 4.6; - /// The floor on how far the selected label sits from the resting one, so the - /// text channel keeps saying something when the fill beneath it is washed - /// out — a translucent window, a blurred background, an unvetted seed. - /// - /// A floor, not a target: most themes clear it by construction and are left - /// alone. It exists because the two label colors are derived from opposite - /// ends (`dim` walks the resting one *into* the surface, `ink_on` leaves the - /// selected one at the foreground whenever the fill allows), so a theme whose - /// foreground sits close to its background can collapse the gap without any - /// single derivation being wrong. One Dark Pro's `#abb2bf` on `#282c34` is - /// that theme: 1.32:1 on the popover surface before this floor existed. pub const TEXT_STEP: f32 = 1.4; } -/// The interaction-state ladder for one painting surface: the fills for each -/// state plus the paired label colors. -/// -/// # Both channels, always -/// -/// A fill alone does not communicate selection. The app learned this the hard -/// way in three separate places — `tab_strip`'s active chip, `tab_strip`'s -/// chrome tiles and the settings sidebar each grew a hand-written -/// fill-plus-text-color pair, while every site that *hadn't* been hand-fixed -/// (segmented controls, the SSH profile list) shipped a fill and nothing else -/// and could not be read. So the text colors ride along in this struct: take a -/// `Surface`, take both channels. -/// -/// * **Fill** answers *which one* — it locates the selection in the row. -/// * **Text** (`text_selected` + `FontWeight::MEDIUM` vs `text_resting`) -/// answers *that this is it* — it survives a low-contrast fill, an oddly -/// seeded imported theme, and a color-blind reader. -/// -/// A keyboard-driven single cursor (the command palette, a context menu) can get -/// away with the fill alone because the eye tracks the one thing that moves. -/// A *static* choice among visible siblings cannot. #[derive(Debug, Clone, Copy)] pub struct Surface { - /// The surface itself — what a resting item paints on (i.e. no fill). pub base: u32, pub hover: u32, pub selected: u32, pub pressed: u32, - /// The louder rung, for the single transient row a pointer or the keyboard - /// is *on* — a menu item, the palette's cursor. Not for a resting choice: - /// see [`state`] for why the two are separate knobs. pub cursor: u32, - /// Label color for a resting/unselected item on this surface. pub text_resting: u32, - /// Label color for the selected item. Pair it with `FontWeight::MEDIUM`. pub text_selected: u32, } -/// The dim a full-window overlay paints over everything behind it. -/// -/// Deliberately *not* a [`Surface`]: nothing sits on a scrim, it is a veil. Its -/// only job is to push the window far enough down that the card floating above -/// it reads as a separate plane. #[derive(Debug, Clone, Copy)] pub struct Scrim { - /// Near-black, but mixed from the theme's own background so a warm theme - /// dims warm rather than going slate. pub ink: u32, - /// How much of it lands. Lighter on light themes, where the alpha that - /// reads as a dim on charcoal reads as a bruise on paper. pub alpha: f32, } -/// Every surface the shell actually paints interactive rows on, published as a -/// GPUI global by `apply_theme` so a render pass can read the ladder without -/// re-resolving (and cloning) the theme registry every frame. -/// -/// Which surface a widget picks matters: a menu row sits on `popover`, not on -/// the window background, and a ladder anchored to the wrong surface is exactly -/// how the context-menu highlight ended up at 1.20:1 while claiming to be the -/// same 0.17 mix that reads fine on the window. #[derive(Debug, Clone)] pub struct Surfaces { - /// The window background — settings sheets, panels, the terminal ground. pub window: Surface, - /// The sunk sidebar rail. pub sidebar: Surface, - /// Elevated surfaces: menus, dropdowns, the command palette. pub popover: Surface, - /// The dim behind a full-window overlay card (the command palette, the - /// workspace switcher). Those two paint on `popover` like every other - /// elevated surface; only the ground under them is special. pub scrim: Scrim, } impl Global for Surfaces {} -/// The active theme's overlay scrim, ready to hand to `.bg()`. -/// -/// A free function rather than a `Surfaces` method so the two call sites read -/// the same either way round — both of them want the fill, neither wants the -/// ink and the alpha separately. pub fn scrim_fill(cx: &App) -> Hsla { let s = cx.global::<Surfaces>().scrim; Hsla::from(gpui::rgb(s.ink)).opacity(s.alpha) } -/// The active theme's contrast-conditioned accent (see [`legible_accent`]), -/// published so a render pass can reach it without cloning the theme registry. -/// -/// Deliberately its own global rather than a field on [`Surfaces`]: the accent is -/// not a surface, and it has exactly one job — ink that must be *noticed* (the -/// caret, the focus ring, a switch's checked track). Every neutral fill in the -/// app comes from a `Surface`; this is the one thing that doesn't. pub struct ActiveAccent(pub u32); impl Global for ActiveAccent {} impl Theme { - /// The representative solid background color. pub fn background_color(&self) -> u32 { self.background.color() } - /// Derive the full shell palette by blending `background` toward a - /// legibility-guaranteed `foreground` (chips, borders, surfaces) and that - /// foreground back toward the background (dimmed text). One ruleset gives - /// every theme — built-in, hand-authored, or imported — a coherent set of - /// greys regardless of its base colors. pub fn neutrals(&self) -> Neutrals { let bg = self.background_color(); let fg = legible_foreground(bg, self.foreground); @@ -346,23 +147,10 @@ impl Theme { } } - /// The interaction-state ladder for content painted on `base`. - /// - /// Every rung blends `base` toward the (legibility-guaranteed) foreground - /// until it clears its [`state`] contrast target *against `base`* — so the - /// direction is "toward the text" by construction on light and dark themes - /// alike. The old fixed-mix ladder had no such guarantee: because the - /// segmented control's fill came from a stock grey rather than the theme, - /// selecting a segment made it *darker* than its siblings on light themes - /// and *lighter* on dark ones, by accident of where `#2f2f2f` happened to - /// fall. pub fn surface(&self, base: u32) -> Surface { let bg = self.background_color(); let fg = legible_foreground(bg, self.foreground); let selected = raise(base, fg, state::SELECTED); - // Dimmed from the foreground until it is merely AA-readable on this - // surface, rather than a fixed blend — a resting label must stay - // legible on an imported theme nobody vetted, too. let text_resting = dim(fg, base, state::TEXT_RESTING); Surface { base, @@ -375,13 +163,6 @@ impl Theme { } } - /// The status palette, built from this theme's own ANSI red/green/yellow/cyan. - /// - /// The normal (not bright) ANSI slots are the seeds: they are what the - /// terminal in the same window paints, so a danger marker and an error line - /// of shell output finally wear the same red. Where a slot is too pale or too - /// dark for a role, [`legible_ink`] deepens it along its own hue rather than - /// reaching for a colour the theme never declared. pub fn semantics(&self) -> Semantics { let bg = self.background_color(); let fg = legible_foreground(bg, self.foreground); @@ -406,15 +187,9 @@ impl Theme { } } - /// The ladders for all three surfaces the shell paints rows on. pub fn surfaces(&self) -> Surfaces { let m = self.neutrals(); let mut sidebar = self.surface(m.sidebar); - // The rail's resting label is a tuned value (a lighter 0.28 dim, so rows - // in a sunk column don't read as disabled), not the generic AA floor. - // Moving it moves the step the selected label is measured against, so - // that one is re-derived rather than left at what `surface` computed - // against the floor it no longer uses. sidebar.text_resting = m.sidebar_fg; sidebar.text_selected = stepped_ink( sidebar.selected, @@ -428,8 +203,6 @@ impl Theme { popover: self.surface(m.popover), scrim: Scrim { ink: mix(m.background, 0x000000, 0.82), - // Lighter on light themes: the alpha that reads as a dim over - // charcoal reads as a bruise over paper. alpha: match self.dark { true => 0.55, false => 0.30, @@ -438,11 +211,6 @@ impl Theme { } } - /// The terminal-facing slice of the palette: ANSI-16 plus the selection - /// surface (`mix(bg, fg, 0.24)`), which the renderer's search-match washes - /// derive from. The selection itself paints as a translucent foreground wash - /// (see `element::PaintColors::resolve`), so cells keep their own colors - /// while selected. pub fn active_palette(&self) -> ActivePalette { let mut ansi16 = [Rgb { r: 0, g: 0, b: 0 }; 16]; for (i, (r, g, b)) in self.ansi16.iter().enumerate() { @@ -480,24 +248,9 @@ impl Theme { } } -/// The shared bisection behind [`raise`] and [`dim`]: find the blend of `from` -/// toward `toward` whose contrast against `from`-or-`toward` (whichever is the -/// surface, passed as `against`) sits at `target`. -/// -/// `against` must **not** sit strictly between the endpoints in luminance, or -/// the ratio along the blend is V-shaped rather than monotone and a bisection -/// would return an arbitrary one of the two answers. Every caller satisfies -/// that: [`raise`] and [`dim`] pass one of the endpoints itself, and -/// [`legible_accent`] passes the background, which an accent only reaches this -/// code by being *close* to — while `fg` is guaranteed 4.5:1 away from it. fn bisect_contrast(from: u32, toward: u32, against: u32, target: f32) -> u32 { - // 12 halvings resolve t to ~0.0002 — far finer than an 8-bit channel step, - // so the result is exact in the only units that reach the screen. const STEPS: u32 = 12; let rising = contrast(toward, against) > contrast(from, against); - // Unreachable target (e.g. a 4.6:1 label floor on a surface whose own - // foreground only manages 4.5): clamp to the most extreme blend rather than - // returning something arbitrary from the middle of the range. if rising && contrast(toward, against) <= target { return toward; } @@ -517,58 +270,14 @@ fn bisect_contrast(from: u32, toward: u32, against: u32, target: f32) -> u32 { mix(from, toward, hi) } -/// Lift a fill off `base` toward `toward` (always the foreground) until it -/// clears `target` contrast against `base`. -/// -/// This is the primitive that replaced fixed `mix(bg, fg, t)` state colors: the -/// caller names the perceived step it wants and gets it on every theme, instead -/// of naming a blend and getting whatever step that theme's seed implies. fn raise(base: u32, toward: u32, target: f32) -> u32 { bisect_contrast(base, toward, base, target) } -/// Dim `ink` toward `surface` until it sits *at* `target` contrast against -/// `surface` — a de-emphasised label that is still guaranteed readable, rather -/// than a fixed blend whose ratio drifts with the seed. fn dim(ink: u32, surface: u32, target: f32) -> u32 { bisect_contrast(ink, surface, surface, target) } -/// The label color for text sitting on `fill`: the theme's foreground when it -/// still clears `target` there, otherwise that foreground pushed *past* itself -/// (toward white on a dark fill, black on a light one) until it does. -/// -/// This exists because the fill ladder and the text channel pull against each -/// other. Raising a fill toward the foreground necessarily moves the ground -/// closer to the label it carries, and on a theme whose foreground isn't an -/// extreme — Catppuccin Latte's `#4c4f69` is only 7.4:1 on its own background — -/// a 1.70:1 cursor fill drags the label on it down to 4.14:1, *below* the -/// resting labels around it. A selection whose text is harder to read than its -/// neighbours' is not a selection. -/// -/// Pushing along the fg→extreme axis rather than snapping to pure black/white -/// keeps the theme's ink hue; Latte's selected label becomes a deeper version of -/// the same blue-grey, not a foreign pure black. -/// -/// Three tiers, in order of how much of the theme they preserve: the foreground -/// itself, then the foreground deepened along its own side, then — only when that -/// side simply cannot reach the target — the opposite extreme. That last tier is -/// not hypothetical: the Light theme's danger fill is `#d1242f`, a mid-dark red -/// against which even *pure black* tops out at 3.96:1. White text on a dark red -/// button is the right answer there, and it is only reachable by looking the -/// other way. -/// The selected label on `fill`: [`ink_on`]'s answer, then pushed further from -/// `resting` if the two would not read as a step ([`state::TEXT_STEP`]). -/// -/// The push is the last resort it looks like — `ink_on` already guarantees the -/// label is readable on its own fill, and this only widens a gap that is too -/// narrow *between the two labels*. Most themes never reach it. -/// -/// Direction is away from `base`, never toward it: `resting` is the foreground -/// dimmed *into* the surface, so the far side of the surface is the only way to -/// open the gap without walking the selected label back down onto its own -/// ground. That also means the on-fill contrast `ink_on` established can only -/// improve — `fill` sits between `base` and the foreground. fn stepped_ink(fill: u32, base: u32, fg: u32, resting: u32) -> u32 { let ink = ink_on(fill, fg, state::TEXT_RESTING); if contrast(ink, resting) >= state::TEXT_STEP { @@ -585,11 +294,6 @@ fn ink_on(fill: u32, fg: u32, target: f32) -> u32 { if contrast(fg, fill) >= target { return fg; } - // Push *away* from the fill along the axis the foreground already sits on — - // darker ink gets darker, lighter ink lighter. Choosing the extreme by the - // fill's own brightness instead is wrong at the midpoint: Latte's `#b8bac6` - // fill reads as "dark" to a `< 0.5` luminance test, which sends its already - // dark ink toward white and *lowers* the contrast it was called to raise. let near = if relative_luminance(fg) < relative_luminance(fill) { 0x000000 } else { @@ -599,7 +303,6 @@ fn ink_on(fill: u32, fg: u32, target: f32) -> u32 { if contrast(deepened, fill) >= target { return deepened; } - // The foreground's own side is exhausted. Take whichever extreme reads best. if contrast(fill, 0xffffff) >= contrast(fill, 0x000000) { 0xffffff } else { @@ -607,7 +310,6 @@ fn ink_on(fill: u32, fg: u32, target: f32) -> u32 { } } -/// Blend `a` toward `b` by `t` (0.0 = all `a`, 1.0 = all `b`), per channel. pub(crate) fn mix(a: u32, b: u32, t: f32) -> u32 { let (ar, ag, ab) = (a >> 16 & 0xff, a >> 8 & 0xff, a & 0xff); let (br, bg, bb) = (b >> 16 & 0xff, b >> 8 & 0xff, b & 0xff); @@ -615,7 +317,6 @@ pub(crate) fn mix(a: u32, b: u32, t: f32) -> u32 { (ch(ar, br) << 16) | (ch(ag, bg) << 8) | ch(ab, bb) } -/// Split a `0xRRGGBB` literal into an alacritty `Rgb`. fn rgb_bytes(n: u32) -> Rgb { Rgb { r: (n >> 16) as u8, @@ -624,9 +325,6 @@ fn rgb_bytes(n: u32) -> Rgb { } } -// ── Contrast / brightness ─────────────────────────────────────────────────── - -/// WCAG relative luminance of a `0xRRGGBB` color (0.0 = black, 1.0 = white). fn relative_luminance(c: u32) -> f32 { fn chan(v: u32) -> f32 { let s = v as f32 / 255.0; @@ -639,71 +337,34 @@ fn relative_luminance(c: u32) -> f32 { 0.2126 * chan(c >> 16 & 0xff) + 0.7152 * chan(c >> 8 & 0xff) + 0.0722 * chan(c & 0xff) } -/// The largest per-channel difference between two colors (0…255). A crude but -/// hue-aware "are these the same colour" check — contrast alone can't tell red -/// from green, since they can share a luminance. #[cfg(test)] fn channel_distance(a: u32, b: u32) -> u32 { let d = |sh: u32| (a >> sh & 0xff).abs_diff(b >> sh & 0xff); d(16).max(d(8)).max(d(0)) } -/// WCAG contrast ratio between two colors (1.0 … 21.0). fn contrast(a: u32, b: u32) -> f32 { let (l1, l2) = (relative_luminance(a), relative_luminance(b)); let (hi, lo) = if l1 >= l2 { (l1, l2) } else { (l2, l1) }; (hi + 0.05) / (lo + 0.05) } -/// A theme is dark when its background is closer to black than white. fn is_dark(bg: u32) -> bool { relative_luminance(bg) < 0.5 } -/// Whether `a` is the lighter of two colors. Lets callers pick "the light end of -/// this theme's axis" without caring which of background/foreground that is — -/// e.g. a switch knob, which is near-white in both macOS appearances. pub(crate) fn is_lighter(a: u32, b: u32) -> bool { relative_luminance(a) > relative_luminance(b) } -/// The minimum contrast an accent must clear against the background before it is -/// allowed to carry ink (a caret, a link, a focus ring). 3:1 is the WCAG -/// large-text / non-text floor. const ACCENT_FLOOR: f32 = 3.0; -/// The WCAG AA text floor. What a coloured *label* must clear on its ground. const TEXT_FLOOR: f32 = 4.5; -/// Make a hued seed usable at `floor` against `bg`: keep it when it already -/// clears, otherwise drive it *away from the background* — toward white on a dark -/// theme, black on a light one — until it does. -/// -/// This is why a seed colour can never be used raw. The bundled Light theme's -/// accent `#00c2ff` manages 2.07:1 on white and the built-ins' accents span -/// 2.07:1 to 8.43:1; the ANSI reds behind [`Semantics`] are just as uneven. Any -/// unconditional use of one is a coin flip on some theme. -/// -/// It drives toward black/white rather than toward the theme's foreground because -/// the foreground is usually *tinted*, and blending into a tint destroys hue at -/// exactly the moment hue matters most — when a seed is far from the floor and so -/// has to travel far. On Rosé Pine Dawn, whose foreground is the purple-grey -/// `#575279`, routing through it turned the ANSI red into `#9a5e7a` and the ANSI -/// yellow into `#876a62`: two muddy mauves a user could not tell apart, which is -/// no use at all for "did that fail or is it just a warning". Black and white are -/// neutral, so the hue survives the trip. fn legible_ink(bg: u32, seed: u32, floor: f32) -> u32 { if contrast(seed, bg) >= floor { return seed; } - // Whichever extreme the background is *further* from, exactly as - // [`legible_foreground`] picks it — not `is_dark`, whose 0.5 luminance - // threshold is the wrong question here. The two answers only diverge on a - // midtone background (luminance 0.18…0.5), where `is_dark` still says "dark" - // but black outreaches white: an imported scheme on a mid-grey ground would - // have been driven to pure white and clamped there *below* the floor, losing - // the hue and failing the job in one go. Every built-in is far enough from - // the midpoint that this picks what `is_dark` did. let away = if contrast(0xffffff, bg) >= contrast(0x000000, bg) { 0xffffff } else { @@ -712,16 +373,10 @@ fn legible_ink(bg: u32, seed: u32, floor: f32) -> u32 { bisect_contrast(seed, away, bg, floor) } -/// The accent conditioned for ink (caret, focus ring, a switch's checked track). fn legible_accent(bg: u32, accent: u32) -> u32 { legible_ink(bg, accent, ACCENT_FLOOR) } -/// Guarantee a legible default text color: keep the authored `fg` if it clears -/// the WCAG AA text threshold (4.5) against `bg`, otherwise fall back to pure -/// black or white — whichever contrasts more. Protects hand-authored and -/// imported themes from an unreadable foreground without touching the many -/// built-ins that already pass. fn legible_foreground(bg: u32, fg: u32) -> u32 { if contrast(bg, fg) >= 4.5 { return fg; @@ -733,49 +388,30 @@ fn legible_foreground(bg: u32, fg: u32) -> u32 { } } -/// The render-facing slice of the active theme's window background — fill, -/// window opacity, and optional image — published as a GPUI global by -/// `apply_theme` so the root view can paint gradients/images every frame -/// without re-resolving (and cloning) the whole theme registry. pub struct ActiveBackground { pub fill: Fill, - /// Window opacity, already filtered to `Some` only when < 1.0. pub opacity: Option<f32>, pub image: Option<Image>, } impl Global for ActiveBackground {} -// ── Registry ──────────────────────────────────────────────────────────────── - -/// The id of the app's default theme. Mirrors `Config`'s default `theme_preset` -/// (core can't reference this module). Unknown ids fall back to it. pub const DEFAULT_ID: &str = "light"; -/// The loaded set of themes (built-ins first, then user files), stored as a GPUI -/// global so any view can list/resolve them. Rebuilt from disk at startup and on -/// hot-reload. pub struct Themes(pub Vec<Theme>); impl Global for Themes {} -/// (Re)load built-ins + user theme files from disk into the [`Themes`] global. -/// Called at startup (before the first `apply_theme`) and on config hot-reload. pub fn load_registry(cx: &mut App) { cx.set_global(Themes(load_all())); } -/// All themes, in display order (built-ins first, then user files). Falls back to -/// just the built-ins if the registry hasn't been loaded yet (e.g. very early -/// startup). pub fn all(cx: &App) -> Vec<Theme> { cx.try_global::<Themes>() .map(|t| t.0.clone()) .unwrap_or_else(builtins) } -/// Look a theme up by id, falling back to [`DEFAULT_ID`] (then the first theme) -/// for an unknown id so a stale/typo'd config never breaks startup. pub fn by_id(cx: &App, id: &str) -> Theme { let themes = all(cx); themes @@ -787,9 +423,6 @@ pub fn by_id(cx: &App, id: &str) -> Theme { } impl Theme { - /// Whether this theme is a user-owned, editable YAML file (as opposed to a - /// read-only built-in or an imported `.itermcolors`, both of which must be - /// duplicated first). Drives the in-app color editor and the duplicate action. pub fn editable(&self) -> bool { self.path .as_ref() @@ -799,8 +432,6 @@ impl Theme { } } -/// Serialize a theme into tty7's YAML schema — the inverse of [`load_yaml_theme`], -/// used by the duplicate action and the in-app editor to write themes to disk. pub fn to_yaml(t: &Theme) -> String { fn hex(c: u32) -> String { format!("\"#{:06x}\"", c & 0xff_ffff) @@ -809,7 +440,6 @@ pub fn to_yaml(t: &Theme) -> String { format!("\"#{r:02x}{g:02x}{b:02x}\"") } let mut s = String::new(); - // `{:?}` on a String yields a double-quoted, escaped literal — valid YAML. s.push_str(&format!("name: {:?}\n", t.name)); match &t.background { Fill::Solid(c) => s.push_str(&format!("background: {}\n", hex(*c))), @@ -838,9 +468,6 @@ pub fn to_yaml(t: &Theme) -> String { if t.blur { s.push_str("blur: true\n"); } - // Written back as the (expanded) absolute path: `expand_path` already - // resolved `~`/relative forms on load, and dropping the field here would - // silently delete a theme's image on the first in-app color edit. if let Some(img) = &t.image { s.push_str(&format!( "background_image:\n path: {:?}\n opacity: {}\n", @@ -860,9 +487,6 @@ pub fn to_yaml(t: &Theme) -> String { s } -/// Duplicate `t` into a new editable YAML file in the themes folder, returning the -/// new theme's id (its file stem). The id is `<base>-custom` (deduplicated with a -/// numeric suffix), so duplicating "Dracula" yields "dracula-custom". pub fn fork_to_file(t: &Theme) -> std::io::Result<String> { let dir = themes_dir() .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no themes directory"))?; @@ -883,8 +507,6 @@ pub fn fork_to_file(t: &Theme) -> std::io::Result<String> { Ok(stem) } -/// Write an edited theme back to its own file (the in-app color editor). Errors if -/// the theme isn't file-backed. pub fn write_theme_file(t: &Theme) -> std::io::Result<()> { let path = t.path.clone().ok_or_else(|| { std::io::Error::new(std::io::ErrorKind::InvalidInput, "theme is not file-backed") @@ -892,10 +514,6 @@ pub fn write_theme_file(t: &Theme) -> std::io::Result<()> { crate::core::config::write_atomic(&path, to_yaml(t).as_bytes()) } -/// Build the full theme list from disk: the built-ins, then every parseable user -/// file under the themes directory. A user file whose id collides with a built-in -/// is appended (both remain listed); `by_id` resolves to the first match, so -/// built-ins win a straight id clash. fn load_all() -> Vec<Theme> { let mut themes = builtins(); themes.extend(load_user_themes()); @@ -903,14 +521,6 @@ fn load_all() -> Vec<Theme> { themes } -/// Guarantee every theme carries a unique `id` so `by_id` (and thus selection) -/// can address each one. Built-ins are added first and keep their canonical ids; -/// a later theme — typically a user file whose stem matches a built-in, e.g. -/// `dracula.itermcolors` vs the built-in `dracula` — gets the first free -/// `<id>-2`, `-3`, … and its display name is suffixed to match, so the gallery -/// doesn't show two identical labels and both entries stay selectable. Order is -/// stable (user paths are pre-sorted), so a given file keeps its id across -/// launches and a persisted `theme_preset` stays valid. fn dedupe_ids(themes: &mut [Theme]) { let mut seen = std::collections::HashSet::new(); for t in themes.iter_mut() { @@ -929,14 +539,10 @@ fn dedupe_ids(themes: &mut [Theme]) { } } -/// The themes directory, `~/.config/tty7/themes` (honoring `--config-dir`). pub fn themes_dir() -> Option<PathBuf> { crate::core::config::config_path("themes") } -/// Parse every `*.yaml` / `*.yml` / `*.itermcolors` file in the themes directory -/// into a [`Theme`]. Missing directory → empty. A file that fails to parse is -/// skipped with a warning; it never blocks the others or startup. fn load_user_themes() -> Vec<Theme> { let Some(dir) = themes_dir() else { return Vec::new(); @@ -946,7 +552,6 @@ fn load_user_themes() -> Vec<Theme> { }; let mut out = Vec::new(); let mut paths: Vec<PathBuf> = entries.flatten().map(|e| e.path()).collect(); - // Stable, case-insensitive order so the gallery doesn't reshuffle per launch. paths.sort_by_key(|p| p.to_string_lossy().to_lowercase()); for path in paths { let ext = path @@ -966,8 +571,6 @@ fn load_user_themes() -> Vec<Theme> { out } -/// Derive a theme id/name from a file stem: the id is the raw stem, the name is -/// a title-cased version (`solarized_dark` → "Solarized Dark"). fn id_and_name(path: &std::path::Path) -> (String, String) { let stem = path .file_stem() @@ -996,11 +599,6 @@ fn id_and_name(path: &std::path::Path) -> (String, String) { ) } -// ── YAML theme files (tty7's own schema) ───────────────────────────────────── - -/// A theme as authored in a `*.yaml` file. This is the on-disk schema; it -/// converts into a runtime [`Theme`]. Unknown fields are ignored by serde, so a -/// file may carry extra keys without failing. #[derive(Deserialize)] struct ThemeFile { name: Option<String>, @@ -1095,8 +693,6 @@ impl FillFile { } } -/// Expand a leading `~` to `$HOME`; resolve a relative path against the themes -/// directory (so a theme can ship an image beside it). fn expand_path(p: &str) -> PathBuf { let p = p.trim(); if let Some(rest) = p.strip_prefix("~/") { @@ -1111,13 +707,6 @@ fn expand_path(p: &str) -> PathBuf { themes_dir().map(|d| d.join(&path)).unwrap_or(path) } -// ── iTerm2 `.itermcolors` import ───────────────────────────────────────────── - -/// Import an iTerm2 color scheme (an XML plist). Maps `Ansi 0..15 Color` to the -/// ANSI-16 set, `Background/Foreground/Cursor Color` to the seed, and derives the -/// accent from the cursor (falling back to bright blue). iTerm's explicit -/// selection color is intentionally dropped — tty7 derives selection from -/// background/foreground for consistency. fn load_iterm_theme(path: &std::path::Path) -> Result<Theme, String> { let value = plist::Value::from_file(path).map_err(|e| e.to_string())?; let dict = value @@ -1147,8 +736,6 @@ fn load_iterm_theme(path: &std::path::Path) -> Result<Theme, String> { let background = color("Background Color").ok_or("missing 'Background Color'")?; let foreground = color("Foreground Color").ok_or("missing 'Foreground Color'")?; let cursor = color("Cursor Color"); - // Accent: the cursor color when it's distinct enough from the background, - // else bright blue (slot 12) — a sensible, always-present pick. let bright_blue = { let (r, g, b) = ansi16[12]; (r as u32) << 16 | (g as u32) << 8 | b as u32 @@ -1176,9 +763,6 @@ fn load_iterm_theme(path: &std::path::Path) -> Result<Theme, String> { }) } -// ── Hex parsing ────────────────────────────────────────────────────────────── - -/// Parse a `#rrggbb` (or bare `rrggbb`) string into a `0xRRGGBB` value. fn parse_hex(s: &str) -> Result<u32, String> { let hex = s.trim().trim_start_matches('#'); if hex.len() != 6 { @@ -1187,22 +771,15 @@ fn parse_hex(s: &str) -> Result<u32, String> { u32::from_str_radix(hex, 16).map_err(|_| format!("'{s}' is not a hex color")) } -/// Parse a `#rrggbb` string into an `(r, g, b)` byte triple. fn parse_rgb(s: &str) -> Result<(u8, u8, u8), String> { let n = parse_hex(s)?; Ok(((n >> 16) as u8, (n >> 8) as u8, n as u8)) } -// ── Built-in themes ────────────────────────────────────────────────────────── - -/// The built-in themes as concrete [`Theme`] values (built-ins first in display -/// order: light themes, then dark). pub fn builtins() -> Vec<Theme> { BUILTINS.iter().map(Theme::from_builtin).collect() } -/// A built-in theme's seed data, kept as a static table (with `&'static str` -/// ids) and converted to an owned [`Theme`] by [`Theme::from_builtin`]. struct BuiltinSpec { id: &'static str, name: &'static str, @@ -1213,7 +790,6 @@ struct BuiltinSpec { ansi16: [(u8, u8, u8); 16], } -/// A hand-picked set of familiar terminal palettes. static BUILTINS: [BuiltinSpec; 9] = [ BuiltinSpec { id: "light", @@ -1221,10 +797,7 @@ static BUILTINS: [BuiltinSpec; 9] = [ background: 0xffffff, foreground: 0x111111, accent: 0x00c2ff, - // A warm orange caret, distinct from the cyan accent (which also tints the - // active-line highlight and links). caret: Some(0xf5a15c), - // True-hue, high-contrast set tuned for a white ground (GitHub Light-ish). ansi16: [ (0x24, 0x29, 0x2e), (0xd1, 0x24, 0x2f), @@ -1405,9 +978,6 @@ static BUILTINS: [BuiltinSpec; 9] = [ name: "One Dark Pro", background: 0x282c34, foreground: 0xabb2bf, - // The editor cursor / focus blue, not the syntax blue `#61afef`: the - // accent doubles as the switch's checked track, and `#61afef` sits at - // the same luminance as the `#abb2bf` knob (1.11:1 — invisible). accent: 0x528bff, caret: None, ansi16: [ @@ -1461,7 +1031,6 @@ static BUILTINS: [BuiltinSpec; 9] = [ mod tests { use super::*; - /// Default foreground must stay readable on the background in every built-in. #[test] fn foreground_is_legible_on_background() { for t in builtins() { @@ -1474,8 +1043,6 @@ mod tests { } } - /// Brightness is inferred correctly: the four light built-ins classify light, - /// the five dark ones dark. #[test] fn dark_is_inferred_from_background() { let dark: Vec<_> = builtins() @@ -1489,9 +1056,6 @@ mod tests { ); } - /// The selection surface must stay a *tint* — decisively on the background's - /// side of the fg↔bg axis — or selected text (whose glyphs keep their own - /// color) would wash out. #[test] fn selection_surface_stays_on_the_background_side() { for t in builtins() { @@ -1507,15 +1071,6 @@ mod tests { } } - /// Every surface's state ladder must be *strictly ordered and separable* on - /// every built-in — this is the regression guard for issue #197, where the - /// segmented control's selected fill sat 1.03:1 from its unselected siblings - /// on Dracula (and no better than 1.20:1 on any other bundled theme). - /// - /// The assertions are deliberately below the [`state`] targets: they pin the - /// *property* (a selection is distinguishable from resting and from hover on - /// every surface of every theme), not the current taste, so retuning the - /// constants doesn't force a test edit but abandoning the ladder does. #[test] fn state_ladder_is_separable_on_every_surface() { for t in builtins() { @@ -1539,9 +1094,6 @@ mod tests { "{}/{name}: selected is only {sel_hover:.2}:1 from hover", t.id ); - // The two selection rungs have to stay apart, or splitting them - // bought nothing and a menu's cursor reads as a rail's resting - // selection again. assert!( cursor_sel >= 1.2, "{}/{name}: cursor is only {cursor_sel:.2}:1 from the resting selection", @@ -1561,9 +1113,6 @@ mod tests { } } - /// The whole point of a ratio target over a blend ratio: the *perceived* step - /// is the same on every theme. A fixed `mix` put selected-vs-resting between - /// 1.20:1 and 1.47:1 depending on the seed; these must all agree. #[test] fn state_ladder_is_theme_independent() { let ratios: Vec<f32> = builtins() @@ -1587,28 +1136,17 @@ mod tests { ); } - /// Both selection rungs must leave the signed-off Dracula greys where they - /// were — the values the look was tuned against, and two *different* values. - /// This is what makes the ratio ladder a no-op on the theme it was designed - /// on and a lift for everything else; if a retune moves Dracula, that was a - /// taste decision and wants to be a deliberate one. - /// - /// The resting rung is the half that regressed: folded into `CURSOR`'s - /// 1.70:1, the rail's selected row went to `#C0C0C0` on the Light theme — - /// twice the perceived step it had ever had. #[test] fn dracula_selection_matches_the_signed_off_greys() { let dracula = builtins().into_iter().find(|t| t.id == "dracula").unwrap(); let bg = dracula.background_color(); let s = dracula.surfaces(); for (what, now, legacy) in [ - // The old `sidebar_sel`, against the rail it actually paints on. ( "resting", s.sidebar.selected, mix(bg, dracula.foreground, 0.12), ), - // The old `list_active`, which was mixed off the window background. ("cursor", s.window.cursor, mix(bg, dracula.foreground, 0.17)), ] { assert!( @@ -1618,10 +1156,6 @@ mod tests { } } - /// A resting label must clear WCAG AA on the surface it sits on, for every - /// theme *and* every surface — a menu row's label sits on `popover`, not on - /// the window background, and the fixed dim it replaced was anchored to the - /// latter wherever it was used. #[test] fn resting_labels_stay_readable() { for t in builtins() { @@ -1637,16 +1171,6 @@ mod tests { } } - /// The text channel's two invariants, on every surface of every theme. - /// - /// 1. A selected label is readable **on its own fill** — never merely on the - /// surface it would have sat on unselected. Getting this wrong is subtle: - /// raising the fill toward the foreground eats the label's contrast, and - /// Catppuccin Latte's selected label landed at 4.14:1 (below the 4.57:1 of - /// the *resting* labels beside it) before `ink_on` existed. - /// 2. The two label colors differ enough to read as a step, so the channel - /// still says something when the fill is washed out — a translucent - /// window, a blurred background, an imported seed nobody vetted. #[test] fn label_channel_is_readable_and_stepped() { for t in builtins() { @@ -1672,20 +1196,8 @@ mod tests { } } - /// ...and that step is guaranteed by construction, not by every built-in - /// happening to clear it. - /// - /// The two label colors are derived from opposite ends — `dim` walks the - /// resting one *into* the surface, while `ink_on` leaves the selected one - /// sitting at the foreground whenever the fill allows — so on a theme whose - /// foreground is close to its background they meet in the middle with - /// neither derivation being wrong. One Dark Pro's popover surface is exactly - /// that: 1.32:1 before [`stepped_ink`] existed. Widening the gap must not - /// hand back the on-fill readability `ink_on` was called for in the first - /// place. #[test] fn selected_label_is_stepped_off_the_resting_one() { - // One Dark Pro's popover ladder: `#abb2bf` foreground over `#282c34`. let (base, fill, fg) = (0x2f333b, 0x40454d, 0xabb2bf); let resting = dim(fg, base, state::TEXT_RESTING); @@ -1708,20 +1220,6 @@ mod tests { ); } - /// A switch's two tracks must both be distinguishable *from each other* and - /// from the surface, and the knob — one colour serving both states — has to - /// stay visible on each. - /// - /// The toggles shipped inverted on every dark theme (stock near-black knob on - /// a near-white checked track, and invisible on the unchecked one) because - /// `switch`, `switch_thumb` and `tokens.background` were all unset. This pins - /// the arrangement that replaced it: knob at the light end of the theme's - /// axis, unchecked track on the ladder, checked track on the accent. - /// - /// The knob-on-checked-track floor is 1.25, not 3 — a white knob on a - /// coloured track is separated by the component's `shadow_md`, exactly as it - /// is in macOS, and demanding raw contrast there would force every accent to - /// go dark. #[test] fn switch_tracks_and_knob_stay_legible() { for t in builtins() { @@ -1743,8 +1241,6 @@ mod tests { "{}: knob {knob:#08x} lost on the checked track {checked:#08x}", t.id ); - // The two states must not be near-identical greys, or the switch says - // nothing but the knob's position. assert!( contrast(checked, unchecked) >= 1.3, "{}: checked and unchecked tracks are {:.2}:1 apart", @@ -1754,13 +1250,6 @@ mod tests { } } - /// Status colours must clear their floors on every theme, and — the point of - /// deriving them from the theme's own ANSI-16 — must stay *recognisable* as - /// red / green / yellow rather than converging on the foreground. - /// - /// Before this, `danger` was gpui-component's stock `#f87171` on every theme: - /// 2.45:1 on Catppuccin Latte (under even the 3:1 non-text floor) and a - /// different red from the `#ff5555` the terminal beside it paints. #[test] fn semantic_colors_clear_their_floors() { for t in builtins() { @@ -1794,8 +1283,6 @@ mod tests { contrast(c.on_fill, c.fill) ); } - // Conditioning must not wash the hues into each other: a user has to - // be able to tell an error from a success without reading the label. for (a, b, pair) in [ (s.danger.ink, s.success.ink, "danger/success"), (s.danger.ink, s.warning.ink, "danger/warning"), @@ -1810,9 +1297,6 @@ mod tests { } } - /// Each status colour must stay recognisably its own theme's hue — that is - /// the whole reason for sourcing them from ANSI-16 rather than a brand set. - /// Where a seed already clears its floor it must pass through untouched. #[test] fn semantic_colors_keep_the_theme_hue() { let dracula = builtins().into_iter().find(|t| t.id == "dracula").unwrap(); @@ -1821,14 +1305,9 @@ mod tests { (r as u32) << 16 | (g as u32) << 8 | b as u32 }; assert_eq!(ansi_red, 0xff5555, "Dracula's ANSI red moved"); - // 4.53:1 on Dracula's background — already over AA, so it is used as-is - // and the danger dot matches the terminal's own error output exactly. assert_eq!(dracula.semantics().danger.ink, ansi_red); } - /// Every theme's accent must be able to carry ink (caret, link, focus ring). - /// The bundled Light theme's raw `#00c2ff` manages 2.07:1 on white, which is - /// why this conditioning exists rather than using the seed directly. #[test] fn accents_are_conditioned_to_carry_ink() { for t in builtins() { @@ -1841,8 +1320,6 @@ mod tests { t.id ); } - // ...and a seed that already clears the floor is passed through untouched, - // so conditioning never dulls a theme that didn't need it. let rose = builtins() .into_iter() .find(|t| t.id == "rose_pine") @@ -1850,39 +1327,18 @@ mod tests { assert_eq!(rose.neutrals().accent, rose.accent); } - /// `raise`/`dim` must land *just* past their targets from either direction, - /// and clamp rather than return a mid-range guess when one is unreachable. - /// - /// "Just past" is one 8-bit channel step, not zero: the tightest grey clearing - /// 2.0:1 on black is `#404040` at 2.025:1, because a channel step near there - /// moves the ratio by ~0.03. Anything tighter would be asserting sub-pixel - /// precision the framebuffer can't hold. #[test] fn contrast_bisection_hits_its_target() { const SLACK: f32 = 0.05; - // Reachable, rising: a fill lifted off black. let f = raise(0x000000, 0xffffff, 2.0); assert!((2.0..2.0 + SLACK).contains(&contrast(f, 0x000000))); - // Reachable, rising: lifted off white — the direction flips, the API - // doesn't (this is what the old fixed-mix ladder got wrong per theme). let f = raise(0xffffff, 0x000000, 2.0); assert!((2.0..2.0 + SLACK).contains(&contrast(f, 0xffffff))); - // Reachable, falling: white ink dimmed to just above AA on black. let d = dim(0xffffff, 0x000000, 4.5); assert!((contrast(d, 0x000000) - 4.5).abs() < SLACK); - // Unreachable: nothing between these two clears 21:1, so clamp to the - // far endpoint instead of bisecting to something arbitrary. assert_eq!(raise(0x000000, 0x808080, 21.0), 0x808080); } - /// Conditioning has to take the extreme it can actually *reach*, which on a - /// midtone ground is not the one `is_dark`'s 0.5 luminance threshold names. - /// A mid-grey background is "dark" by that test, yet white tops out at - /// 3.95:1 on it while black manages 5.32:1 — so driving toward white would - /// clamp at pure white, below the floor and with the hue thrown away, in the - /// one case where a status colour most needs both. Reachable only for an - /// imported scheme; every built-in sits far enough from the midpoint that - /// this picks the same extreme `is_dark` did. #[test] fn semantic_conditioning_survives_a_midtone_background() { let bg = 0x808080; @@ -1896,14 +1352,10 @@ mod tests { } } - /// A bad foreground is swapped for a legible black/white; a good one is kept. #[test] fn legible_foreground_rescues_unreadable_text() { - // Light-grey text on white is unreadable → forced to black. assert_eq!(legible_foreground(0xffffff, 0xeeeeee), 0x000000); - // A genuine dark foreground on white is kept. assert_eq!(legible_foreground(0xffffff, 0x111111), 0x111111); - // Dark-grey on black is unreadable → forced to white. assert_eq!(legible_foreground(0x000000, 0x222222), 0xffffff); } @@ -1915,8 +1367,6 @@ mod tests { assert!(parse_hex("nope!!").is_err()); } - /// A minimal YAML theme parses, derives its name from the caller-supplied id, - /// and lays its ANSI set out normal-then-bright. #[test] fn yaml_theme_parses_normal_then_bright() { let yaml = r##" @@ -1935,8 +1385,6 @@ ansi: assert_eq!(parse_rgb(&file.ansi.bright[7]).unwrap(), (0xff, 0xff, 0xff)); } - /// A gradient background deserializes and reports its top stop as the - /// representative color. #[test] fn yaml_gradient_background_parses() { let file: ThemeFile = serde_yaml::from_str( @@ -1961,9 +1409,6 @@ ansi: assert_eq!(fill.color(), 0x001122); } - /// Window fields and the background image must survive a serialize → parse - /// round trip, or an in-app color edit would silently strip them from the - /// user's file. #[test] fn to_yaml_round_trips_window_and_image_fields() { let mut theme = builtins().into_iter().next().unwrap(); @@ -1997,7 +1442,6 @@ ansi: assert_eq!(name, "Solarized Dark"); } - /// `mix` endpoints and midpoint behave. #[test] fn mix_blends_channels() { assert_eq!(mix(0x000000, 0xffffff, 0.0), 0x000000); diff --git a/src/ui/remote_connect.rs b/src/ui/remote_connect.rs index 9a40463b..74716f1b 100644 --- a/src/ui/remote_connect.rs +++ b/src/ui/remote_connect.rs @@ -1,49 +1,3 @@ -//! "Connect to Host": the client half of a remote workspace. -//! -//! This module is everything between *the user picked a machine* and *the window -//! is bound to a workspace on it*. It has no gpui views of its own — the home -//! page renders the panels (`ui::home`) and `Tty7App` owns the state -//! (`ui::app`) — because every step here is blocking work that has to happen off -//! the UI thread, and keeping it out of the render path is what makes that -//! obvious. -//! -//! ## The five steps -//! -//! | | Step | Here | -//! |---|---|---| -//! | 1 | List the machines the user already configured | [`available_hosts`] | -//! | 2 | Resolve one into a self-contained SSH spec | [`spec_for`] | -//! | 3 | Open a routed control connection through the local daemon | [`connect_blocking`] | -//! | 4 | Read the machine's own workspace list | [`rows_from_list`] | -//! | 5 | Hold the connection for the workspaces bound to it | [`HostLinks`] | -//! -//! ## Machines are configured once -//! -//! A remote workspace reuses an SSH configuration that already -//! exists — a saved profile or a `~/.ssh/config` alias — with its keys, its jump -//! host and its `ProxyCommand` already set up. There is deliberately no host -//! *editor* here; [`available_hosts`] only reads, and [`spec_for`] hands the -//! resolution straight to `ui::ssh_connect`, which is the same code the SSH-pane -//! entry points use. A machine reachable as an SSH pane is reachable as a -//! workspace, with nothing to configure twice and nothing to keep in step. -//! -//! ## …except WSL, which is configured zero times -//! -//! A WSL distribution is the one machine with nothing to -//! configure: it is reached by spawning `wsl.exe -d <distro> -- tty7-server -//! --stdio`, so there is no address, no credential and no host key — nothing -//! that could be set up, and nothing that could be set up wrongly. So it is not -//! read from a store like the other rows but *discovered*, by [`sweep_wsl`], -//! and every distro the user has installed is offered. -//! -//! ## The connection is routed, not direct -//! -//! Design D3: the GUI never speaks SSH. It opens the same local daemon socket it -//! always did and prefixes one `RouteHeader` frame; the daemon opens the SSH -//! channel and copies bytes. So the sequence in [`connect_blocking`] is -//! *local socket → route header → route ack → control hello*, and only the last -//! of those is a conversation with the remote machine. - use std::collections::HashMap; use std::io; use std::path::PathBuf; @@ -64,14 +18,6 @@ use crate::daemon::router::RouteHeader; use tty7_core::host::remote::RemoteHost; use tty7_core::host::{Host as _, HostId}; -// --------------------------------------------------------------------------- -// 1. What there is to connect to -// --------------------------------------------------------------------------- - -/// One machine the user has already configured, ready to be offered. -/// -/// `label` is the name they gave it; `detail` is the endpoint, so two profiles -/// pointing at the same box are told apart by the line that actually differs. #[derive(Clone, Debug, PartialEq, Eq)] pub struct HostChoice { pub target: RemoteTarget, @@ -79,18 +25,6 @@ pub struct HostChoice { pub detail: String, } -/// Every machine tty7 already knows how to reach: saved profiles first, then -/// this computer's WSL distributions, then `~/.ssh/config` aliases. -/// -/// Profiles come first because they are the ones the user built deliberately; -/// the config aliases are a long tail that is often machine-generated. An alias -/// whose name matches a profile is dropped rather than listed twice — the -/// profile carries strictly more (credentials, forwards), so it is the better -/// of the two rows and the duplicate would only make the list longer. -/// -/// Distributions sit between the two: installing one is as deliberate as -/// writing a profile, but the list is *discovered* rather than written, so it -/// does not outrank the machines the user named by hand. pub fn available_hosts(cx: &App) -> Vec<HostChoice> { let mut out: Vec<HostChoice> = Vec::new(); let mut seen: Vec<String> = Vec::new(); @@ -133,13 +67,6 @@ pub fn available_hosts(cx: &App) -> Vec<HostChoice> { out } -/// The name the picker shows for `target`. -/// -/// Not `RemoteTarget`'s `Display`, which for a saved profile is its *uuid* — the -/// type deliberately cannot reach into the profile store, so anything putting a -/// machine's name in front of the user has to do this lookup. Falls back to the -/// `Display` for a machine no longer on file, which is the honest answer: that -/// is all tty7 still knows about it. pub fn label_for(target: &RemoteTarget, cx: &App) -> String { available_hosts(cx) .into_iter() @@ -148,17 +75,6 @@ pub fn label_for(target: &RemoteTarget, cx: &App) -> String { .unwrap_or_else(|| target.to_string()) } -/// The machines matching `query`, best match first. -/// -/// A `~/.ssh/config` with fifty `Host` blocks is normal, and a list that long -/// is not something anyone reads — it is something they search. So the picker -/// filters instead of scrolling to the letter `w`. -/// -/// An empty query keeps [`available_hosts`]'s own order (profiles first, then -/// aliases): that order is deliberate, and a score has nothing to add to it. A -/// non-empty one is the palette's fuzzy match over the name, falling back to the -/// endpoint at a penalty — an alias is how the user thinks of a box, but -/// "the one on 10.0.0.4" is a real way to look for one too. pub fn filter_hosts(hosts: &[HostChoice], query: &str) -> Vec<HostChoice> { let query = query.trim(); if query.is_empty() { @@ -168,35 +84,18 @@ pub fn filter_hosts(hosts: &[HostChoice], query: &str) -> Vec<HostChoice> { .iter() .filter_map(|host| host_score(query, host).map(|score| (score, host))) .collect(); - // Stable, so machines that score the same stay in the order above. scored.sort_by(|a, b| b.0.cmp(&a.0)); scored.into_iter().map(|(_, host)| host.clone()).collect() } -/// How well `host` answers `query`: its name, or its endpoint at a penalty -/// (`-3`, the same one the command palette puts on a subtitle match). fn host_score(query: &str, host: &HostChoice) -> Option<i32> { let label = crate::ui::palette::fuzzy_score(query, &host.label); let detail = crate::ui::palette::fuzzy_score(query, &host.detail).map(|score| score - 3); label.into_iter().chain(detail).max() } -/// The environment variable that stands a machine up on this computer. -/// -/// Set it to a `tty7-server` binary and the picker grows one extra row that -/// routes through [`RemoteTarget::LocalStdio`] — a real remote workspace, with a -/// real server process, a real control handshake and real routed panes, and no -/// sshd anywhere. It is the only way to exercise the whole path by hand, and -/// the same seam `crates/tty7-server/tests/routed_pane.rs` uses. -/// -/// Deliberately an environment variable and not a setting: it is a developer's -/// tool, and a row in Settings would be a feature nobody outside this repo -/// should ever see. pub const LOCAL_STDIO_ENV: &str = "TTY7_LOCAL_STDIO_SERVER"; -/// The dev-only "machine on this computer" row, when the environment asks for -/// one. Empty in every normal run, which is why nothing downstream needs to -/// know it exists. fn local_stdio_host() -> Option<HostChoice> { let program = std::env::var(LOCAL_STDIO_ENV) .ok() @@ -212,17 +111,8 @@ fn local_stdio_host() -> Option<HostChoice> { }) } -/// What the endpoint column says for a distribution. It has no address to -/// print, so it says what kind of machine it is and where it is instead — and -/// it doubles as the thing a user typing `wsl` into the search box matches, -/// since [`host_score`] falls back to this line. const WSL_DETAIL: &str = "WSL · this computer"; -/// This computer's WSL distributions, as machines a workspace can live on. -/// -/// Reads [`WslDistros`] and never probes: this runs inside the switcher's -/// render, and enumerating distros spawns a process. [`sweep_wsl`] is what -/// fills it. fn wsl_hosts(cx: &App) -> Vec<HostChoice> { let names = cx .try_global::<WslDistros>() @@ -231,7 +121,6 @@ fn wsl_hosts(cx: &App) -> Vec<HostChoice> { wsl_choices(names) } -/// The pure half of [`wsl_hosts`]: distro names in, rows out. fn wsl_choices(names: &[String]) -> Vec<HostChoice> { names .iter() @@ -239,55 +128,23 @@ fn wsl_choices(names: &[String]) -> Vec<HostChoice> { target: RemoteTarget::Wsl { distro: distro.clone(), }, - // The distro name *is* what the user calls it — `wsl -d` takes this - // exact string — so there is no friendlier name to look up. label: distro.clone(), detail: WSL_DETAIL.to_string(), }) .collect() } -/// This computer's WSL distributions, as of the last probe. -/// -/// A global filled in the background rather than a call, because -/// [`available_hosts`] runs inside the switcher's render and `wsl.exe -l -q` is -/// a process spawn — a beat on a warm distribution, much worse on a cold one. -/// The same shape `terminal::pane_liveness` uses for the machine answers drawn -/// two rows above these. #[derive(Default)] struct WslDistros { - /// The last list a probe actually produced. A probe that could not answer - /// leaves it alone — see [`sweep_wsl`]. names: Vec<String>, - /// When the last probe landed; `None` while none ever has, which is what - /// makes the first [`sweep_wsl`] run instead of waiting out the TTL. probed_at: Option<Instant>, - /// One probe at a time: the switcher can render many frames inside the - /// couple of hundred milliseconds `wsl.exe` takes to answer. in_flight: bool, } impl Global for WslDistros {} -/// How long a distribution list is trusted. Installing or unregistering one is -/// rare and deliberate, so this is not a poll — it is short enough that someone -/// who just ran `wsl --install` finds their distro by reopening the switcher -/// rather than by restarting tty7. const WSL_TTL: Duration = Duration::from_secs(30); -/// Re-enumerate this computer's WSL distributions if the list is missing or -/// stale. -/// -/// Safe to call from `render` or from an action: it reads the global, may start -/// background work, and never blocks. Off Windows there are no distributions -/// and nothing is spawned. -/// -/// **A probe that could not answer keeps the last list.** `wsl.exe` refuses while -/// a `wsl --shutdown` is in flight, and overwriting with its empty answer would -/// make every distribution vanish from the switcher for a TTL — over something -/// the user runs routinely and that changed nothing. An *authoritative* empty -/// answer still clears the rows, which is what unregistering the last -/// distribution has to look like. pub fn sweep_wsl(cx: &mut App) { if !cfg!(windows) { return; @@ -305,21 +162,12 @@ pub fn sweep_wsl(cx: &mut App) { .await; let _ = cx.update(|cx| { cx.update_global::<WslDistros, _>(|state, _| adopt_probe(state, probed)); - // The frame that asked for this list is long gone — the answer lands - // on a background task, and an idle switcher has nothing else that - // would redraw it. cx.refresh_windows(); }); }) .detach(); } -/// Fold a probe result into the state: an answer replaces the list, a probe that -/// could not answer leaves it standing, and either way the stamp advances so the -/// TTL governs the next attempt. -/// -/// Pure, because this is the whole judgement in [`sweep_wsl`] and the rest of it -/// is a background task on a platform CI cannot run. fn adopt_probe(state: &mut WslDistros, probed: Option<Vec<String>>) { if let Some(names) = probed { state.names = names; @@ -328,8 +176,6 @@ fn adopt_probe(state: &mut WslDistros, probed: Option<Vec<String>>) { state.in_flight = false; } -/// `user@host` — with the port only when it isn't the default, which is the -/// convention every other endpoint line in the app follows. fn endpoint_label(user: &str, host: &str, port: u16) -> String { let base = if user.is_empty() { host.to_string() @@ -343,17 +189,6 @@ fn endpoint_label(user: &str, host: &str, port: u16) -> String { } } -// --------------------------------------------------------------------------- -// 2. Resolving a choice into a spec -// --------------------------------------------------------------------------- - -/// Resolve a [`RemoteTarget`] into the self-contained spec the daemon needs — -/// secrets, jump chain and all — through the same path an SSH pane uses. -/// -/// `Err` is a user-facing sentence: a target can name a profile that has since -/// been deleted or an alias that is no longer in `~/.ssh/config`, and a -/// workspace pointing at one has to say so rather than fail as a connect error -/// much later. pub fn spec_for(target: &RemoteTarget, cx: &App) -> Result<NativeSshSpec, String> { let cfg = cx.global::<Config>(); match target { @@ -393,11 +228,6 @@ pub fn spec_for(target: &RemoteTarget, cx: &App) -> Result<NativeSshSpec, String cfg.verify_host_keys, )) } - // Both of these address their machine directly; there is no SSH - // connection to describe, which is exactly what `Err` means to - // [`control_route`] and - // [`crate::terminal::PaneWorkspace::route_header`] — they read the - // target instead and build a `wsl:` / `stdio:` header from it. RemoteTarget::Wsl { .. } => Err("a WSL workspace has no SSH connection".to_string()), RemoteTarget::LocalStdio { .. } => { Err("a local --stdio workspace has no SSH connection".to_string()) @@ -405,13 +235,6 @@ pub fn spec_for(target: &RemoteTarget, cx: &App) -> Result<NativeSshSpec, String } } -/// The route header a *control* connection to `target` opens with. -/// -/// The workspace-level twin of -/// [`PaneWorkspace::route_header`](crate::terminal::PaneWorkspace::route_header), -/// and it has to agree with it: the control stream and the pane streams of one -/// workspace must resolve to the same machine, or the window lists one box's -/// files while its terminals run on another. pub fn control_route(target: &RemoteTarget, cx: &App) -> Result<RouteHeader, String> { let header = match target { RemoteTarget::LocalStdio { program, args } => RouteHeader::local_stdio( @@ -421,46 +244,21 @@ pub fn control_route(target: &RemoteTarget, cx: &App) -> Result<RouteHeader, Str RemoteTarget::Wsl { distro } => RouteHeader::wsl(distro.clone()), _ => spec_for(target, cx).map(RouteHeader::ssh)?, }; - // Every header this client writes teaches [`RouteOrigins`] one machine, so a - // question the daemon relays back about it can be attributed without the - // daemon having to know this client's names for things. note_origin(&header.target, target); Ok(header) } -// --------------------------------------------------------------------------- -// 3. Connecting -// --------------------------------------------------------------------------- - -/// A machine that answered: its host object, and the workspaces it says it has. pub struct Connected { pub host: Arc<RemoteHost>, - /// The remote's `$HOME` — where a *new* workspace starts. The - /// remote's, never this client's. pub home: PathBuf, pub rows: Vec<RemoteWorkspaceRow>, } -/// Reach `target` and read its workspace list. **Blocking**; call from a -/// background task. -/// -/// The four hops, in order, each with its own failure sentence — a connect that -/// fails has to say *which* of them gave up, because "local daemon isn't -/// running" and "that machine refused the connection" want completely different -/// things from the user: -/// -/// 1. the local daemon is running (`spawn::ensure_running`) -/// 2. a local socket to it, carrying a [`RouteHeader`] -/// 3. the daemon's [`RouteAck`] — the SSH connect and channel open happened here -/// 4. the control handshake with the remote `tty7-server` pub fn connect_blocking( target: &RemoteTarget, header: RouteHeader, label: &str, ) -> Result<Connected, String> { - // Which machine a relayed question belongs to travels on the header itself - // (`RouteTarget::origin_key`), so nothing about this thread has to be true - // for [`GuiRouteAuth`] to name the right host. note_origin(&header.target, target); crate::daemon::spawn::ensure_running() .map_err(|e| format!("tty7's local daemon could not be started: {e}"))?; @@ -468,15 +266,6 @@ pub fn connect_blocking( let stream = crate::daemon::transport::connect() .map_err(|e| format!("could not reach tty7's local daemon: {e}"))?; - // `negotiate` writes the header and then *answers* — install consent, an - // auth prompt, a build-mismatch notice — until the daemon acks. Those - // questions are raised in the daemon process, which has the connection but - // no user; this is the end of the socket that has one. Before the relay - // existed a host needing any of them simply failed here. - // - // The ack carries the daemon's own reason when the SSH side failed — a - // refused connection, a rejected key, an unreachable jump host. Passing it - // through verbatim is the whole point of the ack existing. let mut stream = stream; crate::daemon::router::negotiate(&mut stream, &header) .map_err(|e| format!("could not reach {label}: {e}"))?; @@ -492,19 +281,8 @@ pub fn connect_blocking( Ok(Connected { host, home, rows }) } -/// Machines whose agent hooks this process has already looked at. static HOOKS_REFRESHED: Mutex<Vec<HostId>> = Mutex::new(Vec::new()); -/// Heal this machine's stale tty7 agent hooks — the ones naming a server binary -/// that is no longer the one this client installs, whether because a wire break -/// moved the name or because an older, version-naming client wrote them (see -/// [`crate::core::agent_hooks::refresh_remote_hooks`]). -/// -/// Off the connect's own thread, and once per machine per run: it is a config -/// read per agent over the control connection, and a reconnect — which happens -/// on a backoff loop — must not wait on six round trips to a box that may be an -/// ocean away. The hooks are for panes that do not exist yet at this point in -/// the connect, so nothing is racing it. fn refresh_agent_hooks_once(host: &Arc<RemoteHost>, home: &std::path::Path) { let id = host.id(); match HOOKS_REFRESHED.lock() { @@ -520,10 +298,6 @@ fn refresh_agent_hooks_once(host: &Arc<RemoteHost>, home: &std::path::Path) { }); } -/// The control handshake over the routed stream. Split out only because the -/// transport type differs per platform (a Unix socket here, a token-checked -/// loopback socket on Windows) and both need their shutdown wired so dropping -/// the host actually closes the link. #[cfg(unix)] fn handshake( stream: crate::daemon::transport::Stream, @@ -542,7 +316,6 @@ fn handshake( RemoteHost::over_tcp(stream, connection_key, hello) } -/// Ask a connected machine for its workspaces. pub fn list_workspaces(host: &Arc<RemoteHost>) -> io::Result<Vec<RemoteWorkspaceRow>> { match host.client().call(ControlRequest::MachineGet)? { ReplyOk::MachineTree(machine) => Ok(rows_from_machine(&machine)), @@ -553,18 +326,10 @@ pub fn list_workspaces(host: &Arc<RemoteHost>) -> io::Result<Vec<RemoteWorkspace } } -/// A one-off session token. The takeover (M6) decides between two -/// clients by this plus the hostname; until then it is only carried. fn new_session_token() -> String { uuid::Uuid::new_v4().to_string() } -/// This computer's name, as the remote should show it in a takeover notice. -/// -/// Memoized: it cannot change while the process runs, and the lookup shells out. -/// A machine that will not say its name is not an error — `"a tty7 client"` is a -/// worse label but a perfectly serviceable one, and failing a connect over it -/// would be absurd. fn client_hostname() -> String { static NAME: OnceLock<String> = OnceLock::new(); NAME.get_or_init(|| { @@ -579,23 +344,14 @@ fn client_hostname() -> String { .clone() } -// --------------------------------------------------------------------------- -// 4. Reading the remote's workspace records -// --------------------------------------------------------------------------- - -/// One workspace as the remote machine describes it, flattened for the picker. #[derive(Clone, Debug, PartialEq, Eq)] pub struct RemoteWorkspaceRow { pub id: WorkspaceId, - /// The user-set name when there is one, else derived from the tabs' repo - /// groups and cwds — the same precedence `Workspace::display_name` gives a - /// local workspace, computed here from the machine's tree. pub name: String, pub panes: usize, pub last_active: u64, } -/// Turn a machine's tree into picker rows, newest first. pub fn rows_from_machine(machine: &tty7_core::core::machine::Machine) -> Vec<RemoteWorkspaceRow> { let mut rows: Vec<RemoteWorkspaceRow> = machine .workspaces @@ -611,60 +367,23 @@ pub fn rows_from_machine(machine: &tty7_core::core::machine::Machine) -> Vec<Rem rows } -// --------------------------------------------------------------------------- -// 5. Holding the connections -// --------------------------------------------------------------------------- - -/// The live control links, by [`HostId`] — one per machine, one machine per -/// entry. -/// -/// The name says the model: every machine this client talks to is reached -/// over exactly one control link, and the local machine is a machine like any -/// other — its link simply lives in its own global -/// ([`LocalLink`](crate::ui::local_link::LocalLink)) because it is in-process -/// rather than wire-backed. One entry per *machine*, not per workspace — the -/// same granularity the SSH connection is pooled at and the same one -/// [`crate::ui::host_registry`] uses, so two windows on one box share a -/// connection, a host object and a git-status cache. This table holds the -/// concrete [`RemoteHost`] because pushing a layout needs its control client; -/// `HostRegistry` holds the same object erased to `dyn Host` for the panels. #[derive(Default)] pub struct HostLinks { hosts: HashMap<HostId, Arc<RemoteHost>>, - /// Each machine's `$HOME`, as its handshake reported it. - /// - /// Kept beside the connection because it is the same lifetime and the same - /// scope: it is a fact about the *machine*, true for every window, and it - /// arrives on the same handshake. It used to live only in the window-owned - /// `Tty7App::host_snapshots`, which meant "New Workspace" appeared on a - /// connected machine **only in the window that had personally connected to - /// it** — a machine brought up by the reconnect supervisor (app restart, a - /// dropped link, another window's connect) reached `Link::Connected` with no - /// home recorded anywhere this window could see, and the row silently - /// vanished. homes: HashMap<HostId, PathBuf>, } impl Global for HostLinks {} impl HostLinks { - /// The connection to `id`, if this process has one. pub fn get(cx: &mut App, id: HostId) -> Option<Arc<RemoteHost>> { cx.default_global::<HostLinks>().hosts.get(&id).cloned() } - /// Where a *new* workspace on `id` would start: that machine's own `$HOME`, - /// never this client's. pub fn home(cx: &mut App, id: HostId) -> Option<PathBuf> { cx.default_global::<HostLinks>().homes.get(&id).cloned() } - /// Record a connection, and register the same object with the host registry - /// so the file tree / git / editor reach it the way they reach any host. - /// - /// `home` rides along rather than sitting behind its own setter so that no - /// connect path can register a machine and forget to say where its `$HOME` - /// is — which is exactly how the reconnect path lost it. pub fn insert(cx: &mut App, host: Arc<RemoteHost>, home: PathBuf) { let id = host.id(); crate::ui::host_registry::HostRegistry::insert(cx, Arc::clone(&host).into_shared()); @@ -673,7 +392,6 @@ impl HostLinks { table.homes.insert(id, home); } - /// Drop a machine's connection once nothing is using it. pub fn remove(cx: &mut App, id: HostId) { let table = cx.default_global::<HostLinks>(); table.hosts.remove(&id); @@ -681,23 +399,11 @@ impl HostLinks { crate::ui::host_registry::HostRegistry::remove(cx, id); } - /// Machines currently connected. Diagnostics and teardown. pub fn len(cx: &mut App) -> usize { cx.default_global::<HostLinks>().hosts.len() } } -// --------------------------------------------------------------------------- -// 6. Install consent -// --------------------------------------------------------------------------- - -/// The prompt shown before tty7 writes a binary onto someone else's machine. -/// -/// Every field of [`InstallRequest`] appears, because the point of asking is -/// that the user can actually judge the answer: *what* is being written, *where* -/// it lands, *how big* it is, *where it came from*, and the checksum they can -/// verify by hand. A prompt that said only "install the server?" would be a -/// consent ritual rather than consent. pub fn install_detail(request: &InstallRequest) -> String { format!( "tty7 will write its server binary to {machine} so this machine can host \ @@ -720,18 +426,10 @@ pub fn install_detail(request: &InstallRequest) -> String { ) } -/// The prompt's title. Names the machine, because a user with several open -/// windows needs to know which one is asking. pub fn install_title(request: &InstallRequest) -> String { format!("Install tty7's server on \u{201c}{}\u{201d}?", request.host) } -/// Bytes as the user thinks of them. Binary units with one decimal, matching the -/// download sizes shown elsewhere in the app. -/// -/// Shared with the switcher's install bar so the size quoted in the consent -/// prompt and the size counting up underneath it are formatted identically — -/// they are the same number, and "8.2 MiB" beside "8.2 MB" would look like two. pub fn human_bytes(n: u64) -> String { const KIB: f64 = 1024.0; let n = n as f64; @@ -749,23 +447,14 @@ pub fn human_bytes(n: u64) -> String { unreachable!("the loop returns on its last iteration") } -/// How long a blocked installer waits for the user before giving up. -/// -/// Generous — a prompt can sit behind another window — but finite, because the -/// thread parked on it is holding an SSH connect open. Timing out **declines**: -/// that is the same answer [`crate::daemon::install::DenyInstall`] gives, and an -/// unanswered question is not consent. const CONSENT_TIMEOUT: Duration = Duration::from_secs(180); -/// One install waiting on an answer. pub struct PendingInstall { pub request: InstallRequest, reply: std::sync::mpsc::SyncSender<InstallDecision>, } impl PendingInstall { - /// Answer it. Dropping a `PendingInstall` without answering leaves the - /// installer to time out and decline, which is the safe direction. pub fn answer(self, decision: InstallDecision) { let _ = self.reply.send(decision); } @@ -773,12 +462,6 @@ impl PendingInstall { static MAILBOX: Mutex<Vec<PendingInstall>> = Mutex::new(Vec::new()); -/// The consent handler the GUI registers at startup ([`register`]). -/// -/// `confirm` is called on whichever thread is doing the install, which is never -/// the UI thread, so it parks the request in [`MAILBOX`] and blocks. The GUI -/// picks it up with [`take_pending_install`] while a connect is in flight and -/// answers it. pub struct GuiInstallConfirm; impl InstallConfirm for GuiInstallConfirm { @@ -798,33 +481,12 @@ impl InstallConfirm for GuiInstallConfirm { } } -/// The latest progress report, per machine. -/// -/// Keyed by [`HostId`] rather than by the label the user typed, because the -/// string the installer reports is a *daemon-side* connection key -/// (`install::connection_label`) — the same one a relayed mismatch carries, and -/// the same one [`origin_host`] exists to translate. An alias like `java` never -/// reaches that side. -/// -/// Several machines can be installing at once (two windows, two connects), so -/// this is a map and not a slot. [`clear_install_progress`] drops an entry as -/// soon as its connect settles. static PROGRESS: Mutex<Vec<(HostId, InstallPhase)>> = Mutex::new(Vec::new()); -/// The progress sink the GUI registers ([`register`]). -/// -/// Called from whichever thread is moving bytes — the routed connection's -/// reader, in the normal case — so it does nothing but overwrite the machine's -/// slot. The panel picks it up on the poll it already runs while a connect is in -/// flight (`watch_for_install_consent`), which is what keeps a burst of reports -/// from becoming a burst of repaints. pub struct GuiInstallProgress; impl InstallProgress for GuiInstallProgress { fn report(&self, host: &str, phase: InstallPhase) { - // Same fallback as the auth relay's: a key this client never noted an - // origin for still resolves to a stable id, so an install is never - // silently unattributable. let id = origin_host(host).unwrap_or_else(|| HostId::from_connection_key(host)); let Ok(mut slots) = PROGRESS.lock() else { return; @@ -836,7 +498,6 @@ impl InstallProgress for GuiInstallProgress { } } -/// What `host` last reported, if it is installing right now. pub fn install_progress_for(host: HostId) -> Option<InstallPhase> { let slots = PROGRESS.lock().ok()?; slots @@ -845,64 +506,30 @@ pub fn install_progress_for(host: HostId) -> Option<InstallPhase> { .map(|(_, phase)| *phase) } -/// Forget a machine's progress. Called when a connect settles either way: on -/// success the install is over, and on failure the error takes the same space -/// the bar was using. pub fn clear_install_progress(host: HostId) { if let Ok(mut slots) = PROGRESS.lock() { slots.retain(|(known, _)| *known != host); } } -/// Install the GUI's consent handler. Called once at startup; without it the -/// process-wide default declines every install, which is deliberate — a tty7 -/// with no UI attached must not decide on the user's behalf that writing to -/// their servers is fine. pub fn register(cx: &mut App) { crate::daemon::install::set_install_confirm(Arc::new(GuiInstallConfirm)); crate::daemon::install::set_install_progress(Arc::new(GuiInstallProgress)); crate::daemon::router::set_route_auth_responder(Arc::new(GuiRouteAuth)); - // Touch the globals so the first connect isn't also the first allocation of - // the table it writes into, on a thread that is holding a socket open. let _ = HostLinks::len(cx); } -/// The oldest install waiting for an answer, if any. pub fn take_pending_install() -> Option<PendingInstall> { MAILBOX.lock().ok()?.pop() } -// --------------------------------------------------------------------------- -// 6b. Auth prompts on a routed connection -// --------------------------------------------------------------------------- - -/// One interactive SSH question waiting on an answer. -/// -/// The same mailbox shape as [`PendingInstall`], and for the same reason: the -/// question is raised on a background thread holding a connect open, and only -/// the UI thread can put a sheet in front of a person. -/// -/// **Why a routed connection needs its own mailbox at all.** A native-SSH -/// *pane*'s prompts ride that pane's own stream and land in its -/// `TerminalView` — `RemoteTerminal::take_auth_prompt`. A remote workspace's -/// connect has no pane and no view yet; the prompt arrives during the route -/// setup, before anything exists to render it. pub struct PendingAuth { - /// Which machine is asking. - /// - /// Without it the queue in `ui::remote_workspace` could only be a global - /// "one at a time" latch: a prompt would have no owner, so a sheet could - /// not say which box wants the password, and two machines asking at once - /// could not be told apart. Resolved from the target on the connection's own - /// [`RouteHeader`] (see [`RouteOrigins`]). pub host: HostId, pub prompt: AuthPromptKind, reply: std::sync::mpsc::SyncSender<AuthResponse>, } impl PendingAuth { - /// Answer it. Dropping one unanswered lets the connect time out and cancel, - /// which fails the auth step cleanly rather than hanging. pub fn answer(self, response: AuthResponse) { let _ = self.reply.send(response); } @@ -910,42 +537,14 @@ impl PendingAuth { static AUTH_MAILBOX: Mutex<Vec<PendingAuth>> = Mutex::new(Vec::new()); -/// One machine, under both names it has: the router's -/// ([`RouteTarget::origin_key`]) and this client's ([`HostId`] / -/// [`RemoteTarget`]). struct RouteOrigin { key: String, target: RemoteTarget, host: HostId, } -/// Every machine this client has written a [`RouteHeader`] for. -/// -/// **Why a table and not the connecting thread.** A question raised while a -/// routed connection is being set up has to be attributed to a machine: the -/// sheet names it, the start-up queue is keyed by it (D7), and -/// `raise_auth_sheet` finds a window with it. That used to be read off a -/// thread-local set by [`connect_blocking`], which held for the workspace -/// connect and quietly did not for a pane's — `connect_routed` lives in -/// `terminal::` and cannot reach `ui::`, so it set nothing and every routed pane -/// prompt was attributed to no machine at all. -/// -/// The router names the machine on the header instead, and this maps that name -/// back to the id this client files it under. Both directions are needed -/// because neither side can compute the other's: the daemon has no idea a -/// machine is "the `build` alias" or "profile 7f3…", and the client cannot -/// re-derive a saved profile from the endpoint the daemon knows it by. -/// -/// A plain static rather than a gpui `Global`: it is read on whichever -/// background thread is holding the connect open, which is never the UI thread. -/// Entries are never removed — one per machine addressed in a session, and a -/// machine's identity does not stop being true. static ORIGINS: Mutex<Vec<RouteOrigin>> = Mutex::new(Vec::new()); -/// Record that `target` is the machine the router calls `route.origin_key()`. -/// -/// Called from every place this client turns a [`RemoteTarget`] into a route -/// header, which is the only moment both names are in hand at once. pub fn note_origin(route: &crate::daemon::router::RouteTarget, target: &RemoteTarget) { let key = route.origin_key(); let Ok(mut origins) = ORIGINS.lock() else { @@ -953,11 +552,6 @@ pub fn note_origin(route: &crate::daemon::router::RouteTarget, target: &RemoteTa }; let host = target.host_id(); match origins.iter_mut().find(|o| o.key == key) { - // Last writer wins. Two saved profiles can differ only in credentials - // and so share an endpoint — and therefore a key — in which case - // "whichever the user most recently addressed" is the best available - // answer to which of them a prompt is for. It is the *machine* that is - // certain here, and the machine is what the sheet and the queue need. Some(existing) => { existing.target = target.clone(); existing.host = host; @@ -970,19 +564,11 @@ pub fn note_origin(route: &crate::daemon::router::RouteTarget, target: &RemoteTa } } -/// This client's id for the machine the router names `key`, if it has one. pub fn origin_host(key: &str) -> Option<HostId> { let origins = ORIGINS.lock().ok()?; origins.iter().find(|o| o.key == key).map(|o| o.host) } -/// This client's target for the machine the router names `key`. -/// -/// The mismatch prompt's route back: [`MismatchedRemoteDaemon::host`] is a -/// daemon-side connection label, which is the same string -/// [`RouteTarget::origin_key`](crate::daemon::router::RouteTarget::origin_key) -/// produces for an SSH machine — so this is how "restart the server on *that* -/// box" turns back into a header this client can write. pub fn origin_target(key: &str) -> Option<RemoteTarget> { let origins = ORIGINS.lock().ok()?; origins @@ -991,7 +577,6 @@ pub fn origin_target(key: &str) -> Option<RemoteTarget> { .map(|o| o.target.clone()) } -/// The routed-auth handler the GUI registers at startup ([`register`]). pub struct GuiRouteAuth; impl crate::daemon::router::RouteAuthResponder for GuiRouteAuth { @@ -1001,11 +586,6 @@ impl crate::daemon::router::RouteAuthResponder for GuiRouteAuth { prompt: &AuthPromptKind, ) -> AuthResponse { let key = machine.origin_key(); - // A machine this client has never written a header for cannot raise a - // prompt — every routed connection starts with one. If one ever does, - // it is attributed to an id derived from the router's own name for it - // rather than dropped: an entry under a key no window matches still gets - // answered (and times out cleanly); a lost one hangs a connect. let host = origin_host(&key).unwrap_or_else(|| HostId::from_connection_key(&key)); let (tx, rx) = std::sync::mpsc::sync_channel(1); { @@ -1023,64 +603,20 @@ impl crate::daemon::router::RouteAuthResponder for GuiRouteAuth { } } -/// The oldest routed auth prompt waiting for an answer, if any. -/// -/// Polled by the connect watcher beside [`take_pending_install`]. While nothing -/// polls it the prompts time out and cancel — byte-for-byte the behaviour this -/// path had before the relay, so wiring the sheet is an improvement and never a -/// regression. pub fn take_pending_auth() -> Option<PendingAuth> { AUTH_MAILBOX.lock().ok()?.pop() } -/// Whose turn it is to drain [`AUTH_MAILBOX`], for tests only. -/// -/// The mailbox is process-global, and [`pump_auth_sheets`] takes *every* entry in -/// one pass — correct for the app, where one tick serves one mailbox, and fatal -/// in a test binary, where a test waiting for the prompt it just caused shares -/// that mailbox with every gpui test driving a tick. The prompt gets drained by a -/// tick that has no idea it was spoken for, and the waiting test never sees it. -/// -/// So a test that needs its own prompt back claims this first, and the drain -/// yields while it is held. Compiled out of a release build, where there is one -/// app, one tick and nothing to arbitrate. -/// -/// [`pump_auth_sheets`]: crate::ui::remote_workspace::pump_auth_sheets #[cfg(test)] pub(crate) static MAILBOX_TURN: Mutex<()> = Mutex::new(()); -/// Claim [`MAILBOX_TURN`], ignoring poisoning: a test that panicked while holding -/// it has nothing to corrupt here — the guard protects an ordering, not data. #[cfg(test)] pub(crate) fn claim_mailbox() -> std::sync::MutexGuard<'static, ()> { MAILBOX_TURN.lock().unwrap_or_else(|e| e.into_inner()) } -// --------------------------------------------------------------------------- -// 7. Remote daemon version skew -// --------------------------------------------------------------------------- - -/// The answers the dialect-mismatch prompt offers, in the order `window.prompt` -/// takes them — **index 1 is the destructive one**, which is what -/// `prompt_remote_daemon_mismatch` matches on. -/// -/// Written down here rather than at the prompt because [`mismatch_detail`] spells -/// both out by name in its body: a detail explaining a button that is no longer -/// there is worse than no explanation at all. `Keep Sessions` used to be index 0 -/// and had to go, which is precisely the drift this prevents repeating. pub const MISMATCH_ANSWERS: [&str; 2] = ["Cancel", "Restart Server"]; -/// The restart-or-cancel question for a remote `tty7-server` this client cannot -/// talk to. -/// -/// **There is no "keep and carry on" here, and the wording must not imply one.** -/// A mismatch is only ever recorded when the running daemon's *dialects* are not -/// ours (`Installer::check_running_build`) — a merely different build that can -/// still speak to us is reused in silence and never reaches this prompt. The -/// workspace connects to the daemon that is running, so leaving it in place -/// means the connection fails in the handshake. The real choice is between -/// ending that machine's sessions and not connecting at all, and saying so is -/// the difference between a decision and a trick. pub fn mismatch_detail(m: &MismatchedRemoteDaemon) -> String { let running = match (&m.running_version, &m.running_exe) { (Some(v), Some(exe)) => format!("{v} (from {exe})"), @@ -1100,28 +636,14 @@ pub fn mismatch_detail(m: &MismatchedRemoteDaemon) -> String { ) } -/// The prompt's title. pub fn mismatch_title(m: &MismatchedRemoteDaemon) -> String { format!("Restart tty7's server on \u{201c}{}\u{201d}?", m.host) } -/// The machine a mismatch record is about, as this client knows it. -/// -/// `None` when the record names a machine no header in this session addressed — -/// which cannot happen for a mismatch (it is discovered *while* opening a routed -/// connection this client asked for), and which the caller turns into a refusal -/// rather than a guess. pub fn mismatch_target(m: &MismatchedRemoteDaemon) -> Option<RemoteTarget> { origin_target(&m.host) } -/// Carry out "Restart Server": stop the `tty7-server` on the -/// machine `header` names and start this client's build. **Blocking**, and -/// **every pane that server hosts dies** — only ever call this with the user's -/// explicit answer behind it. -/// -/// The connection is a setup window and nothing else: the daemon acks and both -/// ends close. Reconnecting afterwards is the supervisor's job, not this one's. pub fn restart_server_blocking(header: RouteHeader, label: &str) -> Result<(), String> { let action = header.action; crate::daemon::spawn::ensure_running() @@ -1130,10 +652,6 @@ pub fn restart_server_blocking(header: RouteHeader, label: &str) -> Result<(), S .map_err(|e| format!("could not reach tty7's local daemon: {e}"))?; let ack = crate::daemon::router::negotiate(&mut stream, &header) .map_err(|e| format!("could not restart tty7's server on {label}: {e}"))?; - // An older local daemon does not know the action and forwards the - // connection instead — a link, not a restart. Saying nothing happened is the - // only honest answer; the alternative is a "done" over a server still - // running the old build. if !ack.performed(action) { return Err(format!( "this machine's tty7 daemon is an older build and cannot restart the server on \ @@ -1159,10 +677,6 @@ mod tests { } } - /// The confirmation says *what* is written, *where*, - /// *how big* and *where from*. A field silently dropped from the prompt - /// would turn an informed decision back into a blind one, so every one of - /// them is pinned here rather than eyeballed. #[test] fn the_install_prompt_states_every_field_of_the_request() { let request = request(); @@ -1179,10 +693,7 @@ mod tests { "{needle:?} missing from:\n{detail}" ); } - // The size is shown in units, not raw bytes. assert!(detail.contains("9.0 MiB"), "{detail}"); - // And the title names the machine, so a user with several windows open - // knows which one is asking. assert!(install_title(&request).contains("me@build-box:22")); } @@ -1194,9 +705,6 @@ mod tests { assert_eq!(human_bytes(3 * 1024 * 1024 * 1024), "3.0 GiB"); } - /// An unanswered consent request must not read as approval — the default - /// everywhere in the install path is to decline, and a dropped prompt is - /// exactly the case where nobody said yes. #[test] fn an_unanswered_install_request_declines() { let (tx, rx) = std::sync::mpsc::sync_channel(1); @@ -1219,11 +727,8 @@ mod tests { assert_eq!(rx.recv().unwrap(), InstallDecision::Approve); } - /// The handler parks the request rather than deciding, so the GUI can pick - /// it up; and the mailbox hands it back exactly once. #[test] fn the_gui_handler_parks_the_request_for_the_ui_to_answer() { - // Drain anything a sibling test left behind — the mailbox is process-wide. while take_pending_install().is_some() {} let handle = std::thread::spawn(|| GuiInstallConfirm.confirm(&request())); let pending = loop { @@ -1250,23 +755,8 @@ mod tests { ) } - /// **A routed auth prompt knows which machine asked.** - /// - /// The attribution rides the connection's own [`RouteHeader`] — the router - /// names the machine, [`RouteOrigins`] maps that name back to this client's - /// id. Without the host the start-up queue could only be a global "one at a - /// time" latch: no sheet could name the box, and two machines asking at once - /// could not be told apart. - /// - /// Answered **on another thread than the one that noted the origin**, which - /// is the point: the mechanism this replaced could only work when the two - /// were the same thread, and on the pane path they never are. #[test] fn a_routed_auth_prompt_carries_the_machine_that_raised_it() { - // Held for the whole exchange: the prompt this test is about to cause - // goes into a process-global mailbox, and `pump_auth_sheets` drains all of - // it from any gpui test in this binary that drives a tick. Without the - // claim that drain takes this test's prompt and the wait below never ends. let _turn = claim_mailbox(); while take_pending_auth().is_some() {} let target = RemoteTarget::direct("me", "build-box", 22); @@ -1284,14 +774,6 @@ mod tests { }, ) }); - // Bounded, because this loop is the difference between a stolen prompt - // being a failure and being a *hang*. `AUTH_MAILBOX` is process-global - // and `pump_auth_sheets` drains every entry in one pass, so any gpui test - // in this binary that drives a tick can take this prompt before the line - // below does — and unbounded, this test then spins until CI's six-hour - // job limit. It has: a `main` run sat inside this test for 2h50m, and - // three Windows runs before it went the same way, none of them naming a - // test until the run was cancelled and its partial log read back. let deadline = Instant::now() + Duration::from_secs(10); let pending = loop { if let Some(p) = take_pending_auth() { @@ -1316,15 +798,6 @@ mod tests { ); } - /// **Both routed paths land on the same machine.** A workspace's control - /// connection and one of its panes are built from the same SSH spec but by - /// different code on different threads (`connect_blocking` and - /// `connect_routed`), and a prompt raised on either has to name the one - /// machine — the queue, the sheet and the window lookup are all keyed by it. - /// - /// The pane header differs from the control one (`--pane`, `channel: Pane`) - /// and must *not* look like a second machine, which is what the shared - /// origin key buys. #[test] fn a_pane_and_its_workspace_resolve_to_the_same_machine() { use crate::daemon::router::{RouteHeader, RouteTarget as RT}; @@ -1339,8 +812,6 @@ mod tests { "a pane's header names the machine its workspace's does" ); - // And a `--stdio` machine's two headers agree too, though the pane's - // argv carries `--pane` and the control one does not. let local = RemoteTarget::LocalStdio { program: "/opt/tty7-server".into(), args: vec!["--stdio".into()], @@ -1357,10 +828,6 @@ mod tests { assert_eq!(origin_host(&pane.origin_key()), Some(local.host_id())); } - /// The mismatch prompt's way home: the daemon labels a mismatch with the - /// connection key, which is the same string the router names the machine - /// by — so "restart the server on *that* box" resolves to a target this - /// client can build a header from. Without it the restart has nowhere to go. #[test] fn a_mismatch_record_resolves_back_to_the_machine_it_is_about() { let target = RemoteTarget::direct("me", "skew-box", 2222); @@ -1381,8 +848,6 @@ mod tests { }; assert_eq!(mismatch_target(&mismatch), Some(target)); - // A machine this client never addressed answers `None` rather than a - // guess — the restart refuses instead of acting on the wrong box. assert_eq!( mismatch_target(&MismatchedRemoteDaemon { host: "me@never-seen:22".into(), @@ -1406,7 +871,6 @@ mod tests { assert!(detail.contains("me@build-box:22"), "{detail}"); assert!(mismatch_title(&m).contains("me@build-box:22")); - // A daemon whose build could not be read still produces a usable prompt. let unknown = MismatchedRemoteDaemon { running_version: None, running_exe: None, @@ -1415,11 +879,6 @@ mod tests { assert!(mismatch_detail(&unknown).contains("an unknown build")); } - /// The detail explains the buttons by name, so it has to name the ones that - /// are actually there. This is a prompt whose whole job is to make a - /// destructive choice legible; a body describing an answer the prompt does - /// not offer (as it did while `Keep Sessions` was one of them) turns that - /// back into a guess. #[test] fn the_mismatch_detail_explains_every_answer_the_prompt_offers() { let detail = mismatch_detail(&MismatchedRemoteDaemon { @@ -1440,8 +899,6 @@ mod tests { assert_eq!(endpoint_label("", "box.local", 22), "box.local"); } - /// The picker's rows come from the machine's tree: newest first, with a - /// name derived the way a local workspace's would be when none is set. #[test] fn rows_from_the_tree_sort_newest_first_and_derive_names() { use tty7_core::core::machine::{Machine, PaneRecord, Tab, Workspace}; @@ -1493,9 +950,6 @@ mod tests { } } - /// An empty query is not a search: it must hand back the list exactly as - /// [`available_hosts`] built it (profiles first, aliases after), because - /// that order carries information a score cannot reproduce. #[test] fn an_empty_query_keeps_every_machine_in_order() { let hosts = vec![ @@ -1506,8 +960,6 @@ mod tests { assert_eq!(all, hosts); } - /// The two things a user types: the name they gave the box, and the address - /// they remember it by. Both find it; a name match outranks an address one. #[test] fn a_query_matches_the_name_or_the_endpoint() { let hosts = vec![ @@ -1524,25 +976,16 @@ mod tests { assert_eq!(by_address.len(), 1); assert_eq!(by_address[0].label, "aws-xy"); - // "or" is in `orb`'s name and in the other two's `root@` — the machine - // actually called that comes first. let mixed = filter_hosts(&hosts, "or"); assert_eq!(mixed[0].label, "orb", "a name match beats an endpoint one"); } - /// A query nothing answers filters everything out rather than falling back - /// to the full list — the panel's empty state says so, and a silent "here - /// is everything" would read as the search being broken. #[test] fn a_query_nothing_matches_returns_nothing() { let hosts = vec![host("gate2jup", "root@18.143.92.244")]; assert!(filter_hosts(&hosts, "zzz").is_empty()); } - /// A distro row carries the exact string `wsl -d` takes, because that is - /// what [`RouteHeader::wsl`](crate::daemon::router::RouteHeader::wsl) will - /// be handed and what `wsl:<distro>` keys the machine by. A row whose label - /// were prettied up ("Ubuntu 22.04") would connect to nothing. #[test] fn a_wsl_row_names_the_distro_verbatim() { let rows = wsl_choices(&["Ubuntu-22.04".to_string(), "Arch".to_string()]); @@ -1558,16 +1001,11 @@ mod tests { assert_eq!(rows[1].label, "Arch"); } - /// Nothing installed is not an empty *section* — it is no section at all, - /// which is what keeps the band off a Mac and off a Windows box with no WSL. #[test] fn no_distros_is_no_rows() { assert!(wsl_choices(&[]).is_empty()); } - /// Both ways a user looks for a distro: by its name, and by the kind of - /// thing it is. The second only works because the endpoint column says - /// `WSL`, and [`host_score`] searches it. #[test] fn a_distro_is_found_by_name_or_by_wsl() { let mut hosts = vec![host("gate2jup", "root@18.143.92.244")]; @@ -1582,10 +1020,6 @@ mod tests { assert_eq!(by_kind[0].label, "Ubuntu"); } - /// **A probe that could not answer is not an empty machine list.** `wsl.exe` - /// refuses while a `wsl --shutdown` is in flight; adopting that as "you have - /// no distributions" would empty the switcher for a TTL over a command that - /// changed nothing. #[test] fn a_failed_probe_keeps_the_distros_it_already_had() { let mut state = WslDistros { @@ -1599,9 +1033,6 @@ mod tests { assert!(!state.in_flight, "the next sweep is allowed to run"); } - /// An answer is adopted whole, *including* an empty one — unregistering the - /// last distribution has to take its row away, or the picker offers a machine - /// that cannot be reached. #[test] fn an_answered_probe_replaces_the_list_even_when_it_is_empty() { let mut state = WslDistros { diff --git a/src/ui/remote_workspace.rs b/src/ui/remote_workspace.rs index 57ecd1e7..d074cabb 100644 --- a/src/ui/remote_workspace.rs +++ b/src/ui/remote_workspace.rs @@ -1,39 +1,3 @@ -//! The window's half of "Connect to Host". -//! -//! [`ui::remote_connect`](crate::ui::remote_connect) is the plumbing — SSH -//! specs, routed control connections, the remote machine's tree. This is the -//! part that lives on a window: the state the home page renders, the steps that -//! move between those states, and the guards that keep a window on one machine. -//! -//! ## One window, one machine -//! -//! There is a single rule, and its inverse is listed under *never do -//! this*: **a window shows one workspace on one machine, and every tab and pane -//! in it is on that machine.** The whole M5 data layer leans on it — a workspace -//! stores its `host` once rather than per pane, and `sidebar_group` stays a bare -//! `PathBuf` because a repo root only has to be unique within one machine. -//! -//! So the invariant cannot be a comment. Three things enforce it here: -//! -//! | Path | Guard | -//! |---|---| -//! | New tab / split | [`Tty7App::spawn_host`] — a remote window refuses to spawn a local shell | -//! | Reopening a closed tab | [`Tty7App::rebind_host`] clears the closed stack when a window changes machine | -//! | Restart / session restore | the machine's own tree is the only layout source — the client persists no layout at all | -//! -//! The fourth path, dragging a tab between windows, does not exist in tty7: -//! tabs never leave the window they were opened in, so there is nothing to -//! guard there. If that ever changes, it needs a fourth row. -//! -//! ## What is left for M6 -//! -//! The flow below reaches `Attached` and stops. Reconnect backoff, takeover -//! (`Preempted`) and the start-up auth queue are M6, and the -//! seams for them are named where they belong: [`RemoteStatus`] has the two -//! states to add, [`Tty7App::connect_remote_workspace`] is the one entry point -//! that opens a connection, and [`Tty7App::reopen_remote_at_startup`] is where -//! the "`open: true` remote workspaces reconnect at launch" rule hooks in. - use std::path::PathBuf; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; @@ -49,26 +13,12 @@ use crate::daemon::install::InstallDecision; use crate::ui::app::Tty7App; use crate::ui::remote_connect::{self, HostChoice, RemoteWorkspaceRow}; -/// Where the home page's "Connect to Host" flow has got to. -/// -/// Every state is a place the user can be *left*: no -/// failure closes a window, so [`ConnectFlow::Failed`] is a resting state with -/// its own affordances rather than a toast on the way back to the picker. pub enum ConnectFlow { - /// Reaching a machine. Blocking work is on a background task; this is what - /// the panel shows while it runs. Connecting { choice: HostChoice }, - /// It did not work, and the window stays here until the user decides what to - /// do about it (never auto-close, always offer the next move). Failed { choice: HostChoice, error: String }, } impl ConnectFlow { - /// The machine this flow is about. - /// - /// Still an `Option` at the call sites because the *flow* is optional; both - /// remaining states name a machine, which is why picking one no longer has a - /// state of its own — [`ui::switcher`](crate::ui::switcher) is the list now. pub fn choice(&self) -> Option<&HostChoice> { match self { ConnectFlow::Connecting { choice } | ConnectFlow::Failed { choice, .. } => Some(choice), @@ -76,47 +26,17 @@ impl ConnectFlow { } } -/// A **remote** workspace's connection state. -/// -/// Only remote workspaces have one — a local window is not a disconnected -/// remote window, it is a window with no machine to be connected to, which is -/// why [`Tty7App::remote_status`] answers `Option`. Without that the enum grows -/// a "local" variant that every `match` then has to pretend is a network state. -/// -/// All six states. `Reconnecting` and `Preempted` were named here by M5 before -/// they existed, because every consumer — the status strip, the read-only -/// degrade, the input gate — wants to switch on the whole set, and a -/// two-variant enum would get written as a `bool` instead. #[derive(Clone, Debug, PartialEq, Eq)] pub enum RemoteStatus { - /// There is no connection and nothing is trying to make one. Disconnected, Connecting, Attached, - /// The link dropped and the supervisor is retrying, for ever, on the - /// backoff in [`Backoff`]. Read-only, and the window stays open — design - /// No failure closes a window. - Reconnecting { - /// How many attempts have already failed. Shown because "reconnecting…" - /// that has been on screen for four minutes should say so. - attempt: u32, - }, - /// Somebody else attached to this workspace (D8). Read-only and - /// **not** retried: a client that reconnected automatically would fight the - /// machine the user just moved to. Taking it back is a deliberate act. - Preempted { - /// The machine that took it, as the remote reported it. - by: String, - }, - /// An attempt failed, with the reason. Distinct from `Disconnected` because - /// the reason is the most useful thing on the screen, and because only this - /// state has something to retry. + Reconnecting { attempt: u32 }, + Preempted { by: String }, Failed(String), } impl RemoteStatus { - /// The one-line status the window shows. `None` only when everything is - /// working — a permanent "you are fine" banner is noise. pub fn strip_message(&self, machine: &str) -> Option<String> { match self { RemoteStatus::Attached => None, @@ -134,14 +54,6 @@ impl RemoteStatus { } } - /// The line along the bottom of a degraded window (底部一条 - /// "未连接 — 输入暂不生效"). - /// - /// Separate from [`RemoteStatus::strip_message`] because they answer - /// different questions and sit at opposite ends of the window: the strip - /// says *what is happening to the connection*, this says *what that means - /// for the keyboard*. Collapsing them would put a retry countdown next to - /// the cursor or a typing notice in the title area. pub fn input_notice(&self) -> Option<&'static str> { match self { RemoteStatus::Attached => None, @@ -150,13 +62,9 @@ impl RemoteStatus { } } - /// What the status strip's button offers, or `None` when there is nothing - /// useful to do: a failure state always offers the next move. pub fn action_label(&self) -> Option<&'static str> { match self { RemoteStatus::Attached | RemoteStatus::Connecting => None, - // Retrying *now* rather than waiting out the backoff. The automatic - // retry keeps running either way, so this can only ever help. RemoteStatus::Reconnecting { .. } => Some("Retry Now"), RemoteStatus::Preempted { .. } => Some("Take Back"), RemoteStatus::Disconnected => Some("Connect"), @@ -164,16 +72,6 @@ impl RemoteStatus { } } - /// Whether keystrokes reach the panes. The read-only degrade: a - /// window that is not attached still scrolls, selects, copies and searches, - /// but typing goes nowhere and is **not** buffered (D6). - /// - /// The rule lives here rather than in the pane's key handler because the - /// decision is about the *workspace's* connection, not about a pane — and - /// because a rule stated once cannot disagree with itself across the five - /// places a keystroke can enter a pane (`on_key_down`, the IME's - /// `commit_text`, `paste`, `send_to_pty`, and the typeahead `dump_hold` - /// timer). [`workspace_accepts_input`] is the form those call sites use. #[allow( dead_code, reason = "reached through `workspace_accepts_input`, whose callers are in terminal/view.rs" @@ -183,92 +81,38 @@ impl RemoteStatus { } } -// --------------------------------------------------------------------------- -// Reconnect backoff (指数退避 1/2/4/…/30s 封顶,无限重试) -// --------------------------------------------------------------------------- - -/// The first wait after a link drops. pub const RECONNECT_FIRST: std::time::Duration = std::time::Duration::from_secs(1); -/// The ceiling the doubling stops at, fixed at 30s: long enough -/// that a machine that has been down for an hour is not being probed every -/// second, short enough that plugging the cable back in feels immediate. pub const RECONNECT_CAP: std::time::Duration = std::time::Duration::from_secs(30); -/// The retry schedule: 1, 2, 4, 8, 16, 30, 30, … and **never gives -/// up**. -/// -/// Giving up is the one thing this must not do. The window stays open in a -/// read-only state either way, so a supervisor that stopped retrying would -/// leave the user looking at a dead window whose only cure is a button they -/// have no reason to think exists. Retrying at 30s costs one connect attempt -/// every half minute, which is nothing. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct Backoff { attempt: u32, } impl Backoff { - /// How many attempts have already failed. pub fn attempt(&self) -> u32 { self.attempt } - /// How long to wait before the next attempt, without consuming it. pub fn delay(&self) -> std::time::Duration { - // `checked_shl` rather than `1 << n`: past 63 the shift is undefined - // behaviour in C and a panic in debug Rust, and an infinite retry loop - // reaches any exponent you care to name if it is left running long - // enough. Saturating into the cap is the answer at every exponent. let secs = 1u64.checked_shl(self.attempt).unwrap_or(u64::MAX); RECONNECT_FIRST .saturating_mul(u32::try_from(secs).unwrap_or(u32::MAX)) .min(RECONNECT_CAP) } - /// Take the next delay and count the attempt. pub fn advance(&mut self) -> std::time::Duration { let delay = self.delay(); self.attempt = self.attempt.saturating_add(1); delay } - /// Back to the start, after a connection succeeds. pub fn reset(&mut self) { self.attempt = 0; } } -// --------------------------------------------------------------------------- -// The start-up auth queue (D7) -// --------------------------------------------------------------------------- - -/// One auth sheet at a time per window; the rest queue. -/// -/// **It queues sheets, not connections.** D7 wants machines that need no -/// interaction (a key, `ssh-agent`, a connection already authenticated) to -/// connect *in parallel*, so nothing here is consulted until a connect actually -/// asks a human something. A design that classified hosts up front would have to -/// guess, and would guess differently on two machines with the same config — -/// the exact failure D7 rejects. -/// -/// The contract for whoever routes prompts (the daemon's `PromptBroker` relay): -/// -/// | Step | Call | -/// |---|---| -/// | A connect needs a password / passphrase / host-key answer | [`AuthSheetQueue::request`] | -/// | It answered `true` | Show the sheet | -/// | It answered `false` | Park the request; the queue will name this host from `release` | -/// | The sheet is answered, cancelled or its connect died | [`AuthSheetQueue::release`] | -/// | The connect gave up before its turn came | [`AuthSheetQueue::withdraw`] | -/// -/// Keyed by [`HostId`] rather than by window: the credential is the machine's, -/// two windows on one box share a connection, and asking twice for one password -/// is the behaviour this exists to prevent. #[derive(Default)] -// The consumers are the daemon's prompt relay and the sheet it raises, both of -// which land with the routed auth path — this is the queue they call into, and -// the contract table above is what they implement against. Dropped here rather -// than left unused elsewhere so the rule has exactly one home. #[allow( dead_code, reason = "the prompt relay that calls this is the other half of D7's start-up connect" @@ -283,11 +127,6 @@ pub struct AuthSheetQueue { reason = "the prompt relay that calls this is the other half of D7's start-up connect" )] impl AuthSheetQueue { - /// Ask to raise a sheet for `who`. `true` means show it now. - /// - /// Re-asking while already holding it answers `true` again: a connect that - /// asks twice (a key passphrase, then the host key) must not deadlock behind - /// itself. pub fn request(&mut self, who: HostId) -> bool { match self.holder { Some(current) if current == who => true, @@ -305,11 +144,6 @@ impl AuthSheetQueue { } } - /// The sheet for `who` is done with. Answers whoever may go next. - /// - /// A release from a host that is *not* the holder is ignored rather than - /// treated as an error — a connect that failed on its own can call this - /// without first checking whether it ever got the sheet. pub fn release(&mut self, who: HostId) -> Option<HostId> { if self.holder != Some(who) { self.waiting.retain(|w| *w != who); @@ -319,61 +153,33 @@ impl AuthSheetQueue { self.holder } - /// Give up a place in the queue without ever having held it. pub fn withdraw(&mut self, who: HostId) { self.waiting.retain(|w| *w != who); } - /// Who may show a sheet right now. pub fn holder(&self) -> Option<HostId> { self.holder } - /// How many are queued behind the current sheet. pub fn waiting(&self) -> usize { self.waiting.len() } } impl Tty7App { - // ----- the invariant --------------------------------------------------- - - /// The machine this window's panes must be on. - /// - /// Derived from the workspace rather than stored, so it cannot drift: a - /// window's host *is* its workspace's host, and rebinding the window to - /// another workspace changes it by construction. pub(crate) fn spawn_host(&self, cx: &gpui::App) -> HostId { WorkspaceStore::host_of(cx, self.workspace) } - /// Whether this window may open a shell on *this* machine. - /// - /// The guard on that "never do this". A remote window that spawned a - /// local shell would put two machines in one window — and would do it - /// invisibly, because a local shell in a remote window looks exactly like a - /// remote one until the first `ls`. pub(crate) fn can_spawn_locally(&self, cx: &gpui::App) -> bool { self.spawn_host(cx).is_local() } - /// Refuse a spawn this window cannot route, saying why. Returns `true` when - /// the caller may go ahead. - /// - /// **A remote window is no longer refused.** Its panes take - /// [`Tty7App::window_workspace`]'s route to the machine the window is bound - /// to, so "+" there opens a shell *over there*, which is the whole feature. - /// What is still refused is a remote window whose machine has no address on - /// file — a deleted profile, an alias gone from `~/.ssh/config`. Falling - /// back to a local shell for those would put two machines in one window and - /// do it invisibly, because a local shell in a remote window looks exactly - /// like a remote one until the first `ls`. pub(crate) fn guard_local_spawn(&self, window: &mut Window, cx: &mut Context<Self>) -> bool { if self.can_spawn_locally(cx) { return true; } match self.window_workspace(cx) { - // Routable: this is a remote window doing exactly what it is for. Some(ws) if ws.route_header().is_ok() => true, _ => { let machine = self.remote_machine_label(cx); @@ -390,11 +196,6 @@ impl Tty7App { } } - /// What this window's panes carry so their connections reach its machine. - /// - /// `None` for a local window, which is the whole of how a local pane stays - /// byte-for-byte what it always was: `PaneRoute::for_workspace(None)` is - /// `Local`, and `Local` writes no header. pub(crate) fn window_workspace( &self, cx: &gpui::App, @@ -402,8 +203,6 @@ impl Tty7App { pane_workspace_for(cx, self.workspace) } - /// The user-facing name of the machine this window is bound to, or - /// `"this computer"` for a local window. pub(crate) fn remote_machine_label(&self, cx: &gpui::App) -> String { match WorkspaceStore::remote_ref(cx, self.workspace) { Some(host) => host.target.to_string(), @@ -411,28 +210,12 @@ impl Tty7App { } } - /// React to this window changing which machine it shows. - /// - /// The closed-tab stack is the one piece of per-*window* state that outlives - /// a workspace swap, so it is the one thing that could carry a tab across - /// machines: reopen it after switching from a local workspace to a remote - /// one and a local shell lands in a remote window. Dropping the stack on a - /// host change is a small loss (⌘⇧T stops working once, right after a - /// deliberate switch) against the invariant it protects. pub(crate) fn rebind_host(&mut self, previous: HostId, cx: &gpui::App) { if crate::core::session::crosses_machines(previous, self.spawn_host(cx)) { self.closed.clear(); } } - /// The short name of the shell a plain new tab on this window's machine - /// lands on — the menu's `default` tag, and the details panel's "shell" row - /// for a pane that never named one. - /// - /// A local window reads the live `Config` global rather than what the probe - /// recorded, so changing `shell` in Settings retags the menu at once. A - /// remote window's default is a fact about the far machine — its own - /// `config.json`, its own `$SHELL` — and only it can report it. pub(crate) fn default_shell_label(&self, cx: &gpui::App) -> String { if self.shells_host.is_local() { crate::core::shells::default_shell_name( @@ -446,19 +229,6 @@ impl Tty7App { } } - /// Refill the "+" dropdown from the machine this window is bound to. - /// - /// The fourth row of the module header's table, in effect: a window that - /// listed *this* computer's shells would hand a remote spawn `/bin/zsh` on a - /// box whose zsh is `/usr/bin/zsh`, and the pane would come up as a spawn - /// failure rather than a shell. So the list is a property of the window's - /// machine, refetched whenever that machine changes or comes back. - /// - /// A machine that isn't reachable yet empties the list rather than keeping - /// the last one: the window is either still connecting (the connect calls - /// back here) or offline, and in both cases a stale menu would be offering - /// picks that cannot be spawned. The dropdown falls back to its plain "New - /// Tab" entry, which the far end resolves with its own default shell. pub(crate) fn refresh_shells(&mut self, cx: &mut Context<Self>) { let host_id = self.spawn_host(cx); self.shells_host = host_id; @@ -467,16 +237,11 @@ impl Tty7App { cx.notify(); return; }; - // Off the UI thread: `/etc/shells` plus a `PATH` walk locally, a round - // trip (and a `wsl.exe` spawn on a Windows peer) remotely. crate::ui::host_ops::HostOps::run( host, cx, |h| h.shells(), move |app, out, cx| { - // The window may have moved to another machine while this was in - // the air; a late answer for the machine it left is not an answer - // about the one it is on. if app.shells_host != host_id { return; } @@ -487,18 +252,11 @@ impl Tty7App { Default::default() } }; - // Nothing else redraws an idle window, and the dropdown's - // closure captures the list at build time. cx.notify(); }, ); } - /// Every pane in this window, in tab order. - /// - /// The reconnect walks these: a workspace's panes are exactly the panes of - /// the one window showing it (one window, one workspace, one - /// machine), so there is no second place to look. pub(crate) fn panes(&self) -> Vec<gpui::Entity<crate::terminal::view::TerminalView>> { self.tabs .iter() @@ -506,13 +264,6 @@ impl Tty7App { .collect() } - /// This window's connection state. - /// - /// Two sources, in the order that matters. The *picker* flow wins while it - /// is running — a window mid-connect is showing that connect, not the - /// machine's background link — and everything else comes from the - /// supervisor ([`RemoteLinks`]), which is where `Reconnecting` and - /// `Preempted` live because they are events, not derivable facts. pub(crate) fn remote_status(&self, cx: &gpui::App) -> Option<RemoteStatus> { WorkspaceStore::remote_ref(cx, self.workspace)?; match &self.connect { @@ -525,10 +276,8 @@ impl Tty7App { RemoteLinks::status_of(cx, self.workspace) } - /// The status strip's button ([重试] / [抢回]). pub(crate) fn remote_retry(&mut self, cx: &mut Context<Self>) { match &self.connect { - // Mid-picker: the retry the user can see is the picker's own. Some(ConnectFlow::Failed { choice, .. }) => { let choice = choice.clone(); self.connect_to_host(choice, cx); @@ -538,14 +287,6 @@ impl Tty7App { cx.notify(); } - // ----- the flow --------------------------------------------------------- - - /// Reach `choice` and, on success, show what workspaces it has. - /// - /// The blocking half runs on the background executor — it opens sockets, - /// authenticates and waits on a remote — and the `Host` layer actively - /// refuses to be called from the UI thread, which is the check that keeps - /// this honest. pub(crate) fn connect_to_host(&mut self, choice: HostChoice, cx: &mut Context<Self>) { remote_connect::register(cx); let header = match remote_connect::control_route(&choice.target, cx) { @@ -558,9 +299,6 @@ impl Tty7App { }; let target = choice.target.clone(); let label = choice.label.clone(); - // Connecting is the answer to having disconnected, so it clears it — - // otherwise the supervisor would tear this connection back down on its - // next tick, and the user would have pressed Connect to no effect. cx.default_global::<RemoteLinks>() .suspended .remove(&choice.target.host_id()); @@ -569,8 +307,6 @@ impl Tty7App { }); cx.notify(); - // A retry after a failed install must not paint the previous attempt's - // bar for the instant before the first new report lands. remote_connect::clear_install_progress(choice.target.host_id()); self.watch_for_install_consent(choice.target.host_id(), cx); cx.spawn(async move |this, cx| { @@ -583,21 +319,6 @@ impl Tty7App { .detach(); } - /// Watch for an install that is blocked on the user's consent while a - /// connect is in flight. - /// - /// A separate loop rather than a step in the connect task because the - /// installer *blocks* on the answer: the request appears while the connect - /// future is still pending, so anything that only looked after it finished - /// would deadlock until the consent timeout. The loop ends when the flow - /// leaves `Connecting`, so it costs nothing when nothing is connecting. - /// - /// It is also what paints the install's progress bar. The bytes arrive far - /// faster than a screen refresh — hundreds of reports over one install — so - /// they land in a slot ([`remote_connect::GuiInstallProgress`]) and this - /// loop samples it, repainting only when the number it reads has actually - /// changed. A connect that installs nothing therefore costs no repaints at - /// all. fn watch_for_install_consent(&self, host: HostId, cx: &mut Context<Self>) { cx.spawn(async move |this, cx| { let mut painted: Option<crate::daemon::install::InstallPhase> = None; @@ -620,10 +341,6 @@ impl Tty7App { painted = reported; let _ = this.update(cx, |_, cx| cx.notify()); } - // The other question a routed connect can raise: a password, a - // key passphrase, a host key. Same mailbox shape, same reason it - // needs one (the daemon holds the connection, this process holds - // the user), and the same 100 ms poll. cx.update(pump_auth_sheets); cx.background_executor() .timer(std::time::Duration::from_millis(100)) @@ -633,27 +350,19 @@ impl Tty7App { .detach(); } - /// Land a finished connect attempt in the flow's state. fn finish_connect( &mut self, result: Result<remote_connect::Connected, String>, cx: &mut Context<Self>, ) { let Some(choice) = self.connect.as_ref().and_then(ConnectFlow::choice).cloned() else { - // The user left the flow while it was in the air. The connection - // drops with the result; nothing to show. return; }; - // Either way the install is over: on success there is nothing left to - // report, and on failure the error needs the space the bar was in. remote_connect::clear_install_progress(choice.target.host_id()); match result { Ok(connected) => { let home = connected.home.clone(); let rows = connected.rows.clone(); - // The switcher lists every machine at once, so a handshake's - // answer has to outlive the flow that fetched it: the flow holds - // one machine and forgets it on the next connect. self.host_snapshots.insert( choice.target.host_id(), crate::ui::switcher::HostSnapshot { @@ -663,14 +372,9 @@ impl Tty7App { ); remote_connect::HostLinks::insert(cx, connected.host, home.clone()); self.prompt_remote_daemon_mismatch_later(cx); - // Nothing left to *show* about the attempt: the machine is now - // in `HostLinks` and its group in the switcher fills - // itself from there and from the snapshot above. self.connect = None; } Err(error) => { - // This is a resting state. The window keeps everything it - // had, says what went wrong, and offers the next move. log::warn!("connect to {} failed: {error}", choice.label); self.connect = Some(ConnectFlow::Failed { choice, error }); } @@ -678,7 +382,6 @@ impl Tty7App { cx.notify(); } - /// Open a workspace that already exists on the connected machine. pub(crate) fn open_remote_workspace( &mut self, target: RemoteTarget, @@ -688,17 +391,9 @@ impl Tty7App { ) { let host = RemoteRef::new(target, row.id); let id = WorkspaceStore::claim_remote(cx, host); - // No record to apply: the machine's tree is pulled when the window - // hydrates, which the enter below sets in motion. self.enter_remote_workspace(id, window, cx); } - /// Make a workspace on the connected machine, rooted at *its* `$HOME`. - /// - /// A new workspace lands in `~` — the remote's, taken from the - /// control handshake (`ControlHelloOk.home`), never this client's. A window - /// on a Mac opening a workspace on a Linux box must land in - /// `/home/<them>`, not `/Users/<me>`. pub(crate) fn create_remote_workspace( &mut self, target: RemoteTarget, @@ -708,12 +403,6 @@ impl Tty7App { ) { let host = RemoteRef::new(target.clone(), WorkspaceId::new()); let id = WorkspaceStore::claim_remote(cx, host); - // The name is left unset on purpose: `Workspace::display_name` derives - // it from the tabs' repo/cwd, which is the same rule a local workspace - // follows and the one intended. A workspace that opened in - // `~` and then had a repo opened in it renames itself for free. - // The machine learns about the workspace when the window's hydration - // finds nothing under this id and creates it (`WorkspaceCreate`). log::info!( "new remote workspace on {target} rooted at {}", home.display() @@ -721,12 +410,6 @@ impl Tty7App { self.enter_remote_workspace(id, window, cx); } - /// Bind this window to a remote workspace. - /// - /// An empty window swaps in place — the connect flow only runs on the home - /// page, so opening a second window would strand this blank one — and a - /// window with tabs opens a new one, which is also what keeps the machines - /// apart: the tabs already here stay with the workspace they belong to. fn enter_remote_workspace( &mut self, id: WorkspaceId, @@ -734,9 +417,6 @@ impl Tty7App { cx: &mut Context<Self>, ) { self.connect = None; - // From here on the machine is the supervisor's business: it is what - // notices the link dropping, retries on the backoff, and puts - // the window read-only in between. RemoteLinks::ensure_running(cx); if self.tabs.is_empty() { let previous = self.spawn_host(cx); @@ -748,35 +428,10 @@ impl Tty7App { cx.notify(); } - /// `open: true` remote workspaces reconnect at launch. - /// - /// **M6 owns the behaviour**; this owns the seam. Startup opens a window per - /// `open: true` workspace regardless of which machine it is on (`main.rs` - /// never learned about hosts, which is exactly what we want — nothing on the - /// launch path is hard-wired to local), and a remote one arrives here with - /// its window already built and its layout the last one this client pulled. - /// What M6 adds is the connect itself plus the auth queue that keeps ten - /// windows from raising ten password sheets at once. - /// - /// Nothing but a call to [`RemoteLinks::supervise`], and local workspaces - /// pass straight through it — the launch path stays ignorant of hosts. pub(crate) fn reopen_remote_at_startup(&self, cx: &mut Context<Self>) { RemoteLinks::supervise(cx, self.workspace); } - // ----- prompts ----------------------------------------------------------- - - /// Install consent, relayed to the machine that raised it. - /// - /// A native prompt rather than an in-window sheet, matching every other - /// decision in this class in tty7 ("Restart Daemon?", "Quit and Stop - /// Daemon?"): it is modal by nature — a thread is parked on the answer — and - /// the reason it exists is that it must not be dismissible by accident. - /// - /// **The default is to decline**, in three separate ways: the button order - /// puts Cancel first, a dismissed prompt reads as Cancel, and an unanswered - /// request times out into a decline back in `remote_connect`. Not being able - /// to use a remote host because nobody said yes is the intended outcome. pub(crate) fn prompt_install_consent( &mut self, pending: remote_connect::PendingInstall, @@ -793,8 +448,6 @@ impl Tty7App { cx, ); cx.spawn(async move |_, _| { - // Index 1 is Install. Cancel, Escape and a prompt the user never - // answered all mean the same thing. let decision = match answer.await { Ok(1) => InstallDecision::Approve, _ => InstallDecision::Decline, @@ -804,13 +457,6 @@ impl Tty7App { .detach(); } - /// Dialect skew, for a remote server. - /// - /// A mismatch is only recorded when the running daemon cannot speak to this - /// client at all, so "leave it alone" is the same answer as "do not connect" - /// — the buttons say that rather than offering a Keep that would fail in the - /// handshake a moment later. `take` semantics on the producer side mean this - /// fires once per discovery rather than once per window. pub(crate) fn prompt_remote_daemon_mismatch(window: &mut Window, cx: &mut Context<Self>) { for mismatch in crate::daemon::install::take_mismatched_remote_daemons() { let title = remote_connect::mismatch_title(&mismatch); @@ -819,13 +465,10 @@ impl Tty7App { PromptLevel::Warning, &title, Some(&detail), - // Named once, beside the detail that explains them. &remote_connect::MISMATCH_ANSWERS, cx, ); cx.spawn(async move |this, cx| { - // Index 1 is Restart Server. Dismissing the prompt is Cancel, - // which is the answer that destroys nothing. if !matches!(answer.await, Ok(1)) { return; } @@ -837,10 +480,6 @@ impl Tty7App { } } - /// [`Self::restart_remote_server`] for the machine a mismatch names: the - /// prompt knows the daemon by the record that reported it, and the record - /// has to be turned back into something addressable before anything can be - /// asked of it. fn restart_mismatched_remote_server( &mut self, mismatch: crate::daemon::install::MismatchedRemoteDaemon, @@ -856,14 +495,6 @@ impl Tty7App { } } - /// "Restart Server" for a machine with nothing wrong with it — the - /// switcher's machine menu, and where a remote window's "Restart Daemon…" - /// lands. - /// - /// Same outcome and same warning as the two repair paths above; the only - /// difference is that nothing is broken, so the wording claims nothing is. - /// Confirmed for the reason all three are: every session on that machine - /// ends, including the ones other windows are showing. pub(crate) fn confirm_restart_remote_server( &mut self, target: RemoteTarget, @@ -883,7 +514,6 @@ impl Tty7App { cx, ); cx.spawn(async move |this, cx| { - // Index 1 is Restart Server; a dismissed prompt is Cancel. if !matches!(answer.await, Ok(1)) { return; } @@ -894,16 +524,6 @@ impl Tty7App { .detach(); } - /// Carry out "Restart Server": stop the `tty7-server` on a machine and start - /// this client's build in its place. The half every entry point shares, past - /// whichever prompt asked. - /// - /// **This throws work away and says so.** Every pane the old server hosts - /// dies with it — that is what the prompt the user just answered warns - /// about, and it is why nothing on the connect path does this on its own. - /// The supervisor reconnects the machine, finds a server whose instance id - /// differs from the one it was talking to, and rebuilds each window from its - /// layout: same tabs and splits, new shells, nothing running in them. fn restart_remote_server( &mut self, target: RemoteTarget, @@ -919,14 +539,8 @@ impl Tty7App { } }; let host = header.target.origin_key(); - // From the target, not from the connection key: that is how the switcher - // derives the id it looks the phase up under, and a bar keyed to a - // different id than the panel reads is a bar nobody ever sees. let host_id = target.host_id(); log::info!("restarting tty7's server on {label} at the user's request"); - // The same watcher the connect flow uses: a restart re-opens the - // machine's connection, so it can raise a password sheet on the way in, - // and it reports a phase the panel has to be told to look at. let running = Arc::new(std::sync::atomic::AtomicBool::new(true)); self.watch_for_restart_consent(host_id, running.clone(), cx); cx.spawn(async move |this, cx| { @@ -951,30 +565,6 @@ impl Tty7App { .detach(); } - /// "Restart Server", from the error card: put a `tty7-server` this client - /// can talk to on the machine — writing one first only if the binary at our - /// dialect's path cannot — and restart the daemon onto it. - /// - /// **The same button, the same words, and the same outcome as the mismatch - /// prompt's.** Both make that machine's running server one we can speak to - /// and end everything on it; they differ only in what had to happen for the - /// machine to get into each state, which is not the user's problem. Naming - /// the two apart ("Replace" here, "Restart" there) asked them to tell - /// identical outcomes apart by a distinction that only exists inside - /// [`Installer::replace`]. The internal names stay split because the actions - /// really are a superset and a subset. - /// - /// **Confirmed first, because it destroys work.** The connect that failed - /// proves nothing about the *other* panes on that machine — an older daemon - /// can be serving them perfectly well over its own dialect — and they all go - /// with it. The failure the button sits under is a good reason to offer - /// this, never a reason to do it unasked. - /// - /// Reached only from a connect error that [`is_dialect_refusal`] recognises, - /// which is the one failure this can fix. - /// - /// [`is_dialect_refusal`]: crate::daemon::control::is_dialect_refusal - /// [`Installer::replace`]: crate::daemon::install::Installer::replace pub(crate) fn confirm_replace_remote_server( &mut self, target: RemoteTarget, @@ -997,7 +587,6 @@ impl Tty7App { cx, ); cx.spawn(async move |this, cx| { - // Index 1 is Restart Server; a dismissed prompt is Cancel. if !matches!(answer.await, Ok(1)) { return; } @@ -1008,8 +597,6 @@ impl Tty7App { .detach(); } - /// The half of [`Self::confirm_replace_remote_server`] that runs after the - /// user has said yes. fn replace_remote_server( &mut self, target: RemoteTarget, @@ -1019,11 +606,6 @@ impl Tty7App { ) { let route = match remote_connect::control_route(&target, cx) { Ok(header) => header.replace_server(), - // Said out loud, for the reason every other failure on this path is: - // the user answered a prompt that promised the machine's server - // would be replaced, and a log line is not an answer to that. The - // failure this catches — no route to the machine any more — is one - // where nothing was touched, which the wording already allows for. Err(e) => { log::warn!("could not address {label} to replace its server: {e}"); Tty7App::report_restart_failure(&label, &e, window, cx); @@ -1033,9 +615,6 @@ impl Tty7App { let host = route.target.origin_key(); let host_id = target.host_id(); log::info!("replacing tty7's server on {label} at the user's request"); - // The same watcher the restart path uses: replacing re-opens the - // machine's connection, so it can raise a password sheet on the way in, - // and it writes, so it can raise the install-consent sheet too. let running = Arc::new(std::sync::atomic::AtomicBool::new(true)); self.watch_for_restart_consent(host_id, running.clone(), cx); cx.spawn(async move |this, cx| { @@ -1045,8 +624,6 @@ impl Tty7App { .spawn(async move { remote_connect::restart_server_blocking(route, &for_task) }) .await; running.store(false, std::sync::atomic::Ordering::Relaxed); - // Either way the bar is over. On failure the error card takes the - // space back, which it cannot do while a phase is still recorded. remote_connect::clear_install_progress(host_id); let _ = this.update_in(cx, |_, window, cx| match outcome { Ok(()) => { @@ -1062,17 +639,6 @@ impl Tty7App { .detach(); } - /// Tell the user a restart did not happen, rather than leaving the old - /// server running behind a prompt that closed as if it had. - /// - /// The wording deliberately does **not** promise the sessions survived. Most - /// failures here happen before anything is touched (no route, a local daemon - /// too old, an unreachable machine), but one does not: the remote's old - /// daemon is stopped before the new one is launched, so a failure to launch - /// leaves a machine with no server and no sessions. Claiming "nothing was - /// disturbed" would be a lie exactly when it matters most, and the recovery - /// is the same either way — the supervisor reconnects, and reaching a - /// machine with no server running starts one. fn report_restart_failure( label: &str, error: &str, @@ -1095,13 +661,6 @@ impl Tty7App { .detach(); } - /// Pump the routed prompt mailboxes while a restart is in flight. - /// - /// Same shape and same reason as [`Self::watch_for_install_consent`]: the - /// restart blocks on the daemon, which may have to re-authenticate the - /// machine first, and a question raised while that future is pending has - /// nobody looking at its mailbox otherwise. The loop ends with the restart, - /// so it costs nothing when nothing is restarting. fn watch_for_restart_consent( &self, host: HostId, @@ -1111,12 +670,6 @@ impl Tty7App { cx.spawn(async move |this, cx| { let mut painted: Option<crate::daemon::install::InstallPhase> = None; while running.load(std::sync::atomic::Ordering::Relaxed) { - // The same repaint `watch_for_install_consent` does, and needed - // for the same reason: the sink is written from the routed - // connection's reader thread and nothing else would ask the - // panel to look at it. Without this a restart that transfers - // nothing — the common "Restart Server" — leaves the click with - // no visible effect for the length of two timeouts. let reported = remote_connect::install_progress_for(host); if reported != painted { painted = reported; @@ -1131,8 +684,6 @@ impl Tty7App { .detach(); } - /// Raise the mismatch prompt on the next turn of the loop, from a context - /// that has no `&mut Window` in hand. fn prompt_remote_daemon_mismatch_later(&self, cx: &mut Context<Self>) { cx.spawn(async move |this, cx| { let _ = this.update_in(cx, |_, window, cx| { @@ -1143,26 +694,11 @@ impl Tty7App { } } -/// What a pane of `workspace` carries so its connection reaches the right -/// machine — the one function that turns a stored [`RemoteRef`] into the routing -/// input every pane-addressed call needs. -/// -/// **`None` means local**, and every consumer treats it as "the path that -/// shipped": no route header, no extra frame, no behaviour to regress. -/// -/// A remote workspace whose SSH details cannot be resolved still answers -/// `Some`, with no spec. That is deliberate: it becomes -/// [`PaneRoute::Unroutable`](crate::terminal::PaneRoute::Unroutable), which -/// *fails* rather than falling back to the local daemon. The fallback is the -/// dangerous answer — a `Kill { pane_id }` sent to the wrong daemon does not -/// error, it succeeds against a stranger's pane. pub(crate) fn pane_workspace_for( cx: &gpui::App, workspace: WorkspaceId, ) -> Option<crate::terminal::PaneWorkspace> { let host = WorkspaceStore::remote_ref(cx, workspace)?; - // Secret-free: the daemon matches the spec against a connection it has - // already authenticated, so no credential needs to ride to it (design D3). let spec = remote_connect::spec_for(&host.target, cx) .ok() .map(|spec| Box::new(spec.without_secrets())); @@ -1171,52 +707,22 @@ pub(crate) fn pane_workspace_for( target: host.target, spec, }; - // The pane path's half of the routed-prompt attribution. A pane opens its - // connection from `terminal::`, which cannot reach `ui::` and so cannot say - // which machine a relayed password sheet belongs to; the header it will - // write can, and this is where that header and this client's `HostId` are - // both in hand. Taken from `route_header` itself rather than rebuilt, so the - // key recorded is the key the pane actually sends. if let Ok(header) = pane.route_header() { remote_connect::note_origin(&header.target, &pane.target); } Some(pane) } -/// Where a pane-addressed request about `workspace` has to go. -/// -/// `Kill`, the restore-time `List` and the reconnect's `Attach` all name a -/// `pane_id`, and pane ids are **per daemon**. Sending one to the wrong daemon -/// is not a failed request — it is a successful request against somebody else's -/// pane. pub(crate) fn pane_route_for(cx: &gpui::App, workspace: WorkspaceId) -> crate::terminal::PaneRoute { crate::terminal::PaneRoute::for_workspace(pane_workspace_for(cx, workspace).as_ref()) } -// --------------------------------------------------------------------------- -// The supervisor (the connection state machine, running) -// --------------------------------------------------------------------------- - -/// How often the pump looks at every machine. -/// -/// Fast enough that a `Preempted` push turns a window read-only while the user -/// is still looking at the machine they typed on, slow enough to be free: a tick -/// is a hash-map walk over the handful of machines a person has open. pub(crate) const PUMP_TICK: Duration = Duration::from_millis(250); -/// One machine's link, as the supervisor sees it. -/// -/// Per **machine**, not per workspace, because that is the granularity a -/// connection actually has (`HostLinks` is keyed by [`HostId`], and two -/// windows on one box share a link). Preemption is the one thing that is -/// per-workspace, and it is kept separately for exactly that reason. struct MachineLink { state: LinkState, backoff: Backoff, - /// When the next attempt is due. `None` while one is in flight or while the - /// link is up. next_attempt: Option<Instant>, - /// An attempt is running; the pump must not start a second one. attempting: bool, } @@ -1228,69 +734,25 @@ enum LinkState { Failed(String), } -/// Every remote machine this client is trying to stay connected to. -/// -/// Stored rather than derived — which is the change M5 flagged. "Is there a live -/// socket?" is derivable; "we lost it 4 seconds ago and will try again in 8" and -/// "somebody took this workspace" are *events*, and there is nowhere to read -/// them back from once they have happened. #[derive(Default)] pub(crate) struct RemoteLinks { machines: std::collections::HashMap<HostId, MachineLink>, - /// Workspaces taken over, and by whom. Per **workspace**: one machine can - /// hold three of them and lose exactly one. preempted: std::collections::HashMap<WorkspaceId, String>, - /// Workspaces being taken *back*: [`RemoteLinks::retry_now`] cleared their - /// preemption and the reconnect is in flight. Remembered because the - /// window still shows the pre-takeover layout, and [`finish_attempt`] - /// must rebuild it from the tree whole (`Adopt::Replace`) — the IfEmpty - /// hydration it runs for an ordinary reconnect skips any non-empty - /// window, which is precisely what a preempted window is. reclaiming: std::collections::HashSet<WorkspaceId>, - /// Machines the user has deliberately disconnected from. - /// - /// Without this the supervisor would reconnect on the next tick: it keeps a - /// link to every machine with an open workspace, and "the user asked us to - /// stop" is not something it can read off the connection. Cleared by every - /// path that asks to be connected again ([`RemoteLinks::retry_now`], the - /// switcher's connect), and by [`pump_tick`] the moment a machine's last - /// window closes — from there a reopened workspace connects like any other. suspended: std::collections::HashSet<HostId>, - /// The `tty7-server` **process** each machine was last served by - /// ([`crate::daemon::control::server_instance`]). - /// - /// Kept per machine and consulted on every reconnect, because it is the only - /// thing that distinguishes the two ways a link comes back: to the same - /// process (every `pane_id` still names the pane it always did) or to a new - /// one (they name nothing, and the window has to be rebuilt). A machine - /// absent from this map has never been seen before, which is **not** the - /// same as having restarted — see [`finish_attempt`]. instances: std::collections::HashMap<HostId, String>, - /// The start-up sheet queue. Lives here because it is part of the - /// same connection state and has to survive individual windows — the sheet - /// belongs to a machine, not to whichever window happened to ask first. #[allow( dead_code, reason = "read by the prompt relay's drain, which lands with the routed auth sheet" )] pub(crate) auth: AuthSheetQueue, - /// Whether the single pump task is running. pumping: bool, } impl gpui::Global for RemoteLinks {} -/// Events the reader threads pushed, waiting for a turn on the UI thread. -/// -/// A plain mutex rather than a channel because the producer is a reader thread -/// that must never block and the consumer is a 250 ms poll — the identical shape -/// `remote_connect`'s install mailbox uses, for the identical reason. static EVENTS: Mutex<Vec<(HostId, ControlEvent)>> = Mutex::new(Vec::new()); -/// Point the process-wide control-event observer at [`EVENTS`]. Idempotent -/// (installing the same closure again is harmless), and shared with the local -/// link's pump ([`crate::ui::local_link::LocalLink::install`]) — whichever -/// comes up first, reader threads must never find nobody listening. pub(crate) fn install_event_observer() { crate::daemon::control::set_event_observer(Arc::new(|host, event| { if let Ok(mut queue) = EVENTS.lock() { @@ -1300,11 +762,6 @@ pub(crate) fn install_event_observer() { } impl RemoteLinks { - /// Start the supervisor, and make sure control events have somewhere to go. - /// - /// Idempotent: called from every path that can produce a remote workspace - /// (the connect flow, opening one, start-up), because any of them can be the - /// first. pub(crate) fn ensure_running(cx: &mut gpui::App) { install_event_observer(); if cx.default_global::<RemoteLinks>().pumping { @@ -1324,25 +781,6 @@ impl RemoteLinks { .detach(); } - /// Put `workspace`'s machine under the supervisor, if it has one. A local - /// workspace is a no-op, which is what lets every "a window took over a - /// workspace" path call this without first asking whether it is remote. - /// - /// **Every such path must.** The supervisor is not a one-shot at start-up: - /// [`pump_tick`] stops it — and clears every [`MachineLink`] with it — as - /// soon as no *open* workspace is on a remote machine, which closing the - /// last remote window does. What that leaves behind is a live connection - /// with no link behind it, because a closed window is a detach and - /// [`remote_connect::HostLinks`] outlives it by design. Reopening the - /// workspace then reads as [`RemoteStatus::Disconnected`] — a "Not - /// connected" strip and a dead keyboard over panes that are visibly still - /// running (#issue: reopened remote workspace stays "not connected"). - /// - /// So an existing `HostLinks` entry is **not** a reason to skip this: it - /// answers "is there a socket", and the state the window renders from is - /// `machines`. `ensure_running` is idempotent, so the machine that really - /// is already supervised costs a flag check, and the first tick over a live - /// socket marks it `Attached` without opening a second SSH session. pub(crate) fn supervise(cx: &mut gpui::App, workspace: WorkspaceId) { let Some(host) = WorkspaceStore::remote_ref(cx, workspace) else { return; @@ -1352,32 +790,14 @@ impl RemoteLinks { "supervising {} for a workspace that just opened", host.target ); - // No per-window connect call: the supervisor already knows which - // machines have open workspaces, so starting it *is* the reconnect, and - // ten windows on one box produce one attempt rather than ten. - // - // Nothing here classifies the host as needing authentication or not - // (D7): every machine is attempted in parallel, and the ones that turn - // out to need a human queue for the sheet at the moment they ask — see - // [`AuthSheetQueue`]. RemoteLinks::ensure_running(cx); } - /// This workspace's state, or `None` when it is a local one. pub(crate) fn status_of(cx: &gpui::App, workspace: WorkspaceId) -> Option<RemoteStatus> { let host = WorkspaceStore::remote_ref(cx, workspace)?; - // No supervisor yet means no link — **not** "everything is fine". The - // difference matters because this answer feeds the input gate, and - // `None` there reads as "local, always accepts". A remote workspace - // whose supervisor has not started is precisely a remote workspace that - // is not connected, and typing into it must go nowhere. let Some(links) = cx.try_global::<RemoteLinks>() else { return Some(RemoteStatus::Disconnected); }; - // Preemption outranks the link's own state: the socket may well be fine - // — the server closed it, or another of its workspaces is still live — - // and "Attached" over a workspace somebody else is typing in would be a - // lie with consequences. if let Some(by) = links.preempted.get(&workspace) { return Some(RemoteStatus::Preempted { by: by.clone() }); } @@ -1394,24 +814,14 @@ impl RemoteLinks { }) } - /// The user asked to reconnect, or to take a preempted workspace back. - /// - /// Both are the same operation. The [抢回] is "接管一次" in the - /// other direction, and a takeover *is* an attach — so clearing the - /// preemption and letting the supervisor attach is not a shortcut, it is the - /// mechanism. pub(crate) fn retry_now(cx: &mut gpui::App, workspace: WorkspaceId) { let Some(host) = WorkspaceStore::remote_ref(cx, workspace) else { return; }; let links = cx.default_global::<RemoteLinks>(); if links.preempted.remove(&workspace).is_some() { - // Taking back, not merely reconnecting: the window's layout is - // the pre-takeover one, so the attach that lands must rebuild it - // from the tree rather than trust what it shows. links.reclaiming.insert(workspace); } - // Asking to reconnect outranks having asked to disconnect. links.suspended.remove(&host.host_id()); let link = links.machines.entry(host.host_id()).or_insert(MachineLink { state: LinkState::Reconnecting, @@ -1419,9 +829,6 @@ impl RemoteLinks { next_attempt: None, attempting: false, }); - // A deliberate act resets the schedule: the user pressing the button is - // information the backoff does not have, and making them wait out a 30 - // second timer they just overrode would be absurd. link.backoff.reset(); link.next_attempt = Some(Instant::now()); if !link.attempting { @@ -1431,23 +838,8 @@ impl RemoteLinks { cx.refresh_windows(); } - /// Stop holding a connection to `host`, because the user said so. - /// - /// **Nothing closes.** The windows on that machine stay exactly where they - /// are and go read-only, which is the same resting state a dropped link - /// leaves behind (a window is never closed automatically) — with the difference - /// that this one is one click from being undone: no `MachineLink` at all - /// reads as [`RemoteStatus::Disconnected`], and that state already draws a - /// "Connect" button on the window's strip. - /// - /// Closing the user's windows for them would be a different, destructive - /// act wearing the same word. If that is what they want, closing a window is - /// already how it is said — and it disconnects too, by way of the machine - /// going unbound. pub(crate) fn disconnect(cx: &mut gpui::App, host: HostId) { cx.default_global::<RemoteLinks>().suspended.insert(host); - // Let go of the streams before the connection: a pane still holding one - // would keep reading from a socket that is about to be dropped under it. for (workspace, _) in workspaces_on(cx, host) { release_panes(cx, workspace); let links = cx.default_global::<RemoteLinks>(); @@ -1475,32 +867,18 @@ impl RemoteLinks { } } -/// One turn of the supervisor. `false` ends the pump. fn pump_tick(cx: &mut gpui::App) -> bool { drain_events(cx); - // D7: start-up and reconnect are the two moments a dozen - // machines can ask for a password at once, and both go through here. pump_auth_sheets(cx); let bound = bound_machines(cx); if bound.is_empty() { - // Nothing left to supervise. The pump restarts the moment a remote - // workspace opens again, so this costs nothing but a stopped timer. - // - // The preemption notes go too: they describe a session that no longer - // exists, and a stale one would have a reopened workspace come back - // read-only against a takeover that happened to a window that is gone. let links = cx.default_global::<RemoteLinks>(); let forgotten = links.machines.len(); links.machines.clear(); links.preempted.clear(); links.reclaiming.clear(); links.suspended.clear(); - // Logged because the *state* it leaves behind is indistinguishable from - // never having connected: `status_of` reads a missing link as - // `Disconnected`, so a window whose workspace is somehow not `open` - // sits under a "Not connected" strip with a live machine behind it. - // This line is how that is told apart from a real disconnect. log::info!("supervisor stopped: no open remote workspace ({forgotten} link(s) dropped)"); return false; } @@ -1511,8 +889,6 @@ fn pump_tick(cx: &mut gpui::App) -> bool { let now = Instant::now(); let mut changed = false; for (host, target) in bound { - // Skipped before the liveness check, not after: the point is that this - // machine gets no attempt at all, not that it gets a quieter one. if suspended.contains(&host) { continue; } @@ -1534,8 +910,6 @@ fn pump_tick(cx: &mut gpui::App) -> bool { if became { changed = true; log::info!("link to {target} is attached"); - // A fresh link means whatever the mirror held is history; the - // full pull re-bases it before deltas resume advancing it. crate::ui::machine_mirror::MachineMirrors::refresh(cx, host); } continue; @@ -1544,9 +918,6 @@ fn pump_tick(cx: &mut gpui::App) -> bool { continue; } - // The link is down. Drop the dead host object so nothing keeps calling - // into it — a control connection that has gone is the whole - // workspace's lifeline, not one failed request. if remote_connect::HostLinks::get(cx, host).is_some() { remote_connect::HostLinks::remove(cx, host); log::info!("lost the control connection to {target}; reconnecting"); @@ -1582,16 +953,6 @@ fn pump_tick(cx: &mut gpui::App) -> bool { true } -/// A deliberate disconnect lasts exactly as long as there is a window to be -/// disconnected *in*. -/// -/// Once a machine's last workspace closes, the state has nothing left to -/// describe — and remembering it would leave a workspace reopened an hour later -/// sitting offline for no reason the user could see, with no failure to point -/// at. Closing the window is itself an end to the connection; this is that, -/// written down. -/// -/// Pure so the rule is a test rather than a comment. fn prune_suspended( suspended: &mut std::collections::HashSet<HostId>, bound: &[(HostId, RemoteTarget)], @@ -1599,12 +960,6 @@ fn prune_suspended( suspended.retain(|host| bound.iter().any(|(id, _)| id == host)); } -/// Every machine this client should be holding a connection to: the distinct -/// hosts of the remote workspaces whose windows are open. -/// -/// Closed workspaces are excluded on purpose. A workspace with `open: false` is -/// one the user shut; reconnecting to it would hold an SSH connection open for a -/// window that is not there. fn bound_machines(cx: &gpui::App) -> Vec<(HostId, RemoteTarget)> { let mut out: Vec<(HostId, RemoteTarget)> = Vec::new(); for workspace in &WorkspaceStore::all(cx).views { @@ -1622,8 +977,6 @@ fn bound_machines(cx: &gpui::App) -> Vec<(HostId, RemoteTarget)> { out } -/// The store keys of every open workspace on `host`, with the client ids they -/// belong to. fn workspaces_on(cx: &gpui::App, host: HostId) -> Vec<(WorkspaceId, String)> { WorkspaceStore::all(cx) .views @@ -1636,7 +989,6 @@ fn workspaces_on(cx: &gpui::App, host: HostId) -> Vec<(WorkspaceId, String)> { .collect() } -/// Apply everything the reader threads pushed since the last tick. pub(crate) fn drain_events(cx: &mut gpui::App) { let events = match EVENTS.lock() { Ok(mut queue) => std::mem::take(&mut *queue), @@ -1644,9 +996,6 @@ pub(crate) fn drain_events(cx: &mut gpui::App) { }; for (host, event) in events { match event { - // The takeover, arriving. The window goes read-only and - // **stays** — no automatic reconnect, because reconnecting is - // taking it back, and taking it back is the user's decision. ControlEvent::Preempted { workspace, by } => { let Some(id) = client_id_for(cx, host, &workspace) else { log::warn!( @@ -1659,24 +1008,12 @@ pub(crate) fn drain_events(cx: &mut gpui::App) { .preempted .insert(id, by.clone()); release_panes(cx, id); - // The window's tree-sync state goes with the streams: its - // mirror and queue describe a session that just lost the - // workspace, and its `informed` licence must not survive into - // the take-back (see `tree_sync::on_preempted`). crate::ui::tree_sync::on_preempted(cx, id); cx.refresh_windows(); } - // Another writer edited a workspace tree this client shows: apply - // the delta to the mirror and the live window (or re-pull the - // workspace when it will not apply cleanly). ControlEvent::Layout { workspace, delta } => { crate::ui::tree_sync::on_layout_delta(cx, host, &workspace, delta); } - // The machine dropped deltas for this connection: every mirror of - // it is now wrong in a way no later delta repairs. Re-pull the - // machine whole and rebuild the windows on it — the recovery an - // unappliable delta already uses, here announced by the server - // instead of stumbled into. ControlEvent::LayoutResync => { log::info!("{host:?} dropped layout deltas for this client; re-pulling"); crate::ui::machine_mirror::MachineMirrors::refresh(cx, host); @@ -1684,7 +1021,6 @@ pub(crate) fn drain_events(cx: &mut gpui::App) { if WorkspaceStore::host_of(cx, workspace) != host { continue; } - // A preempted window stays passive; its take-back re-pulls. if workspace_is_preempted(cx, workspace) { continue; } @@ -1696,24 +1032,6 @@ pub(crate) fn drain_events(cx: &mut gpui::App) { } } -/// Come back to a machine whose server was just replaced. -/// -/// The connection this client held is to a process that no longer exists, so it -/// is dropped rather than waited out: the supervisor's own liveness check would -/// get there within a tick, but a dead host object in the registry is one the -/// file tree and the git status can still call into meanwhile. -/// -/// `retry_now` (rather than letting the backoff schedule it) because a restart -/// the user asked for should come back at once — this is exactly the "the user -/// pressed the button is information the backoff does not have" case it exists -/// for. -/// -/// **The sessions do not come back, but the window does.** Their pane ids named -/// panes in the old process, so nothing re-attaches; the reconnect notices the -/// new [`server_instance`](crate::daemon::control::server_instance) and rebuilds -/// each window from its layout — fresh shells in their saved cwds, agents -/// resumed where an id was captured. That is what "Restart Server ends every -/// session it is hosting" means: the work in them is gone, the window is not. fn reconnect_after_restart(origin: &str, cx: &mut gpui::App) { let Some(host) = remote_connect::origin_host(origin) else { return; @@ -1726,7 +1044,6 @@ fn reconnect_after_restart(origin: &str, cx: &mut gpui::App) { cx.refresh_windows(); } -/// The client's id for the workspace the remote calls `store_key` on `host`. fn client_id_for(cx: &gpui::App, host: HostId, store_key: &str) -> Option<WorkspaceId> { workspaces_on(cx, host) .into_iter() @@ -1734,20 +1051,11 @@ fn client_id_for(cx: &gpui::App, host: HostId, store_key: &str) -> Option<Worksp .map(|(id, _)| id) } -/// One reconnect attempt, off the UI thread. -/// -/// The sequence, in order: rebuild the control connection, pull the -/// workspace layout, re-attach each workspace. The pane half — reopen a channel -/// per pane, `Attach`, replay, then `Resize` to *this* client's geometry — hangs -/// off [`relink_panes`], which is where it lands when remote panes exist. fn launch_attempt(cx: &mut gpui::App, host: HostId, target: RemoteTarget) { let label = target.to_string(); let header = match remote_connect::control_route(&target, cx) { Ok(header) => header, Err(e) => { - // A profile that has been deleted is not a network failure, and - // retrying it for ever would be. This is a resting state with a - // button. RemoteLinks::mark(cx, host, |link| { link.state = LinkState::Failed(e); link.next_attempt = None; @@ -1757,8 +1065,6 @@ fn launch_attempt(cx: &mut gpui::App, host: HostId, target: RemoteTarget) { return; } }; - // Resolved before the task starts: the workspace list is UI-thread state, - // and the background half must not reach back for it. let keys: Vec<String> = workspaces_on(cx, host) .into_iter() .map(|(_, key)| key) @@ -1771,9 +1077,6 @@ fn launch_attempt(cx: &mut gpui::App, host: HostId, target: RemoteTarget) { .background_executor() .spawn(async move { let connected = remote_connect::connect_blocking(&target, header, &label_for_task)?; - // The attach is what makes this client the - // workspace's session again — and what preempts whoever took it - // while we were away. for key in &keys { match connected .host @@ -1786,9 +1089,6 @@ fn launch_attempt(cx: &mut gpui::App, host: HostId, target: RemoteTarget) { log::info!("took workspace {key} back from {who}"); } Ok(_) => {} - // A machine that has no machine tree (an older - // server) still serves files; the workspace is usable, - // it simply cannot be claimed exclusively. Err(e) => log::warn!("could not attach to workspace {key}: {e}"), } } @@ -1800,7 +1100,6 @@ fn launch_attempt(cx: &mut gpui::App, host: HostId, target: RemoteTarget) { .detach(); } -/// Land a finished reconnect attempt. fn finish_attempt( cx: &mut gpui::App, host: HostId, @@ -1810,42 +1109,18 @@ fn finish_attempt( match outcome { Ok(connected) => { let restarted = server_restarted(cx, host, &connected.host); - // The home too, not just the connection: this is the path a machine - // comes back on after a restart or a dropped link, and dropping it - // here is what left "New Workspace" missing on a machine the panel - // was quite happily calling connected. remote_connect::HostLinks::insert(cx, connected.host, connected.home); for (id, _key) in workspaces_on(cx, host) { let reclaimed = { let links = cx.default_global::<RemoteLinks>(); - // The attach that just landed preempts whoever held the - // workspace, so a still-recorded preemption is one this - // reconnect ends — same situation as an explicit Take - // Back, and rebuilt the same way below. links.preempted.remove(&id).is_some() | links.reclaiming.remove(&id) }; if restarted || reclaimed { - // Rebuild from the tree whole. After a server restart - // every pane this window shows lived in a process that is - // gone (a fresh server holds no live panes), so the tree - // lowers each leaf to a revival — fresh shells in the - // recorded cwds, agents resumed. After a take-back the - // panes may well be alive, but the *layout* on screen is - // the pre-takeover one: the IfEmpty hydration below would - // skip this non-empty window and leave it stale — the - // "take back re-pulls whole" the preemption paths promise - // happens here, as a Replace. crate::ui::tree_sync::resync_window_from_tree(cx, id); } else { relink_panes(cx, id); - // A window that came up before its machine did has no panes - // to relink — it opened empty because there was nothing to - // route to. Now there is: fill it from the tree. crate::ui::tree_sync::hydrate_window_from_tree(cx, id); } - // Same reason the window had no panes: with the machine - // unreachable there was nothing to ask for its shells, so the - // "+" dropdown has been sitting on the empty fallback. refresh_window_shells(cx, id); } RemoteLinks::mark(cx, host, |link| { @@ -1861,8 +1136,6 @@ fn finish_attempt( RemoteLinks::mark(cx, host, |link| { link.attempting = false; link.state = LinkState::Reconnecting; - // The next tick schedules the following attempt off the - // advanced backoff; setting it here would double-count. link.next_attempt = None; }); } @@ -1870,32 +1143,9 @@ fn finish_attempt( cx.refresh_windows(); } -/// The pane half of a reconnect: for each pane, reopen a channel, -/// `Attach`, take the replay, then `Resize` to this client's geometry. -/// -/// # The replay boundary -/// -/// `ReplayRing` is 8 MiB over at most 64 segments. A pane disconnected long -/// enough to overrun it comes back with the daemon's current grid snapshot and -/// **the middle is genuinely gone**. That is the same thing a local pane does -/// after a daemon restart, it is not fixable from this side, and nothing here -/// papers over it: no "restoring…" spinner that implies the gap will fill in, no -/// synthesized scrollback, and no attempt to stitch the pre-drop screen onto the -/// replay — [`RemoteTerminal::adopt_relink`](crate::terminal::RemoteTerminal::adopt_relink) -/// resets the mirror precisely so what is on screen afterwards is what the -/// machine actually has. -/// -/// # Why each pane goes through a background task -/// -/// The re-`Attach` is a network round trip on a link that has just been rebuilt, -/// and a `TerminalView` can only be touched on the UI thread. So the blocking -/// half runs off it and only the socket swap comes back — the same split every -/// other connect in this module makes. fn relink_panes(cx: &mut gpui::App, workspace: WorkspaceId) { let route = pane_route_for(cx, workspace); if matches!(route, crate::terminal::PaneRoute::Local) { - // Not a remote workspace: there is no route to redo, and a local pane - // must never be re-attached by this path. return; } let panes = panes_of(cx, workspace); @@ -1927,9 +1177,6 @@ fn relink_panes(cx: &mut gpui::App, workspace: WorkspaceId) { } }); } - // A pane that could not come back stays on screen in its - // disconnected state. The supervisor is still retrying the - // machine, and the next success runs this again. Err(e) => log::warn!("could not relink pane {pane_id}: {e}"), } }) @@ -1937,41 +1184,18 @@ fn relink_panes(cx: &mut gpui::App, workspace: WorkspaceId) { } } -/// Whether the machine we just reconnected to is being served by a *different* -/// `tty7-server` process than the one we last spoke to. -/// -/// `true` is a statement of fact, not a guess, and that is the whole point: it -/// is the difference between a link that blinked (re-attach; the shells are -/// still running over there) and a server that was replaced (rebuild; they are -/// not). Before the instance id existed there was nothing to tell them apart — -/// `build` and both dialect numbers survive a restart unchanged — so the -/// reconnect had to assume the safer of the two and leave dead panes on screen. -/// -/// Two cases answer `false` and mean different things, both deliberately: -/// -/// | | | -/// |---|---| -/// | No previous instance recorded | First time we have reached this machine. Nothing was attached, so nothing was lost | -/// | The peer reported no instance | We cannot tell. Never treat "don't know" as "restarted" — that would throw away live shells on a hunch | fn server_restarted(cx: &mut gpui::App, host: HostId, peer: &RemoteHost) -> bool { let instance = peer.peer().instance.clone(); let seen = &mut cx.default_global::<RemoteLinks>().instances; note_instance(seen, host, &instance) } -/// Record `instance` as what is serving `host` and answer whether it displaced a -/// *different* one. Split out from [`server_restarted`] because the rule matters -/// more than the plumbing around it and is worth a test that doesn't need a -/// connection to run. fn note_instance( seen: &mut std::collections::HashMap<HostId, String>, host: HostId, instance: &str, ) -> bool { if instance.is_empty() { - // Nothing learned, so nothing recorded: writing an empty value would - // make the *next* reconnect compare against it and read a real instance - // as a restart. return false; } match seen.insert(host, instance.to_string()) { @@ -1986,8 +1210,6 @@ fn note_instance( } } -/// Ask the window showing `workspace` to refill its "+" dropdown, now that its -/// machine is answering. No-op for a workspace with no window on screen. fn refresh_window_shells(cx: &mut gpui::App, workspace: WorkspaceId) { let Some(app) = crate::ui::windows::WindowRegistry::app_for(cx, workspace).and_then(|app| app.upgrade()) @@ -1997,42 +1219,13 @@ fn refresh_window_shells(cx: &mut gpui::App, workspace: WorkspaceId) { app.update(cx, |app, cx| app.refresh_shells(cx)); } -/// The takeover, on this client's side: stop holding a stream to a -/// workspace somebody else is now typing in. -/// -/// The remote closes them too — this is not the mechanism, it is the client -/// making sure. The panes stay on screen (read-only, never auto-closed); -/// only the links go. fn release_panes(cx: &mut gpui::App, workspace: WorkspaceId) { for view in panes_of(cx, workspace) { view.update(cx, |view, cx| view.detach_link(cx)); } } -// --------------------------------------------------------------------------- -// The start-up auth queue, wired (D7) -// --------------------------------------------------------------------------- - -/// Move every routed auth prompt one step: drain the mailbox, offer each to the -/// queue, and raise the sheet for whichever machine's turn it is. -/// -/// The rule is "**一次只弹一个 sheet,其余排队**", and the queue is keyed -/// by machine because the credential is the machine's — ten windows restoring -/// onto one box must ask for one password, not ten. -/// -/// A prompt that does not get the sheet is **parked, not dropped**. A dropped -/// [`PendingAuth`](remote_connect::PendingAuth) leaves a connect thread blocked -/// until the 180s consent timeout, which is exactly the "the app hung on launch" -/// this queue exists to prevent. -/// -/// Called from two places, because there are two moments a routed prompt can -/// arrive and neither covers the other: the connect watcher (a picker connect, -/// when no workspace is bound to the machine yet) and the supervisor's tick -/// (start-up and every reconnect, when no picker is open). pub(crate) fn pump_auth_sheets(cx: &mut gpui::App) { - // Yield the mailbox to a test that is waiting for a prompt it caused itself; - // see `remote_connect::MAILBOX_TURN`. Compiled out of a release build, which - // has one tick and one mailbox and nothing to arbitrate. #[cfg(test)] let _turn = remote_connect::claim_mailbox(); @@ -2040,9 +1233,6 @@ pub(crate) fn pump_auth_sheets(cx: &mut gpui::App) { while let Some(pending) = remote_connect::take_pending_auth() { inbox.push(pending); } - // Anything that waited its turn last time goes back in the same pass, so a - // sheet that has since closed hands straight on rather than waiting for the - // next machine to ask something. if let Ok(mut parked) = PARKED.lock() { inbox.append(&mut parked); } @@ -2055,8 +1245,6 @@ pub(crate) fn pump_auth_sheets(cx: &mut gpui::App) { } match raise_auth_sheet(cx, pending) { SheetOutcome::Raised => {} - // Nobody could show it. Give the turn back so the next machine is - // not stuck behind a sheet that never appeared. outcome => { cx.default_global::<RemoteLinks>().auth.release(host); if let SheetOutcome::GiveBack(pending) = outcome { @@ -2067,26 +1255,12 @@ pub(crate) fn pump_auth_sheets(cx: &mut gpui::App) { } } -/// What became of an attempt to show one machine's sheet. -/// -/// An enum rather than a `Result` because "nobody could show it" is not an -/// error — it is the queue working — and because the prompt has to travel back -/// intact either way: dropping a [`PendingAuth`](remote_connect::PendingAuth) -/// leaves a connect thread parked until its 180s timeout. pub(crate) enum SheetOutcome { - /// It is on screen, or was a banner and is already answered. The queue is - /// released when the sheet resolves. Raised, - /// No window on that machine yet, or another sheet is up. Park it and offer - /// it again on the next pump. GiveBack(remote_connect::PendingAuth), - /// It went down with a window that closed mid-flight. The connect times out - /// and cancels — the same answer it would have got with no UI at all. Lost, } -/// Put the sheet in front of a person, on the window of a workspace on that -/// machine. fn raise_auth_sheet(cx: &mut gpui::App, pending: remote_connect::PendingAuth) -> SheetOutcome { let host = pending.host; let Some((workspace, _)) = workspaces_on(cx, host).into_iter().next() else { @@ -2107,20 +1281,10 @@ fn raise_auth_sheet(cx: &mut gpui::App, pending: remote_connect::PendingAuth) -> .unwrap_or(SheetOutcome::Lost) } -/// Hand the sheet on when one machine's question is done with. -/// -/// Called from the sheet itself (`ui::ssh_prompt`), on every way out of it — -/// answered, cancelled or dismissed. Parked prompts are re-offered by the next -/// [`pump_auth_sheets`], so nothing here has to know who goes next. pub(crate) fn release_auth_sheet(host: HostId, cx: &mut gpui::App) { cx.default_global::<RemoteLinks>().auth.release(host); } -/// Prompts waiting for a turn. -/// -/// A process-wide mutex like the mailbox they came from, because a prompt -/// outlives the window that happened to poll for it: a machine can lose its -/// window while its connect thread is still parked on the answer. static PARKED: Mutex<Vec<remote_connect::PendingAuth>> = Mutex::new(Vec::new()); fn park(pending: remote_connect::PendingAuth) { @@ -2129,7 +1293,6 @@ fn park(pending: remote_connect::PendingAuth) { } } -/// Every pane of the window showing `workspace`, or empty when no window does. fn panes_of( cx: &mut gpui::App, workspace: WorkspaceId, @@ -2143,22 +1306,6 @@ fn panes_of( app.read(cx).panes() } -/// Whether keystrokes should reach the panes of `workspace`. -/// -/// **The one call the pane layer makes.** The read-only degrade has to -/// be checked at every point a keystroke can enter a pane — `on_key_down`, the -/// IME's `commit_text`, `paste`, `send_to_pty`, and the typeahead `dump_hold` -/// timer — and a rule copied five times is a rule that will disagree with itself -/// after the first change here. -/// -/// A local workspace always answers `true`: it has no connection to lose, and a -/// gate that could ever say otherwise for a local pane would be a bug that -/// bricks the app. -/// -/// **Nothing is buffered** (D6). This is a gate, and the deliberate absence of a -/// queue behind it is the decision: keystrokes typed while disconnected are -/// dropped, because replaying them into a screen that has moved on is how a -/// stray `y` becomes an answer to a question the user never read. #[allow( dead_code, reason = "the five keystroke entry points that call this live in terminal/view.rs" @@ -2167,9 +1314,6 @@ pub(crate) fn workspace_accepts_input(cx: &gpui::App, workspace: WorkspaceId) -> RemoteLinks::status_of(cx, workspace).is_none_or(|s| s.accepts_input()) } -/// Whether another client's session currently holds `workspace`. Read by the -/// delta application, which must leave a preempted window passive — attaching -/// to the usurper's panes would steal the streams they are typing into. pub(crate) fn workspace_is_preempted(cx: &gpui::App, workspace: WorkspaceId) -> bool { cx.try_global::<RemoteLinks>() .is_some_and(|links| links.preempted.contains_key(&workspace)) @@ -2179,17 +1323,9 @@ pub(crate) fn workspace_is_preempted(cx: &gpui::App, workspace: WorkspaceId) -> mod tests { use super::*; - /// Take Back is `retry_now` on a preempted workspace, and the window it - /// recovers still shows the pre-takeover layout — so clearing the - /// preemption must leave a `reclaiming` mark behind for `finish_attempt` - /// to read, or the landed attach runs its ordinary IfEmpty hydration, - /// skips the non-empty window, and the stale layout survives to roll the - /// other client's edits back on the next save. #[gpui::test] fn taking_back_marks_the_workspace_for_a_whole_rebuild(cx: &mut gpui::TestAppContext) { cx.update(|cx| { - // `retry_now` wakes the supervisor, whose first tick resolves the - // machine's route off the config global. cx.set_global(crate::core::config::Config::default()); let host = RemoteRef::new( RemoteTarget::Alias { @@ -2227,26 +1363,12 @@ mod tests { }); } - /// **Stopping the supervisor is not a terminal state.** It stops whenever no - /// open workspace is remote — closing the last remote window does it — and a - /// workspace reopened afterwards has to start it again, or it sits under a - /// "Not connected" strip with a dead keyboard for ever while its panes run - /// on the far side. - /// - /// What this pins is that [`RemoteLinks::supervise`] is that restart, from a - /// pump that has genuinely stopped. It cannot reproduce the original bug in - /// full — that needed a live `HostLinks` entry, which takes a real control - /// connection to build — so the other half of the rule lives in - /// `supervise`'s own doc: never gate the `ensure_running` call on one. #[gpui::test] fn a_stopped_supervisor_restarts_when_a_remote_workspace_comes_back( cx: &mut gpui::TestAppContext, ) { let id = cx.update(|cx| { cx.set_global(crate::core::config::Config::default()); - // Nothing remote on file yet, so the first tick has no machine to - // supervise and shuts the pump down — the state a closed remote - // window leaves behind. crate::core::session::WorkspaceStore::install_for_test( cx, crate::core::session::WindowViews::default(), @@ -2255,8 +1377,6 @@ mod tests { assert!(cx.default_global::<RemoteLinks>().pumping); WorkspaceId::new() }); - // The tick runs and returns `false` without ever reaching its timer, so - // this needs no clock of its own. cx.background_executor.run_until_parked(); cx.update(|cx| { assert!( @@ -2264,8 +1384,6 @@ mod tests { "with no remote workspace open the pump is expected to stop" ); - // The workspace comes back — reopened from the switcher, or the - // launch path's window landing on it. let host = RemoteRef::new( RemoteTarget::Alias { alias: "build-box".into(), @@ -2293,9 +1411,6 @@ mod tests { }); } - /// A plain reconnect (never preempted) must not be marked for a rebuild — - /// its panes are alive and re-attachable, and a Replace would tear down - /// views the relink was about to reuse. #[gpui::test] fn a_plain_reconnect_is_not_marked_for_a_rebuild(cx: &mut gpui::TestAppContext) { cx.update(|cx| { @@ -2331,9 +1446,6 @@ mod tests { #[test] fn the_status_strip_speaks_unless_everything_is_working() { assert_eq!(RemoteStatus::Attached.strip_message("build-box"), None); - // Each state says its own thing once — an earlier cut phrased the - // no-connection case through `Failed("not connected")` and the strip - // read "Not connected to build-box — not connected". assert_eq!( RemoteStatus::Disconnected .strip_message("build-box") @@ -2354,9 +1466,6 @@ mod tests { ); } - /// The read-only degrade: a window that is connecting or failed - /// still shows and scrolls, but typing goes nowhere — and is not buffered - /// for later (D6), which is why this is a gate and not a queue. #[test] fn input_is_gated_on_being_attached() { assert!(RemoteStatus::Attached.accepts_input()); @@ -2365,10 +1474,6 @@ mod tests { assert!(!RemoteStatus::Failed("x".into()).accepts_input()); } - /// Both remaining states name their machine — which is what lets the - /// switcher give a machine a group of its own while it is still being - /// reached, or after it failed. Picking one out of a list is no longer a - /// state of the flow at all; `ui::switcher` is that list. #[test] fn every_flow_state_names_its_machine() { let choice = HostChoice { @@ -2389,28 +1494,17 @@ mod tests { assert_eq!(flow.choice(), Some(&choice)); } - /// The rule that decides re-attach vs rebuild. Getting any of these four - /// wrong costs the user running work: a false positive rebuilds a window - /// whose shells were fine, a false negative leaves a screen of dead panes. #[test] fn only_a_changed_instance_counts_as_a_restart() { let mut seen = std::collections::HashMap::new(); let host = HostId::from_connection_key("ssh:build-box"); - // First sight of a machine. Nothing was attached to the old process - // because there was no old process *we* knew about. assert!(!note_instance(&mut seen, host, "abc")); - // The link blinked and came back to the same server. assert!(!note_instance(&mut seen, host, "abc")); - // Replaced. assert!(note_instance(&mut seen, host, "def")); - // …and the new one is now the baseline, so it is not a restart twice. assert!(!note_instance(&mut seen, host, "def")); } - /// A peer that reports no instance leaves no trace. Recording the empty - /// string would make the *next* reconnect — one that does report an id — - /// read as a restart and throw away live shells. #[test] fn an_unknown_instance_is_not_a_restart_and_is_not_remembered() { let mut seen = std::collections::HashMap::new(); @@ -2424,8 +1518,6 @@ mod tests { ); } - /// Two machines are tracked apart. They mint instance ids independently, so - /// one restarting must not rebuild the other's windows. #[test] fn instances_are_per_machine() { let mut seen = std::collections::HashMap::new(); @@ -2441,9 +1533,6 @@ mod tests { ); } - // ── The reconnect schedule (no network) ───────────────────────────────── - - /// The schedule is fixed: **1/2/4/…/30s capped, retried for ever**. #[test] fn the_backoff_doubles_to_thirty_seconds_and_stays_there() { let mut b = Backoff::default(); @@ -2452,9 +1541,6 @@ mod tests { assert_eq!(b.attempt(), 8, "every attempt is counted, capped or not"); } - /// A connection that succeeds puts the next failure back at one second — - /// otherwise a link that flaps once an hour ends up on the 30s ceiling for - /// ever and feels broken. #[test] fn a_success_resets_the_schedule() { let mut b = Backoff::default(); @@ -2467,9 +1553,6 @@ mod tests { assert_eq!(b.delay(), RECONNECT_FIRST); } - /// It never gives up and it never panics. A supervisor left running for a - /// week reaches exponents where `1 << n` is undefined behaviour in C and a - /// debug panic in Rust; the cap has to absorb every one of them. #[test] fn the_backoff_survives_absurd_attempt_counts() { let mut b = Backoff::default(); @@ -2479,14 +1562,10 @@ mod tests { assert_eq!(b.delay(), RECONNECT_CAP, "still retrying, still capped"); } - // ── The start-up auth queue (D7) ───────────────────────────── - fn host(key: &str) -> HostId { HostId::from_connection_key(key) } - /// D7: **one sheet at a time, the rest queue.** Ten windows restoring at - /// once must not raise ten password prompts. #[test] fn only_one_machine_may_raise_a_sheet_at_a_time() { let mut q = AuthSheetQueue::default(); @@ -2502,7 +1581,6 @@ mod tests { assert_eq!(q.waiting(), 2); assert_eq!(q.holder(), Some(a)); - // Answering hands the sheet to the next in line, in order. assert_eq!(q.release(a), Some(b)); assert_eq!(q.waiting(), 1); assert_eq!(q.release(b), Some(c)); @@ -2511,8 +1589,6 @@ mod tests { assert_eq!(q.waiting(), 0); } - /// One connect asks twice — a key passphrase, then an unknown host key. The - /// second ask must not queue behind the sheet it is already holding. #[test] fn the_holder_may_ask_again_without_deadlocking() { let mut q = AuthSheetQueue::default(); @@ -2522,9 +1598,6 @@ mod tests { assert_eq!(q.waiting(), 0); } - /// A queued connect that dies on its own (the machine is unreachable) has to - /// leave the queue, or the sheet is handed to a host nobody is waiting on - /// and the next one waits behind a ghost. #[test] fn a_connect_that_gave_up_leaves_the_queue() { let mut q = AuthSheetQueue::default(); @@ -2541,28 +1614,20 @@ mod tests { assert_eq!(q.waiting(), 1); assert_eq!(q.release(a), Some(c), "b is gone, c is next"); - // And a release from someone who never held it changes nothing. assert_eq!(q.release(b), None); assert_eq!(q.holder(), Some(c)); } - /// The queue is keyed by *machine*, not by window: two windows on one box - /// share a connection and a credential, and asking twice for one password is - /// the behaviour this exists to prevent. #[test] fn two_windows_on_one_machine_share_a_place_in_the_queue() { let mut q = AuthSheetQueue::default(); let (a, b) = (host("ssh-alias:build"), host("ssh-alias:other")); assert!(q.request(a)); assert!(!q.request(b)); - // The same machine asking again from its second window is already the - // holder, not a third entry. assert!(q.request(a)); assert_eq!(q.waiting(), 1); } - // ── The input gate ─────────────────────────────────────────────────────── - #[test] fn every_state_says_what_it_means_for_the_keyboard() { let cases = [ @@ -2607,8 +1672,6 @@ mod tests { } } - /// The two states M6 added still produce a strip line, and the takeover one - /// names the machine that took it — 状态条写"已在 <主机名> 上打开". #[test] fn the_new_states_name_what_happened() { assert_eq!( @@ -2634,8 +1697,6 @@ mod tests { ); } - // ── A deliberate disconnect ────────────────────────────────────────────── - fn machine(alias: &str) -> (HostId, RemoteTarget) { let target = RemoteTarget::Alias { alias: alias.to_string(), @@ -2643,25 +1704,15 @@ mod tests { (target.host_id(), target) } - /// A disconnect holds only while the machine still has a window on it. - /// - /// The supervisor reconnects to every machine with an open workspace, so - /// without this set a disconnect would last one tick. With it kept - /// *forever*, the opposite failure: a workspace closed and reopened a day - /// later would come up offline against a decision the user has no memory of - /// and nothing on screen to explain. #[test] fn a_disconnect_ends_when_the_last_window_on_that_machine_closes() { let (build, build_t) = machine("build-box"); let (gpu, gpu_t) = machine("gpu-lab"); let mut suspended = std::collections::HashSet::from([build, gpu]); - // Both still have a window: both decisions still mean something. prune_suspended(&mut suspended, &[(build, build_t.clone()), (gpu, gpu_t)]); assert_eq!(suspended.len(), 2); - // The gpu box's last workspace closed. Its disconnect goes with it; the - // build box's is untouched. prune_suspended(&mut suspended, &[(build, build_t)]); assert_eq!( suspended.into_iter().collect::<Vec<_>>(), @@ -2670,11 +1721,6 @@ mod tests { ); } - /// Disconnecting drops the machine's link state, which *is* how the window - /// says "not connected": `status_of` reads a missing `MachineLink` as - /// `Disconnected`, and that state already draws the Connect button. Asking - /// to reconnect then clears the decision, or the supervisor would undo the - /// reconnect on its next tick. #[gpui::test] fn disconnecting_rests_at_not_connected_and_connect_undoes_it(cx: &mut gpui::TestAppContext) { cx.update(|cx| { @@ -2705,7 +1751,6 @@ mod tests { "a disconnected machine rests where a never-connected one does" ); - // The strip's Connect button. RemoteLinks::retry_now(cx, id); assert!( !cx.default_global::<RemoteLinks>().suspended.contains(&host), diff --git a/src/ui/reorder.rs b/src/ui/reorder.rs index 57df3049..4609a0e3 100644 --- a/src/ui/reorder.rs +++ b/src/ui/reorder.rs @@ -1,71 +1,19 @@ -//! Live drag-to-reorder — the "the list gets out of your way while you drag" -//! behaviour behind the tab strip's chips, the sidebar's rows and the sidebar's -//! group headers. -//! -//! Nothing floats above the window: the item you're dragging stays in the -//! list, dimmed, and travels by the list rearranging around it. gpui's drag -//! system is used only for what it's good at here — knowing a drag is live and -//! redrawing every mouse move — while its floating preview renders nothing. -//! What it doesn't offer at all is any way to know *where* the cursor is -//! mid-drag from inside a drop target: only a `drag_over` style and a final -//! `on_drop`, which is the "one tile swaps with another on release" model this -//! module replaces. Here the surface reads [`Window::mouse_position`] every -//! frame, asks [`Reorder`] where the dragged slot belongs *right now*, and -//! renders the list in that order, so what you see is already the result. -//! -//! The shape of it: -//! -//! | Step | Who | What | -//! |---|---|---| -//! | Measure | every slot, every frame | its own bounds into a per-frame cell | -//! | Freeze | `on_drag` | that cell becomes [`Reorder::rects`] for the whole drag | -//! | Preview | the surface, per frame | [`Reorder::target`] → [`Reorder::order`] | -//! | Track | the held slot | [`Reorder::held_offset`] → follows the cursor | -//! | Slide | each displaced slot | [`Reorder::flip_offset`] → animate to zero | -//! | Record | the surface, per frame | [`set_pending`] — the order a release would give | -//! | Commit | the root, on drag end | [`take_pending`] → `Tty7App::apply_tab_order` | -//! -//! **Geometry is frozen at drag start on purpose.** The preview reflow moves -//! the very slots the hit-testing reads, so measuring live would let the list -//! feed back into its own input and oscillate under a still cursor. Freezing -//! also means a mid-drag scroll isn't tracked — a deliberate trade for a rail -//! whose rows are a few dozen pixels tall. - use gpui::{Axis, Bounds, Pixels, Point, px}; use std::cell::{Cell, RefCell}; use std::path::PathBuf; use std::rc::Rc; -/// The app-wide slot for the one drag that can be live at a time. Shared by -/// `Rc` because the `on_drag` that opens it only gets `&mut App`. pub(crate) type ReorderState = Rc<RefCell<Option<Reorder>>>; -/// Everything a surface needs to draw one frame of a live reorder. pub(crate) struct Preview { - /// Slot indices in the order to render them. pub(crate) order: Vec<usize>, - /// The slot the held item currently occupies — where a release right now - /// would land it. pub(crate) target: usize, - /// The slot being dragged. It stays in the list like any other — the drag - /// is drawn by the list rearranging, not by a card floating over it — and - /// only wears a "picked up" dimming so you can see which one you have. pub(crate) from: usize, - /// Bumped whenever the preview order changes; part of each slot's - /// animation id so a slide restarts rather than resuming. pub(crate) generation: usize, - /// Per slot (indexed by its *frozen* index), the offset to start this - /// frame at so it slides into place. Zero for everything the last change - /// didn't touch, and unused for [`Self::from`] — the held item doesn't - /// slide, it tracks. pub(crate) offsets: Vec<Pixels>, - /// Where to draw the held item relative to the slot it's laid out in, so - /// it follows the cursor continuously instead of hopping slot to slot. pub(crate) held: Pixels, } -/// This frame's preview of `surface`, if that's where the live drag started -/// and its frozen geometry still describes a list of `len` slots. pub(crate) fn preview( state: &ReorderState, surface: &Surface, @@ -88,84 +36,38 @@ pub(crate) fn preview( }) } -/// Record the tab order the current preview implies, so releasing the mouse -/// applies exactly what the user was looking at. -/// -/// The surface computes this every frame while it draws the preview, rather -/// than a drop handler working it out on release, because a drop handler only -/// fires when the pointer is over *that element* at release — release a hair -/// above the rail (easy when dragging a row upward) and the move would be -/// silently lost, the list snapping back. The drag ending is the commit, and -/// where the cursor happens to be at that moment doesn't enter into it. pub(crate) fn set_pending(state: &ReorderState, surface: &Surface, order: Vec<usize>) { if let Some(r) = state.borrow().as_ref().filter(|r| r.surface == *surface) { *r.pending.borrow_mut() = Some(order); } } -/// Forget the order recorded by the previous frame, at the start of every -/// frame a drag is live — the surfaces re-record it as they draw. -/// -/// Without this the recording is a high-water mark rather than a snapshot: drag -/// a row down and back to its own slot and the surface stops recording (the -/// move is a no-op), so a stale "moved" order would survive to be committed by -/// a release the user made after visibly putting the row back. Same for a frame -/// where the drag's frozen geometry no longer matches the list ([`Reorder::covers`] -/// — a tab closed, or a git probe moved one to another group): nothing is drawn -/// and so nothing may commit. pub(crate) fn clear_pending(state: &ReorderState) { if let Some(r) = state.borrow().as_ref() { r.pending.borrow_mut().take(); } } -/// Take the recorded order out of a finished drag — see [`set_pending`]. pub(crate) fn take_pending(state: &ReorderState) -> Option<Vec<usize>> { state.borrow_mut().take()?.pending.into_inner() } -/// Which of the app's reorderable lists a drag belongs to. The sidebar rail -/// holds two at once — the rows inside a group, and the group blocks -/// themselves — so a surface asking "is this drag mine?" needs more than -/// "am I the sidebar". Rows carry their group's repo root (`None` = Scratch) -/// because a row drag must never reflow a sibling group: a tab's group comes -/// from its cwd, not from where it sits in the list. #[derive(Clone, PartialEq, Eq, Debug)] pub(crate) enum Surface { - /// The horizontal title-bar strip. Display order is plain tab order. Strip, - /// The rows of one sidebar group. SidebarRows(Option<PathBuf>), - /// The sidebar's group blocks (header + its rows), dragged by the header. SidebarGroups, } -/// One live drag-reorder. Created when gpui starts a drag, read by the surface -/// on every frame until the drop, then dropped. pub(crate) struct Reorder { - /// The list this drag belongs to; a surface ignores state that isn't its own. pub(crate) surface: Surface, - /// Index of the dragged slot in the frozen order. pub(crate) from: usize, - /// Every slot's bounds in display order, as measured on the last frame - /// before the drag started (see the module docs on why they're frozen). rects: Vec<Bounds<Pixels>>, - /// The axis the list runs along — vertical for the rail, horizontal for the strip. axis: Axis, - /// The list's gap between slots, so a displaced slot's shift matches what - /// the layout will actually do. gap: Pixels, - /// Where inside the dragged slot the pointer grabbed it, so the slot's - /// position is derived from the cursor exactly as gpui's floating preview is. grab: Point<Pixels>, - /// The target the previous frame drew, and a counter bumped whenever it - /// changes. The slide-in animation keys its element id off the counter, so - /// a slot that has just been displaced restarts its slide instead of - /// resuming a finished one. prev: Cell<usize>, generation: Cell<usize>, - /// The tab order releasing right now would produce, refreshed every frame - /// by the surface drawing the preview. See [`set_pending`]. pending: RefCell<Option<Vec<usize>>>, } @@ -191,15 +93,10 @@ impl Reorder { } } - /// True when this state belongs to `surface` and its frozen geometry still - /// describes a list of `len` slots — a tab closing mid-drag, or the git - /// probe moving a tab to another group, invalidates it rather than letting - /// stale indices reorder the wrong thing. pub(crate) fn covers(&self, surface: &Surface, len: usize) -> bool { self.surface == *surface && self.rects.len() == len && self.from < len } - /// The scalar component along the list's axis. fn along(&self, p: Point<Pixels>) -> Pixels { match self.axis { Axis::Vertical => p.y, @@ -207,7 +104,6 @@ impl Reorder { } } - /// A slot's extent along the list's axis. fn extent(&self, b: &Bounds<Pixels>) -> Pixels { match self.axis { Axis::Vertical => b.size.height, @@ -215,28 +111,10 @@ impl Reorder { } } - /// How far a slot moves when the dragged one passes it: the dragged slot's - /// extent plus the gap it also takes with it. fn shift(&self) -> Pixels { self.extent(&self.rects[self.from]) + self.gap } - /// Where the dragged slot belongs for a cursor at `pointer`: how many of - /// the other slots would sit before it. - /// - /// A neighbour yields once the held item covers half of it — its *trailing* - /// edge past that neighbour's centre going forward, its *leading* edge past - /// it going back. Edges rather than the held item's own centre, because the - /// two are only equivalent when everything is the same size: in the rail a - /// three-row group block is twice a one-row block, and a centre-to-centre - /// test would demand the tall block's middle reach the short one's middle — - /// pointer travel that runs off the top of the list, which is exactly the - /// "big group won't move up" case. Half-overlap asks the same of both - /// directions and of any pair of sizes. - /// - /// The comparison is against the *frozen* centres, never the reflowed ones, - /// so the reflow can't move the number it's being compared to and the - /// crossing can't chase itself under a still cursor. pub(crate) fn target(&self, pointer: Point<Pixels>) -> usize { let leading = self.free_origin(pointer); let trailing = leading + self.extent(&self.rects[self.from]); @@ -247,7 +125,6 @@ impl Reorder { .filter(|(i, r)| { let centre = self.along(r.origin) + self.extent(r) / 2.; if *i < self.from { - // Still above the held item: it hasn't reached back this far. leading >= centre } else { trailing > centre @@ -256,17 +133,10 @@ impl Reorder { .count() } - /// Where the dragged slot's leading edge is, following the pointer without - /// limit: the cursor less where inside the slot it was grabbed, so the item - /// sits under the cursor exactly where you picked it up. fn free_origin(&self, pointer: Point<Pixels>) -> Pixels { self.along(pointer) - self.along(self.grab) } - /// [`Self::free_origin`] confined to the list's own span, so dragging far - /// past either end parks the item against that end instead of sending it - /// off across the window. Only the *drawing* is clamped — [`Self::target`] - /// reads the free position, so pushing past the last slot still selects it. fn held_origin(&self, pointer: Point<Pixels>) -> Pixels { let first = self.along(self.rects[0].origin); let last = self.rects.last().expect("non-empty"); @@ -275,20 +145,11 @@ impl Reorder { .clamp(first, end - self.extent(&self.rects[self.from])) } - /// The offset to draw the dragged slot at so it tracks the pointer: the - /// distance from where the list has *laid it out* this frame (its slot - /// under `target`) to where the cursor is actually holding it. - /// - /// This is what makes the drag feel attached rather than stepwise — the - /// held item moves pixel-for-pixel with the mouse, and the reflow of the - /// others is the only thing that snaps. pub(crate) fn held_offset(&self, pointer: Point<Pixels>, target: usize) -> Pixels { let home = self.along(self.rects[self.from].origin); self.held_origin(pointer) - (home + self.displacement(self.from, target)) } - /// Slot indices in preview order: the dragged one lifted out of `from` and - /// dropped back in at `target`. pub(crate) fn order(&self, target: usize) -> Vec<usize> { let mut order: Vec<usize> = (0..self.rects.len()).collect(); let dragged = order.remove(self.from); @@ -296,10 +157,6 @@ impl Reorder { order } - /// Open a frame previewing `target`: returns the animation generation to - /// key slide-ins on, and the target the previous frame drew — which - /// [`Self::flip_offset`] measures the slide from. Call once per frame, - /// before laying the slots out. pub(crate) fn begin_frame(&self, target: usize) -> (usize, usize) { let prev = self.prev.get(); if prev != target { @@ -309,17 +166,8 @@ impl Reorder { (self.generation.get(), prev) } - /// Where the slot frozen at index `slot` sits under a given preview, - /// relative to its resting place. - /// - /// The displaced slots each close up by one dragged-slot pitch, in the - /// direction the drag came from. The dragged slot itself moves the other - /// way by everything it has jumped over — it stays in the list rather than - /// floating above it, so it has a position to be displaced to like anyone - /// else, and the two sides always add up to a swap. fn displacement(&self, slot: usize, target: usize) -> Pixels { if slot == self.from { - // Sum the pitches of the slots crossed, since rows differ in height. let crossed = if target > self.from { self.from + 1..=target } else { @@ -339,11 +187,6 @@ impl Reorder { } } - /// The offset a slot should *start* this frame at so it slides into its new - /// place instead of teleporting: where the last frame drew it, minus where - /// this frame puts it. Zero for every slot the new target didn't disturb — - /// which is all but one on a typical frame, so the list only animates the - /// row you just crossed. pub(crate) fn flip_offset(&self, slot: usize, prev: usize, target: usize) -> Pixels { self.displacement(slot, prev) - self.displacement(slot, target) } @@ -354,8 +197,6 @@ mod tests { use super::*; use gpui::{point, size}; - /// A vertical list of `n` slots, each `h` tall with a `gap` between them, - /// starting at y = 0 — the sidebar's shape. fn column(n: usize, h: f32, gap: f32, from: usize) -> Reorder { let rects = (0..n) .map(|i| Bounds { @@ -369,28 +210,19 @@ mod tests { rects, Axis::Vertical, px(gap), - // Grabbed dead centre of the slot. point(px(100.), px(h / 2.)), ) } - /// The dragged slot claims a new index the moment its centre reaches where - /// the neighbour would sit without it, and holds its own index until then. #[test] fn target_follows_the_pointer_across_neighbours() { - // 4 rows of 30px + 2px gaps: centres at 15, 47, 79, 111. let r = column(4, 30., 2., 0); - // Dragging row 0, held by its centre. Row 1 yields once row 0's bottom - // edge covers half of it — pointer 32, i.e. bottom edge at 47. assert_eq!(r.target(point(px(100.), px(32.))), 0); assert_eq!(r.target(point(px(100.), px(34.))), 1); - // Then row 2's centre (79) at pointer 64, row 3's (111) at 96. assert_eq!(r.target(point(px(100.), px(66.))), 2); assert_eq!(r.target(point(px(100.), px(200.))), 3); } - /// Dragging upward is the mirror image, and the order it previews is the - /// dragged slot lifted out and re-inserted. #[test] fn order_lifts_the_dragged_slot_into_the_target() { let r = column(4, 30., 2., 3); @@ -400,33 +232,19 @@ mod tests { assert_eq!(r.order(3), vec![0, 1, 2, 3]); } - /// Only the slot the drag just crossed gets a slide offset, and it's the - /// full row pitch (row height + gap) in the direction it came from. #[test] fn flip_offset_animates_only_the_slot_just_crossed() { let r = column(4, 30., 2., 0); - // Preview moved from "row 0 stays" to "row 0 sits after row 1": - // row 1 closed up by one pitch, so it starts one pitch lower. assert_eq!(r.flip_offset(1, 0, 1), px(32.)); - // Rows the crossing didn't touch don't move at all. assert_eq!(r.flip_offset(2, 0, 1), px(0.)); assert_eq!(r.flip_offset(3, 0, 1), px(0.)); - // The dragged slot slides too, now that it rides in the list rather - // than floating over it: three rows crossed, three pitches to travel. assert_eq!(r.flip_offset(0, 0, 3), px(-96.)); assert_eq!(r.flip_offset(0, 3, 0), px(96.)); - // Backing out again slides row 1 the other way. assert_eq!(r.flip_offset(1, 1, 0), px(-32.)); } - /// A tall block and a short one swap in *both* directions, with the same - /// half-overlap threshold. The regression this pins: under a - /// centre-to-centre test a three-row group could never move above a - /// one-row group, because reaching its centre meant dragging the pointer - /// off the top of the list. #[test] fn unequal_sizes_swap_in_both_directions() { - // A 60px block at y=0 and a 140px block at y=62 (2px gap). let rects = vec![ Bounds { origin: point(px(0.), px(0.)), @@ -446,16 +264,10 @@ mod tests { px(2.), grab, ); - // Dragging the tall block up: it takes the top slot once its leading - // edge passes the short block's centre (30) — pointer 40, i.e. ~30px - // of travel from rest, all of it well inside the list. assert_eq!(tall.target(point(px(100.), px(41.))), 1); assert_eq!(tall.target(point(px(100.), px(39.))), 0); - // Held at rest, it keeps its own slot. assert_eq!(tall.target(point(px(100.), px(72.))), 1); - // And the short block still goes down past the tall one, at the same - // half-overlap rule: trailing edge (pointer + 50) past centre 132. let short = Reorder::new( Surface::SidebarGroups, 0, @@ -468,55 +280,33 @@ mod tests { assert_eq!(short.target(point(px(100.), px(84.))), 1); } - /// The held item tracks the pointer pixel for pixel, measured from - /// whichever slot the list has currently laid it out in — so it sits under - /// the cursor both before and after a crossing re-slots it. #[test] fn held_offset_tracks_the_pointer_across_a_crossing() { - // 4 rows of 30px + 2px gaps (pitch 32), grabbed dead centre of row 0. let r = column(4, 30., 2., 0); - // Nudged 10px down, still target 0: the row is 10px off its home slot. assert_eq!(r.held_offset(point(px(100.), px(25.)), 0), px(10.)); - // Just past row 1's centre the target flips to 1, and the row is now - // laid out one pitch lower — so the same pointer reads 32px less. assert_eq!(r.held_offset(point(px(100.), px(48.)), 1), px(1.)); - // Dragging far past the end parks it against the last slot instead of - // running off: row 3 starts at 96, so that's the furthest it goes. assert_eq!(r.held_offset(point(px(100.), px(900.)), 3), px(0.)); } - /// Only what the last drawn frame recorded may commit. The regression this - /// pins: dragging an item away and then back to its own slot stops the - /// surface recording (the move became a no-op), so without the per-frame - /// clear the earlier "moved" order would survive and be applied on release, - /// moving an item the user had visibly put back. #[test] fn pending_only_survives_the_frame_that_recorded_it() { let state: ReorderState = Rc::new(RefCell::new(Some(column(3, 30., 2., 0)))); let mine = Surface::Strip; - // A frame that previews a move records it. clear_pending(&state); set_pending(&state, &mine, vec![1, 0, 2]); - // Another surface's recording is ignored — one drag, one owner. set_pending(&state, &Surface::SidebarGroups, vec![2, 1, 0]); - // The next frame draws the item back in its own slot and records - // nothing; the earlier order must not outlive it. clear_pending(&state); assert_eq!(take_pending(&state), None); - // Taking also retires the drag. assert!(state.borrow().is_none()); - // And the ordinary path: recorded, then released. *state.borrow_mut() = Some(column(3, 30., 2., 0)); clear_pending(&state); set_pending(&state, &mine, vec![1, 0, 2]); assert_eq!(take_pending(&state), Some(vec![1, 0, 2])); } - /// The generation only advances when the preview actually changes, so a - /// jittering cursor inside one slot doesn't restart the slide every frame. #[test] fn begin_frame_bumps_the_generation_only_on_change() { let r = column(3, 30., 2., 0); @@ -526,8 +316,6 @@ mod tests { assert_eq!(r.begin_frame(2), (2, 1)); } - /// A horizontal list measures along x — the strip's chips, which are wider - /// than they are tall and vary in width. #[test] fn horizontal_lists_measure_along_x() { let widths = [100., 160., 120.]; @@ -551,8 +339,6 @@ mod tests { px(6.), point(px(50.), px(15.)), ); - // Chip 1's centre is at x=186; chip 0 takes its slot once its trailing - // edge (pointer + 50) covers half of it. assert_eq!(r.target(point(px(135.), px(15.))), 0); assert_eq!(r.target(point(px(137.), px(15.))), 1); assert_eq!(r.order(2), vec![1, 2, 0]); diff --git a/src/ui/right_panel.rs b/src/ui/right_panel.rs index 92a474bb..80bfacba 100644 --- a/src/ui/right_panel.rs +++ b/src/ui/right_panel.rs @@ -1,20 +1,3 @@ -//! The right detail panel: a docked column showing what the active pane *is*, -//! rather than what it's printing — session facts, its working-tree diff, and -//! its file tree. -//! -//! Its tab row has two homes. On macOS it is the panel's own title-bar-height top -//! zone, level with the window's chrome, so the column runs unbroken from the top -//! of the window. Off macOS the title bar has to span the panel (the window -//! controls live at its right end), so the row drops to the panel's second line — -//! Cursor-style — while the caption row above is painted in the panel's surface -//! so the column still reads as one colour. -//! Either way the tiles themselves are built in -//! [`tab_strip`](crate::ui::tab_strip), beside the rest of the window's tiles. -//! -//! No new source of truth: Info reads the same `TerminalView`/`Tab` accessors the -//! sidebar row does, Changes probes the same `git_diff` the diff overlay does, and -//! Files renders the same rows as the code panel's tree. - use gpui::{AnyElement, Context, Window, div, prelude::*, px}; use gpui_component::button::Button; use gpui_component::input::Input; @@ -33,110 +16,37 @@ use crate::ui::app::{ }; use crate::ui::scrollbar::with_vertical_scrollbar; -/// Bounds for the panel's width, mirroring the rail's: a floor so the tree never -/// becomes an ellipsis parade, and a ceiling as a fraction of the window so a -/// persisted value can't swallow the terminal. -/// -/// The floor is also what has to seat the panel's top row on macOS, which is the -/// binding constraint: four chrome tiles, the panel toggle and the "⋯" — six -/// 32px boxes, five 2px gaps and the two glyph-aligned insets — need **214px**. -/// A tighter floor doesn't make the panel narrower, it makes that row overflow; -/// the alternative (shrinking the tabs to body scale) was tried and reads as the -/// panel's own navigation being demoted below the two buttons beside it. 216 -/// leaves the row a hair of slack and is still narrower than any window this -/// panel is usable in. pub(crate) const MIN_WIDTH: f32 = 216.; pub(crate) const MAX_WIDTH_RATIO: f32 = 0.5; -/// Width (px) of the resize handle's invisible hit-area, centered on the panel's -/// left border — same geometry as the tab rail's. const RESIZE_HANDLE_WIDTH: f32 = 8.; -/// Panel state that isn't a user preference (those live in `Config`): the cached -/// diff for the Changes tab and the body's scroll position. #[derive(Default)] pub(crate) struct RightPanelState { - /// The machine and cwd `diff` was probed from — compared against the active - /// pane's host and cwd to decide whether the cached snapshot is still about - /// the right repository. The host is half the identity: the same path on two - /// machines is two repositories. pub(crate) diff_cwd: Option<(crate::ui::host_ops::HostId, PathBuf)>, - /// Last completed probe. `Some(None)` and `None` are different answers: - /// "probed, not a work tree" versus "never probed". Shared with the diff - /// overlay rather than a second copy of the same tree — see - /// [`Tty7App::spawn_shared_diff_probe`]. pub(crate) diff: Option<Option<Arc<DiffSnapshot>>>, - /// The machine-and-cwd this panel is waiting on a probe for; keeps the - /// render path from spawning a second one. A key rather than a flag because - /// the shared probe (see [`Tty7App::spawn_shared_diff_probe`]) lands per - /// repo: the panel has to know *which* answer clears its wait, or a probe - /// for the repo it just navigated away from would leave it stuck on - /// "Loading…". pub(crate) diff_pending: Option<(crate::ui::host_ops::HostId, PathBuf)>, - /// The pane `procs` describes, so a pane switch invalidates it rather than - /// showing the previous pane's processes under the new pane's name. pub(crate) procs_pane: Option<u64>, - /// Last completed process/port query for `procs_pane`. pub(crate) procs: Option<PaneProcs>, - /// A poll cycle is live — a query is in flight *or* the inter-tick timer is - /// waiting between ticks. The render path checks this before starting the - /// loop, so a re-render never starts a second chain. It must stay set across - /// the timer too: clearing it the instant a query returned let every repaint - /// in the 2s gap kick off another query, collapsing the interval into a tight - /// query→notify→repaint→query loop that made the list flicker. pub(crate) procs_loading: bool, - /// Bumped on every pane switch to retire the in-flight poll loop: a tick whose - /// generation no longer matches drops its result and stops rescheduling, so the - /// freshly started loop for the new pane is the only one left running. pub(crate) procs_gen: u64, - /// Whether the current pane also wants its SSH forwards re-listed on the - /// procs tick. Kept here rather than only captured by the running loop - /// because it can flip *without* a pane switch — a native-SSH pane you are - /// already watching finishes connecting — and the loop reads this on each - /// reschedule so it picks the change up on the next tick. - /// How the Forwards band's requests reach the daemon while this poll loop - /// runs, or `None` when the pane on screen has nothing to forward over. - /// - /// A route rather than a `bool` because a remote workspace's forwards belong - /// to the *workspace*, not the pane: the pane id alone cannot - /// say which of the two owners to ask, and the reschedule below re-reads - /// this rather than carrying the decision forward. pub(crate) procs_forwards: Option<crate::ui::app::ForwardRoute>, - /// Scroll position of the shared body container (Info / Outline / Changes), - /// owned here rather than left to gpui's element-id state so the overlay - /// scrollbar has a handle to read the offset from and to drag. pub(crate) scroll: gpui::ScrollHandle, - /// The Files tab's local tree scrolls in its own container (it carries the - /// tree's focus handle and key bindings), so it needs its own handle. pub(crate) tree_scroll: gpui::ScrollHandle, } -/// How often the Info tab re-queries processes and ports while it's open. Fast -/// enough that starting a dev server shows up as you tab over, slow enough that -/// the process-table walk stays off the profile. const PROCS_POLL: std::time::Duration = std::time::Duration::from_millis(2000); impl Tty7App { - /// Whether the right panel is docked open. The title bar's tab row, the body - /// column and the code overlay's right inset all derive from this. pub(crate) fn right_panel_open(&self, _cx: &gpui::App) -> bool { self.right_panel_visible && !self.tabs.is_empty() } - /// The panel's live width, re-clamped to the window the same way the rail's - /// is, so a persisted value from a larger display can't take over. - /// Named `_px` rather than `_width` because the field it reads is - /// `right_panel_width`; a method of the same name would shadow it awkwardly - /// at every call site. pub(crate) fn right_panel_px(&self, window: &Window, _cx: &gpui::App) -> f32 { let max = (window.viewport_size().width.as_f32() * MAX_WIDTH_RATIO).max(MIN_WIDTH); - // The live cell, not the config: a drag in progress writes only here, and - // persists to the config on release. self.right_panel_width.get().clamp(MIN_WIDTH, max) } - /// `ToggleRightPanel` (⌘J). Flips this window's panel; the config write is - /// only what the *next* window will start with — see the field's doc comment. pub(crate) fn toggle_right_panel(&mut self, cx: &mut Context<Self>) { let next = !self.right_panel_visible; self.right_panel_visible = next; @@ -144,8 +54,6 @@ impl Tty7App { cx.notify(); } - /// Select a tab. Opens the panel if it was closed, so the title bar's tab - /// tiles double as "show me this" rather than being inert while hidden. pub(crate) fn set_right_panel_tab(&mut self, tab: RightPanelTab, cx: &mut Context<Self>) { self.right_panel_tab = tab; self.right_panel_visible = true; @@ -156,29 +64,12 @@ impl Tty7App { cx.notify(); } - /// The docked column, or `None` while the panel is closed. pub(crate) fn render_right_panel( &mut self, window: &mut Window, cx: &mut Context<Self>, ) -> Option<AnyElement> { let panel_open = self.right_panel_open(cx); - // The remote browser follows the detail pane on *every* paint, not only - // while Files is on screen. Opening it is the Files tab's job (no point - // listing a directory nobody asked to see), but retiring it can't be: - // the transfers footer below is pane-scoped and rides under all four - // tabs, so a pane switch made from Info has to drop the old pane's - // browser too — otherwise the footer would report a transfer belonging - // to a pane you're no longer looking at. - // - // This runs *before* the closed-panel bail, and treats a closed panel as - // "not looking at that pane": the browser owns a 500ms transfer poll that - // only ends when the browser does, so leaving it open behind a closed - // panel would keep a daemon round-trip (and a full re-render) running - // twice a second for a column nobody can see. The poll loop makes the - // same check on its own tick (`sftp_start_polling`) so its lifetime - // doesn't rest on this function being called every frame; retiring here - // as well just gets it done a frame sooner instead of up to 500ms later. if let Some(open) = self.sftp_panel.open_pane_id && (!panel_open || self.remote_files_pane(window, cx).map(|(id, _)| id) != Some(open)) { @@ -206,48 +97,16 @@ impl Tty7App { .w(px(width)) .h_full() .child(backing) - // The sunk sidebar surface, like the tab rail: both are chrome - // around the terminal, so they read as the same material. .bg(cx.theme().sidebar) .border_l_1() .border_color(cx.theme().sidebar_border) - // A title-bar-height top zone of its own, exactly like the rail's. - // This is what makes the panel read as one column instead of a box - // bolted under the title bar: its surface runs the full height of - // the window, and the tab row sits *on* it rather than on the - // terminal's bar above a seam. - // - // macOS only. Off macOS the bar spans the panel — it has to, or the - // window controls end up stranded mid-window (see `app::render`) — - // and a row of tiles under that caption row was one chrome row too - // many: the panel opened with three stacked headers (caption chrome, - // tab tiles, section title) before any content. So there the tiles - // move into the section header instead (`panel_title`), which is a - // row the panel was drawing anyway. .children(cfg!(target_os = "macos").then(|| { let row = h_flex() .id("right-panel-titlebar-drag") .flex_none() .h(px(crate::ui::app::TITLE_BAR_HEIGHT)) - // gpui-component's `TitleBar` centres its content inside a - // `border_b_1` box — border-box shrinks the content height - // by that 1px, nudging its centred glyphs up half a pixel. - // The corner chrome (⋯, panel toggle) lives in *both* the - // title bar and here, so mirror that hidden border to keep - // its centre line identical; without it the glyphs jump - // down a physical pixel the moment the panel opens. .border_b_1() .border_color(cx.theme().transparent); - // The top zone sits level with the real `TitleBar`, but the - // bar only spans the terminal column — so, exactly like the - // rail's top strip (`tab_sidebar`), make this one act like - // the title bar it aligns with: drag to move, double-click - // to zoom. A press arms a flag and the first *move* starts - // the window move, so a plain click on a tab — and a - // double-click — still lands intact; the tabs and corner - // chrome take their own. `window_move_gesture` holds that - // flag in element state, so a repaint between the press and - // the first move can't disarm it (#221). crate::ui::app::window_move_gesture( row, "right-panel-titlebar-drag", @@ -257,32 +116,18 @@ impl Tty7App { .on_double_click(|_, window, _| window.titlebar_double_click()) .items_center() .gap(px(2.)) - // Chrome scale, like the corner controls this row ends with - // (`right_panel_tabs`): the leading inset lines the *glyph* - // up on `CONTENT_INSET`, so it subtracts the 32px tile's own - // padding rather than a 24px one's. .pl(px(tile_trailing_inset())) .children(self.right_panel_tabs(cx)) .child(div().flex_1()) - // The panel is what reaches the window's right edge while - // it's open, so it carries the corner chrome. .child(self.window_chrome(window, cx)) })) .child(body) - // The transfers footer is a sibling of the body, not part of any - // tab: an SFTP transfer belongs to the pane, so reading Info or - // Changes must not make a running upload vanish. .children(self.sftp_transfers_footer(cx)) .child(handle) .into_any_element(), ) } - /// The panel's resize drag: a measuring canvas that installs window-level - /// mouse listeners while held, plus the handle itself. Mirrors the tab rail's - /// (`tab_sidebar.rs`) with the axis flipped — this panel is anchored to the - /// window's right edge, so width grows as the pointer moves *left*, measured - /// from the panel's own right edge rather than its origin. fn right_panel_resize(&self, cx: &mut Context<Self>) -> (AnyElement, AnyElement) { use gpui::{Bounds, MouseButton, MouseMoveEvent, MouseUpEvent, Pixels, canvas}; use std::cell::Cell as StdCell; @@ -342,11 +187,6 @@ impl Tty7App { .size_full() .into_any_element(); - // `occlude()` for the same reason as the rail's handle (`tab_sidebar`): - // it spans the panel's full height, so its top band lies over a - // `WindowControlArea::Drag` row — the macOS top zone, and `panel_title` - // below it — and a non-blocking hitbox lets a press arm that row's window - // move alongside the resize. let active = self.right_panel_dragging.get(); let handle = div() .group("right-panel-resize") @@ -379,40 +219,6 @@ impl Tty7App { (backing, handle) } - /// A tab's header: the name in a weightier small-caps than the old faint - /// label, plus an optional live count trailing it (files, commands, changed - /// files) so the header states scale at a glance, and an optional control on - /// the right. The count is the quiet mono tally the sidebar group headers use. - /// `trailing` carries a tab's own controls where it has any, so they sit on - /// the label's line rather than earning a second header row. - /// - /// Off macOS this row is also the panel's tab switcher: the four tiles ride - /// at its trailing edge, and the row takes the full title-bar height with a - /// hairline under it. The panel there hangs below a caption row that already - /// carries chrome (see `render_right_panel`), and a tile row of its own on top - /// of this one meant three stacked headers before a single line of content — - /// so the two that were saying "this is a header" merge into one that also - /// says which tab you are on. - /// - /// **On macOS it draws nothing unless a tab passes `trailing`.** The panel - /// there has its own tile row in its top zone, which already says which tab - /// you are on — restating it in words underneath was a whole row spent on - /// something the selected tile and the content below both already answer - /// (a file tree is Files, a diff is Changes). What it did cost was the row: - /// the panel opened with tiles, then a title, then a search box, before one - /// line of content. The counts it used to carry move into the tab tooltips. - /// A tab that has its own control still gets the row, because that control - /// has nowhere else to go. - /// - /// When it *does* draw, it is grabbable, like every header in the window (see - /// [`crate::ui::app::window_move_gesture`]): it sits above the panel's scroll - /// container, not inside it, so a drag here has nothing else to mean. The - /// label and its count take no hit box, so the row stays grabbable straight - /// through them however long the text gets — the same rule the "duo" mark - /// established in #202. Anything in `trailing` is a control and must carry - /// its own `occlude()`, or Windows' HTCAPTION eats its clicks. The gesture is - /// armed *after* the empty-row early return, so the macOS zero-height case - /// never becomes an invisible drag area. pub(crate) fn panel_title( &self, text: &str, @@ -433,8 +239,6 @@ impl Tty7App { cx, ); row.flex_none() - // Tall enough to seat the chrome-scale tiles when it carries them; - // otherwise the compact label line it has always been. .h(px(if tabs.is_some() { crate::ui::app::TITLE_BAR_HEIGHT } else { @@ -442,18 +246,11 @@ impl Tty7App { })) .items_center() .pl(px(CONTENT_INSET)) - // Trailing tiles align on the glyph like every other control in the - // window; a label-only header just takes the plain inset. `_SM` for a - // tab's own control, whose glyph sits a different distance inside its - // box than the chrome-scale tab tiles do. .pr(px(match (&tabs, has_trailing) { (Some(_), _) => tile_trailing_inset(), (None, true) => tile_trailing_inset_sm(), (None, false) => CONTENT_INSET, })) - // The line that separates the header from the tab's content. Only - // where the header is the switcher: a label alone doesn't need ruling - // off from the band it introduces. .when(tabs.is_some(), |this| { this.border_b_1().border_color(cx.theme().sidebar_border) }) @@ -487,8 +284,6 @@ impl Tty7App { .flex_shrink_0() .items_center() .gap(px(2.)) - // Clear of a tab's own control where there is one; flush - // against the label's spring where there isn't. .when(has_trailing, |this| this.ml(px(6.))) .children(tiles), ) @@ -496,11 +291,6 @@ impl Tty7App { .into_any_element() } - /// A tab's filter box — the same borderless magnifier + input the tab rail - /// uses, so everything in the window searches the same way. Sits under the - /// header rather than in it: it's a full-width control, not a trailing tile. - /// Takes the input so the local tree and the remote browser can each keep - /// their own query while sharing the one appearance. pub(crate) fn panel_search( &self, input: &gpui::Entity<gpui_component::input::InputState>, @@ -526,8 +316,6 @@ impl Tty7App { .into_any_element() } - /// The body's scrolling area, so every tab shares one scroll container and - /// one content inset. fn panel_scroll(&self, inner: AnyElement, title: AnyElement) -> AnyElement { let body = div() .id("right-panel-body") @@ -548,13 +336,6 @@ impl Tty7App { .into_any_element() } - /// A quiet "nothing to show" line, used wherever a tab has no data yet, - /// with an optional second line saying what would fill it. - /// - /// The hint is the point. An empty state that only reports the absence - /// ("No changes.") leaves the user to work out whether the panel is broken, - /// still loading, or simply pointed at the wrong thing; one that names the - /// condition turns a dead end into an instruction. fn panel_empty(&self, text: &str, hint: Option<&str>, cx: &mut Context<Self>) -> AnyElement { let muted = cx.theme().muted_foreground; v_flex() @@ -573,21 +354,11 @@ impl Tty7App { .into_any_element() } - // ── Info ──────────────────────────────────────────────────────────────── - - /// Session facts for the active pane, as a two-column key/value list. Every - /// row comes from an accessor the sidebar already uses, so the panel can - /// never disagree with the row that spawned it. fn render_panel_info(&mut self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement { let title = self.panel_title("Info", None, None, window, cx); let mut rows: Vec<(&'static str, String)> = Vec::new(); - // Held aside from `rows` because they're not key/value lines: the actions - // hang off the cwd, and the two lists get their own sub-headers below. let mut cwd_for_actions: Option<PathBuf> = None; let mut pane_id: Option<u64> = None; - // Set only for a *connected native* SSH pane — the one kind that can carry - // forwards. A foreground `ssh` typed into a local shell has no connection - // to forward over, and a still-connecting one has nothing to list yet. let mut forwards_pane: Option<u64> = None; if let Some(tab) = self.tabs.get(self.active) { @@ -602,9 +373,6 @@ impl Tty7App { rows.push(("cwd", compact_path(&cwd))); cwd_for_actions = Some(cwd); } - // A pane that named no shell took its machine's default — which - // for a remote workspace is the *far* machine's, not this - // computer's `$SHELL`. let shell = match view.shell_spec().map(|s| s.program.clone()) { Some(program) => crate::core::shells::default_shell_name(Some(&program)), None => self.default_shell_label(cx), @@ -613,13 +381,6 @@ impl Tty7App { if let Some(ssh) = view.ssh_spec() { rows.push(("ssh", ssh.host.clone())); } - // Two ways a pane has something to forward over: it *is* an - // SSH session, or it belongs to a remote workspace, whose - // forwards run on the workspace's own connection. - // The second arm is empty in this build — nothing binds a pane - // to a workspace yet — which is deliberate: the band stays - // empty rather than offering an add that would have nowhere to - // go. let connected_ssh = view .remote_context() .is_some_and(|c| c.kind == crate::daemon::protocol::RemoteKind::NativeSsh) @@ -656,9 +417,6 @@ impl Tty7App { ); } - // Keep the process/port query pointed at the pane on screen, and keep it - // ticking while this tab is the one being looked at. The same tick carries - // the pane's forwards when it has any to carry. let route = forwards_pane.map(|id| self.forward_route(id, cx)); self.sync_procs(pane_id, route, cx); @@ -679,9 +437,6 @@ impl Tty7App { .child(k), ) .child( - // The value is the datum — a path, a branch, a host, a - // count — so it takes the mono face, set apart from the - // sans key beside it. div() .flex_1() .min_w_0() @@ -694,9 +449,6 @@ impl Tty7App { } let inner = v_flex() - // Three labelled bands — Session / Processes / Ports — instead of one - // flat column, so the pane's facts, what it's running, and what it's - // listening on read as distinct groups. .child(self.panel_subtitle("Session", false, None, cx)) .child(list) .when_some(cwd_for_actions, |this, cwd| { @@ -704,18 +456,11 @@ impl Tty7App { }) .children(self.procs_section(pane_id, cx)) .children(self.ports_section(pane_id, cx)) - // Ports says what this pane listens on locally; Forwards says what it - // routes across the connection. Same family of fact, so it reads as - // the band after it rather than a feature bolted on. .children(self.forwards_section(forwards_pane, cx)) .into_any_element(); self.panel_scroll(inner, title) } - /// The "open this cwd in…" row under the Info list. Deliberately only the - /// destinations that need no configuration — a system reveal and the - /// clipboard. An "open in $EDITOR" button would need a picker, a stored - /// choice and a settings page to change it; that's a feature, not a row. fn cwd_actions(&self, cwd: PathBuf, cx: &mut Context<Self>) -> AnyElement { let reveal_label = reveal_label(); h_flex() @@ -756,13 +501,6 @@ impl Tty7App { .into_any_element() } - /// A small-caps band label inside a tab's body, for the sub-lists that hang - /// off the Info tab. Lighter than [`panel_title`], which is the tab's own - /// header. `divider` draws a hairline above it, so the second and third bands - /// separate from the one before; the first band passes `false`. `trailing` - /// carries a band's own control where it has one — the same slot - /// [`panel_title`](Self::panel_title) gives a tab, so a band's `+` sits on its - /// label's line instead of earning a row. pub(crate) fn panel_subtitle( &self, text: &str, @@ -777,16 +515,11 @@ impl Tty7App { .items_center() .justify_between() .pl(px(CONTENT_INSET)) - // A trailing tile aligns on its glyph, not its hit box — same - // correction the tab header makes. .pr(px(if trailing.is_some() { CONTENT_INSET - crate::ui::app::TILE_PAD } else { CONTENT_INSET })) - // A tile is 24px tall against a ~15px label, so the band's own top - // padding would push its glyph off the label's line; give the padding - // back as a shorter lead when one is present. .pt(px(match (divider, trailing.is_some()) { (true, false) => 12., (true, true) => 8., @@ -805,9 +538,6 @@ impl Tty7App { .into_any_element() } - /// The pane's process tree, indented by depth. Returns nothing at all when - /// the pane is just a shell sitting at its prompt: a one-row "processes" - /// section that always says `zsh` is a header earning its keep zero times. fn procs_section(&self, pane_id: Option<u64>, cx: &mut Context<Self>) -> Option<AnyElement> { let procs = &self.procs(pane_id)?.procs; if procs.len() < 2 { @@ -825,8 +555,6 @@ impl Tty7App { .flex_1() .min_w_0() .truncate() - // Indent by depth so the tree reads without drawing - // connector glyphs into a 260px column. .pl(px(f32::from(p.depth) * 10.)) .text_size(px(12.)) .font_family(mono.clone()) @@ -853,8 +581,6 @@ impl Tty7App { ) } - /// TCP ports the pane's processes are listening on — the answer to "what - /// port did that dev server pick?", next to the pane that started it. fn ports_section(&self, pane_id: Option<u64>, cx: &mut Context<Self>) -> Option<AnyElement> { let ports = &self.procs(pane_id)?.ports; if ports.is_empty() { @@ -893,30 +619,11 @@ impl Tty7App { ) } - /// The cached query, but only when it describes `pane_id` — the pane the - /// Info tab is currently rendering. `sync_procs` already drops the answer on - /// a pane switch, so this is belt-and-braces; without the argument the doc - /// claimed a guarantee the body didn't actually make. fn procs(&self, pane_id: Option<u64>) -> Option<&PaneProcs> { (pane_id.is_some() && self.right_panel.procs_pane == pane_id) .then_some(self.right_panel.procs.as_ref())? } - /// Point the process query at `pane_id` and make sure the poll is running. - /// Called from the Info tab's render, so the loop starts when the tab is - /// looked at and dies when it isn't — see [`spawn_procs_query`]. - /// - /// `forwards` asks the same tick to re-list the pane's SSH forwards. It rides - /// this loop rather than owning one because it wants the identical lifetime - /// (Info on screen, this pane) and because a forward can change state without - /// the UI touching it — a remote bind that loses its listener goes to `Error` - /// on the daemon, and only a re-list finds out. Off for a non-SSH pane, so a - /// local shell doesn't pay for a round-trip that can only answer "none". - /// - /// It's recorded on the state as well as passed down because it can flip - /// while the loop is already running — a pane you're watching on Info - /// finishes connecting, and neither the pane id nor the generation changes, - /// so nothing would otherwise tell the loop to start asking. fn sync_procs( &mut self, pane_id: Option<u64>, @@ -927,15 +634,8 @@ impl Tty7App { self.right_panel.procs_forwards = forwards.clone(); if self.right_panel.procs_pane != Some(pane_id) { self.right_panel.procs_pane = Some(pane_id); - // Drop the previous pane's answer rather than showing it under the new - // pane's heading until the first tick lands. self.right_panel.procs = None; - // Same for the forwards: the list is one pane's, and the rows filter by - // pane id anyway, so leaving the old pane's in place would only flash - // them under the new pane's band until the tick lands. self.loopback_panel.managed.clear(); - // Retire the old pane's loop and free the guard so the new pane's loop - // can start below; the retired tick bows out on the generation check. self.right_panel.procs_gen += 1; self.right_panel.procs_loading = false; } @@ -946,9 +646,6 @@ impl Tty7App { } } - /// One query, then reschedule — the poll loop. It reschedules only while the - /// panel is open on Info, so the loop is self-terminating: close the panel or - /// switch tabs and the next completion simply doesn't queue another. fn spawn_procs_query( &mut self, pane_id: u64, @@ -956,11 +653,7 @@ impl Tty7App { forwards: Option<crate::ui::app::ForwardRoute>, cx: &mut Context<Self>, ) { - // `procs_loading` is set by the caller (`sync_procs`) and deliberately - // stays set across the whole cycle, including the timer wait below. cx.spawn(async move |this, cx| { - // Both round-trips on the one background hop, so the tick costs one - // scheduling slot rather than two. let route = forwards.clone(); let (procs, managed) = cx .background_executor() @@ -972,8 +665,6 @@ impl Tty7App { .await; let keep_polling = this .update(cx, |app, cx| { - // A pane switch while we flew bumped the generation: drop this - // answer and leave the guard to whoever owns the new one. if app.right_panel.procs_gen != generation { return false; } @@ -982,12 +673,9 @@ impl Tty7App { app.loopback_panel.managed = managed; } cx.notify(); - // This window's own panel state, not the config's: another - // window closing its panel must not stop our poll. let wanted = app.right_panel_visible && app.right_panel_tab == RightPanelTab::Info; if !wanted { - // Loop ends here; release the guard so reopening restarts it. app.right_panel.procs_loading = false; } wanted @@ -998,16 +686,11 @@ impl Tty7App { } cx.background_executor().timer(PROCS_POLL).await; let _ = this.update(cx, |app, cx| { - // Re-check rather than trusting the pre-sleep decision: two seconds - // is plenty of time to switch panes or close the panel. if app.right_panel.procs_gen != generation { return; } let wanted = app.right_panel_visible && app.right_panel_tab == RightPanelTab::Info; if wanted { - // Re-read rather than carrying the flag forward: the pane may - // have finished connecting since this cycle started, which is - // the one way it changes without a pane switch to retire us. let forwards = app.right_panel.procs_forwards.clone(); app.spawn_procs_query(pane_id, generation, forwards, cx); } else { @@ -1018,17 +701,7 @@ impl Tty7App { .detach(); } - // ── Outline ───────────────────────────────────────────────────────────── - - /// The pane's commands, newest first, each scrolling the terminal back to - /// where it ran. Positions come from the OSC 133 marks the reader thread - /// records — see [`crate::terminal::marks`]. - /// - /// Newest first because that's the end you came from: you scrolled past the - /// thing you want, and the list should start where your attention is. fn render_panel_outline(&mut self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement { - // This panel is a sunk rail (see the `sidebar` fill on its container), so - // its rows read the sidebar ladder. let sf = cx.global::<crate::ui::presets::Surfaces>().sidebar; let Some(leaf) = self .tabs @@ -1045,13 +718,8 @@ impl Tty7App { title, ); }; - // Count first (a cheap getter) so the borrow ends before `panel_title` - // needs `&mut cx`; the list re-borrows the marks below. let count = leaf.read(cx).command_marks().len(); if count == 0 { - // Two very different causes, one honest sentence: nothing has run - // yet, or this shell never reported OSC 133 (no integration, a bare - // `sh`, a nested PTY that eats the marks). let title = self.panel_title("Outline", None, None, window, cx); return self.panel_scroll( self.panel_empty( @@ -1072,9 +740,6 @@ impl Tty7App { let leaf = leaf.clone(); let failed = mark.exit.is_some_and(|c| c != 0); let running = !mark.done; - // A leading status marker reads as a shape first: a hollow ring for a - // clean finish, a filled dot while it runs, and — the only tinted one - // — a danger dot for a nonzero exit. The failure is what you scan for. let dot = { let d = div().flex_none().size(px(7.)).rounded_full(); if failed { @@ -1107,8 +772,6 @@ impl Tty7App { .flex_1() .min_w_0() .truncate() - // Commands are code: the mono face sets them apart from - // the sans labels and lines the list up like a log. .text_size(px(12.)) .font_family(mono.clone()) .text_color(if failed { @@ -1118,9 +781,6 @@ impl Tty7App { }) .child(one_line(&mark.text)), ) - // Only nonzero exits earn a badge. Annotating every success - // with a `0` would make the failures harder to spot, not - // easier — the whole point of the column. .when_some(mark.exit.filter(|c| *c != 0), |this, code| { this.child( div() @@ -1136,23 +796,8 @@ impl Tty7App { self.panel_scroll(list.into_any_element(), title) } - // ── Changes ───────────────────────────────────────────────────────────── - - /// The working-tree diff as a compact file list — path plus `+N −M` — not the - /// diff overlay's hunk cards, which need far more than 260px to be readable. - /// Clicking a row opens the full overlay on that repo. - /// - /// Bounded the same way the overlay is - /// ([`MAX_RENDERED_FILES`](crate::terminal::git_diff::MAX_RENDERED_FILES), - /// one constant so the two views agree): the rows are not virtualized, so a - /// working tree with thousands of changed files is one row per file rebuilt - /// on the UI thread every frame — the stall issue #239 is about, reached - /// through this panel instead of the overlay. fn render_panel_changes(&mut self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement { let sf = cx.global::<crate::ui::presets::Surfaces>().sidebar; - // The pane's own host, and the cwd it resolved its git line through — - // so the Changes list describes the same repository the sidebar's - // `+N −M` does, on the same machine. let target = self .tabs .get(self.active) @@ -1177,25 +822,15 @@ impl Tty7App { title, ); }; - // Probe on first paint for this cwd, and whenever the pane moves to a - // different repository. Refreshes ride the same git-status observer the - // sidebar counts do (see `right_panel_refresh_changes`), which re-probes - // *in place* — the list only blanks when the repository itself changes. let key = (host.id(), cwd.clone()); if self.right_panel.diff_cwd.as_ref() != Some(&key) { self.right_panel.diff_cwd = Some(key); self.right_panel.diff = None; self.spawn_right_panel_diff(host.clone(), cwd.clone(), cx); } else if self.right_panel.diff.is_none() && self.right_panel.diff_pending.is_none() { - // Nothing cached and nothing in flight: a probe for a previous cwd - // landed after we had already moved on and dropped its result, so - // no one is left to answer for this one. Without this the tab would - // sit on "Loading…" until some unrelated event nudged it. self.spawn_right_panel_diff(host.clone(), cwd.clone(), cx); } - // Count of changed files for the header tally — computed before the title - // so the diff borrow ends before `panel_title` takes `&mut cx`. let count = match &self.right_panel.diff { Some(Some(snap)) => { let n = snap.files.len() + snap.untracked_count(); @@ -1220,33 +855,12 @@ impl Tty7App { cx, ), Some(Some(snap)) => { - // A refcount bump, not a copy of the file list: the loop below - // needs the borrow on `self.right_panel` to be over before - // `cx.listener` hands it back, which used to mean collecting - // every `(path, +N, −N)` into a Vec on every frame of this - // panel, on the UI thread. Sharing the snapshot made that a - // deep clone of the whole diff for nothing. let snap = Arc::clone(snap); - // The count, not the paths: this used to clone every untracked - // path String on every frame of this panel, on the UI thread, - // for two `len()`/`is_empty()` reads — the same cost class the - // `Arc` switch removed from the probe path. let untracked = snap.untracked_count(); let focused = self.diff_overlay_focus(host.id(), &cwd).map(str::to_string); - // The overlay's ceiling, deliberately the same number: this list - // is not virtualized either, so a whole-repo reformat means one - // row per changed file built from scratch every frame. The tally - // in the header above still counts every file — capping what is - // *rendered* must not change what is *reported*, the same rule - // `untracked_count` and `DiffSnapshot::totals` follow. let shown = snap.files.len().min(MAX_RENDERED_FILES); - // Rows inset themselves rather than the list, so the hover and - // selected capsules bleed a little past the text into the same - // 12px gutter the tab rail's rows use. let mut list = v_flex().px(px(CONTENT_INSET - 4.)).py(px(2.)).gap(px(1.)); for file in snap.files.iter().take(shown) { - // Owned per *rendered* row — bounded by `shown` — because the - // element id and the click listener both outlive this frame. let path = file.path.clone(); let (added, removed) = (file.added, file.removed); let selected = focused.as_deref() == Some(path.as_str()); @@ -1259,11 +873,6 @@ impl Tty7App { .py(px(3.)) .rounded(px(5.)) .cursor_pointer() - // The rail's own ladder. Hover used to be this fill at - // 55% alpha, which on a light theme is a tint nobody - // can see — the same mistake `chrome_tile_variant_for` - // already documents having fixed in the title bar, made - // again here because there was nothing to reuse. .hover(|s| s.bg(gpui::rgb(sf.hover))) .when(selected, |s| s.bg(gpui::rgb(sf.selected))) .on_click({ @@ -1271,9 +880,6 @@ impl Tty7App { let cwd = cwd.clone(); let path = path.clone(); cx.listener(move |this, _, window, cx| { - // Toggling on the same row closes the overlay, - // so a row is a switch for "show me this diff", - // not a one-way door. this.toggle_diff_overlay_at( host_id, cwd.clone(), @@ -1283,8 +889,6 @@ impl Tty7App { ); }) }) - // A neutral status letter, kind by glyph not by hue — - // tracked edits are `M`; untracked get `U` below. .child(git_badge("M", cx.theme().muted_foreground, &mono)) .child( div() @@ -1296,9 +900,6 @@ impl Tty7App { .text_color(cx.theme().foreground) .child(path), ) - // +N / −M keep the terminal-git greens and reds, the - // one place hue earns its keep; a zero side is dropped - // rather than shown as `+0`. .when(added > 0, |this| { this.child( div() @@ -1321,9 +922,6 @@ impl Tty7App { }), ); } - // The tail the cap cut, the way the overlay's file list and its - // untracked section already report theirs — a truncated list - // that says nothing reads as files having vanished. if snap.files.len() > shown { let rest = snap.files.len() - shown; list = list.child( @@ -1364,13 +962,6 @@ impl Tty7App { self.panel_scroll(inner, title) } - /// Off-thread `git diff` for the panel — the *same* probe the diff overlay - /// uses. This used to be its own `git_diff::probe` call keeping its own - /// `DiffSnapshot`, so a repo with both open generated, parsed and stored - /// its full diff twice (issue #239, finding 5). Now both go through - /// [`Tty7App::spawn_shared_diff_probe`], which dedupes by machine-and-cwd - /// and installs one `Arc` into whoever is watching — including this panel, - /// which is why there is no result handler left here. fn spawn_right_panel_diff( &mut self, host: crate::ui::host_ops::SharedHost, @@ -1384,31 +975,16 @@ impl Tty7App { self.spawn_shared_diff_probe(host, cwd, cx); } - /// Re-probe the Changes list when the shared status cache learned something - /// newer than what's shown — called from the app's - /// `observe_global::<GitStatusCache>` hook, the same trigger that refreshes - /// the sidebar's `+N −M` and the diff overlay. - /// - /// Deliberately *not* "drop the cache and let the next paint re-probe": - /// that observer fires on every landed probe, including unrelated repos', so - /// dropping the cache blanked the list to "Loading…" and spawned a fresh - /// `git diff` several times a second while a pane was producing output. - /// Comparing branch + totals first keeps the quiet case free, and re-probing - /// in place leaves the rows on screen until the new snapshot lands. pub(crate) fn right_panel_refresh_changes(&mut self, cx: &mut Context<Self>) { if self.right_panel.diff_pending.is_some() { return; } let Some((id, cwd)) = self.right_panel.diff_cwd.clone() else { - return; // never probed — the render path owns the first one + return; }; - // The host object itself has to come from the registry: only the id is - // cached, and a machine that has gone away has no diff to re-probe. let Some(host) = crate::ui::host_registry::HostRegistry::get(cx, id) else { return; }; - // `Some(None)` (probed, not a work tree) stays put: a status entry for a - // non-repo can't appear, so there's nothing to disagree with. let Some(Some(snap)) = &self.right_panel.diff else { return; }; @@ -1424,27 +1000,13 @@ impl Tty7App { } } - // ── Files ─────────────────────────────────────────────────────────────── - - /// The project tree, reusing the code panel's rows verbatim — same expand - /// state, same click-to-open, so the panel and the editor overlay are two - /// views of one tree rather than two trees. - /// The Files tab follows the pane: a local pane gets its repository tree, a - /// connected native-SSH pane gets that machine's filesystem over SFTP. One tab, - /// because "the files this pane is working in" is one idea — where they - /// physically live is a property of the pane, not a second feature. fn render_panel_files(&mut self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement { let remote = self.remote_files_pane(window, cx); let host = remote.as_ref().map(|(_, host)| host.clone()); - // Point the browser at this pane, or tear it down when the tab has moved - // back to a local one. Returns whether to render the remote mode. if self.sftp_sync_pane(remote.map(|(id, _)| id), window, cx) { return self.render_panel_sftp(host.unwrap_or_default(), window, cx); } - // No header control: the tree's one view option (dotfiles) is a - // right-click away in the tree itself (`file_tree::dotfiles_menu_item`), - // which is where you are when you want it. let title = self.panel_title("Files", None, None, window, cx); let search = self.panel_search(&self.file_search.clone(), cx); let rows = self.render_file_tree_rows(window, cx); @@ -1457,10 +1019,6 @@ impl Tty7App { .into_any_element() } - /// The detail pane and its host name when it's a *connected native* SSH pane — - /// the gate for the Files tab's remote mode. A foreground `ssh` typed into a - /// local shell has no connection to browse, and a still-connecting one has - /// nothing to list, so both keep the local tree. fn remote_files_pane( &self, window: &mut Window, @@ -1479,9 +1037,6 @@ impl Tty7App { } } -/// A small status letter (`M`/`U`/…) for a change row. The *kind* is told by the -/// glyph in the mono face, not by colour, so the list stays monochrome; callers -/// pass a muted tone and reserve real hue for the `+N −M` counts beside it. pub(crate) fn git_badge(letter: &str, color: gpui::Hsla, mono: &gpui::SharedString) -> AnyElement { div() .flex_none() @@ -1495,8 +1050,6 @@ pub(crate) fn git_badge(letter: &str, color: gpui::Hsla, mono: &gpui::SharedStri .into_any_element() } -/// A pid / port pill: a mono number on the soft-grey capsule the rest of the -/// chrome uses, so a numeric datum reads as a tag rather than loose text. pub(crate) fn info_chip( text: &str, bg: gpui::Hsla, @@ -1516,10 +1069,6 @@ pub(crate) fn info_chip( .into_any_element() } -/// The label for revealing a path in the OS file manager: only macOS has a -/// "Finder", so everywhere else it's the generic "Open Folder". Shared by the -/// Info row, the file-tree context menu and the SFTP job list so the action -/// carries one name per platform. pub fn reveal_label() -> &'static str { if cfg!(target_os = "macos") { "Reveal in Finder" @@ -1528,7 +1077,6 @@ pub fn reveal_label() -> &'static str { } } -/// The one-word status the Info row shows next to the agent's name. fn agent_status_label(status: crate::core::cli_agent::AgentStatus) -> &'static str { use crate::core::cli_agent::AgentStatus::*; match status { @@ -1539,14 +1087,10 @@ fn agent_status_label(status: crate::core::cli_agent::AgentStatus) -> &'static s } } -/// Flatten a possibly-multiline command to one row: newlines and tabs become -/// spaces, runs of whitespace collapse. A heredoc or a `for` loop typed across -/// lines is still recognizable, and the list keeps one row per command. fn one_line(text: &str) -> String { text.split_whitespace().collect::<Vec<_>>().join(" ") } -/// `~`-shorten a path for the Info list, which has ~180px to play with. fn compact_path(path: &std::path::Path) -> String { let s = path.to_string_lossy().to_string(); match std::env::var("HOME") { diff --git a/src/ui/rounding.rs b/src/ui/rounding.rs index 40a44a04..86d0ad44 100644 --- a/src/ui/rounding.rs +++ b/src/ui/rounding.rs @@ -1,43 +1,5 @@ -//! Corner radii for children that paint a fill at the end of a rounded track. -//! -//! # The bug this exists to prevent (issue #236) -//! -//! gpui's `overflow_hidden` does **not** round-clip. [`gpui::ContentMask`] is a -//! plain axis-aligned `Bounds` with no corner radii (`Style::overflow_mask` -//! builds it from the element's bounds, shrunk by the border widths, and throws -//! `corner_radii` away), and every shader tests it as a hard per-fragment -//! `clip_distances < 0` discard. So the mask can only ever cut a square, and it -//! cuts it without a hint of anti-aliasing. -//! -//! A container's *own* rounded corners come from somewhere else entirely: the -//! quad shader's signed-distance field, whose `saturate(0.5 - distance)` gives -//! a clean one-device-pixel edge. That is why a plain rounded card looks -//! smooth. The moment a **child** paints a background into that same corner, -//! the two paths diverge: -//! -//! | | corner comes from | anti-aliased | -//! |---|---|---| -//! | the track's border/fill | quad SDF | yes | -//! | a child's fill in that corner | rectangular content mask | **no** | -//! -//! The child fills the whole square corner, the track's border arc floats -//! *inside* that square, and the child's outer edge is a hard vertical cut. It -//! reads exactly like the report: "anti-aliasing is insufficient". -//! -//! # The rule -//! -//! A child that lands in a corner has to carry its own radius, so its fill is -//! drawn by the SDF path too. It sits one border-width inside the track, so the -//! radius that nests concentrically is `outer - border` — see [`inner_radius`]. -//! -//! The clip is still worth keeping as a backstop for content overflow; it just -//! can't be the thing that shapes a corner. - use gpui::{Corners, Pixels, Styled, px}; -/// `rounded_*` one corner at a time is what gpui offers; this takes the whole -/// [`Corners`] the helpers below return, so a caller never has to spell out four -/// setters and risk transposing two of them. pub(crate) trait RoundedCorners: Styled + Sized { fn rounded_corners(self, corners: Corners<Pixels>) -> Self { self.rounded_tl(corners.top_left) @@ -49,44 +11,17 @@ pub(crate) trait RoundedCorners: Styled + Sized { impl<T: Styled + Sized> RoundedCorners for T {} -/// gpui's `rounded_lg`, resolved. The Tailwind scale is in `rems`, and every -/// radius here has to do arithmetic against a `px` border width, so the tracks -/// this module serves state their radius in pixels. The rem size is whatever -/// `gpui_component::Root::render` sets each frame from `Theme::font_size`, and -/// tty7 never overrides that from gpui-component's default `px(16.)` — so this -/// is the same 8px `rounded_lg()` paints. pub(crate) const TRACK_RADIUS: Pixels = px(8.); -/// gpui's `rounded_md`, resolved — see [`TRACK_RADIUS`]. pub(crate) const CARD_RADIUS: Pixels = px(6.); -/// The hairline every outlined track in the app draws (`border_1()`). pub(crate) const HAIRLINE: Pixels = px(1.); -/// The radius a child needs so its fill follows the *inside* of a rounded, -/// bordered track instead of squaring off the corner it sits in. -/// -/// The child's box starts one border-width in from the track's outer edge (that -/// is also where `Style::overflow_mask` puts the clip), so the concentric arc — -/// same centre, tighter by the border — has radius `outer - border`. Any larger -/// and the child bulges past the border and gets square-clipped again; any -/// smaller and a sliver of track shows between the border and the fill. -/// -/// Clamped at zero: a border wider than the radius leaves a square corner, -/// which is what a concentric inset actually gives you there. pub(crate) fn inner_radius(outer: Pixels, border: Pixels) -> Pixels { let inset = outer - border; if inset > px(0.) { inset } else { px(0.) } } -/// Which corners segment `i` of `count` owns in a horizontal track. -/// -/// The two end segments cap the track and take its rounding; everything between -/// them is square, because its corners are interior seams. A one-option track -/// is both ends at once, so it takes all four. -/// -/// `count == 0` never renders a segment, but returning square corners keeps the -/// function total rather than making callers guard it. pub(crate) fn segment_corners( i: usize, count: usize, @@ -95,9 +30,6 @@ pub(crate) fn segment_corners( ) -> Corners<Pixels> { let r = inner_radius(outer, border); let zero = px(0.); - // An index past the end is not an end cap — `i < count` keeps a degenerate - // or out-of-range call square rather than rounding a corner that has no - // segment to draw it. let first = i < count && i == 0; let last = i < count && i + 1 == count; Corners { @@ -108,9 +40,6 @@ pub(crate) fn segment_corners( } } -/// [`segment_corners`] for a vertical stack — a card whose children are bands -/// stacked top to bottom. The first band caps the top of the card, the last caps -/// the bottom; a lone band caps both. pub(crate) fn stack_corners( i: usize, count: usize, @@ -119,9 +48,6 @@ pub(crate) fn stack_corners( ) -> Corners<Pixels> { let r = inner_radius(outer, border); let zero = px(0.); - // An index past the end is not an end cap — `i < count` keeps a degenerate - // or out-of-range call square rather than rounding a corner that has no - // segment to draw it. let first = i < count && i == 0; let last = i < count && i + 1 == count; Corners { @@ -136,10 +62,6 @@ pub(crate) fn stack_corners( mod tests { use super::*; - /// The whole point: the child's arc is concentric with the border's inner - /// edge, so it is *strictly* tighter than the track's outer radius. A child - /// that reused the track's own radius would bulge past the border and hit - /// the square content mask again — the shape of issue #236. #[test] fn inner_radius_insets_by_the_border() { assert_eq!(inner_radius(px(8.), px(1.)), px(7.)); @@ -148,9 +70,6 @@ mod tests { assert!(inner_radius(CARD_RADIUS, HAIRLINE) < CARD_RADIUS); } - /// A border at least as wide as the radius eats the curve; the inset must - /// bottom out at a square corner rather than going negative, which gpui - /// would carry straight into the shader. #[test] fn inner_radius_never_goes_negative() { assert_eq!(inner_radius(px(1.), px(1.)), px(0.)); @@ -174,8 +93,6 @@ mod tests { assert_eq!((last.top_left, last.bottom_left), (zero, zero)); } - /// One option is both ends of the track, so it has to round all four — - /// otherwise a single-segment control squares off both sides. #[test] fn a_lone_segment_takes_every_corner() { let r = inner_radius(TRACK_RADIUS, HAIRLINE); @@ -185,8 +102,6 @@ mod tests { ); } - /// Total for the degenerate count rather than a panic: no segment renders, - /// so no corner is an end. #[test] fn an_empty_track_has_no_end_caps() { assert_eq!( @@ -195,8 +110,6 @@ mod tests { ); } - /// A stack caps top and bottom where a segmented track caps left and right — - /// same rule, rotated. #[test] fn a_stack_caps_its_first_and_last_band() { let r = inner_radius(CARD_RADIUS, HAIRLINE); @@ -210,7 +123,6 @@ mod tests { assert_eq!((bottom.bottom_left, bottom.bottom_right), (r, r)); assert_eq!((bottom.top_left, bottom.top_right), (zero, zero)); - // A collapsed card is one band, so its header owns every corner. assert_eq!(stack_corners(0, 1, CARD_RADIUS, HAIRLINE), Corners::all(r)); } } diff --git a/src/ui/scrollbar.rs b/src/ui/scrollbar.rs index 3878549a..7acb9dc9 100644 --- a/src/ui/scrollbar.rs +++ b/src/ui/scrollbar.rs @@ -1,41 +1,7 @@ -//! The overlay scrollbar the app's own scroll areas wear (issue #185). -//! -//! gpui's `overflow_y_scroll()` scrolls but paints nothing, so a long file tree -//! or tab list gave no hint that there was more content — or where in it you -//! were. gpui-component ships the [`Scrollbar`] element for exactly this; the -//! only thing missing was a house shape for hanging it off our containers. -//! -//! Why not gpui-component's own `overflow_y_scrollbar()` wrapper: it mints its -//! own `ScrollHandle` internally via `use_keyed_state`, which leaves nothing for -//! `scroll_to_item` to aim at. Our lists need programmatic scrolling (activating -//! a tab pulls its row into view), so the handle stays app-owned and the -//! scrollbar is layered on top of it. -//! -//! Appearance (thumb colour, whether it auto-hides) comes from the theme — see -//! the scrollbar block in [`crate::ui::theme::apply_theme`]. -//! -//! One behaviour worth knowing: while the bar is live it claims mouse-downs in -//! the 16px strip along the container's right edge, so the trailing few pixels -//! of a row stop being clickable there. That is inherent to an overlay -//! scrollbar (macOS's own behave the same way) and the reason the bar is only -//! permanently live on the platforms whose scrollbars are permanently visible. - use gpui::{AnyElement, ElementId, ScrollHandle, div, prelude::*}; use gpui_component::scroll::Scrollbar; use gpui_component::v_flex; -/// Wrap a scrolling column so a vertical scrollbar floats over its right edge. -/// -/// `scroll_area` must be the element that carries `.overflow_y_scroll()` and -/// `.track_scroll(handle)`; this only adds the positioned parent the scrollbar -/// measures against and the absolute layer it paints into. The scrollbar draws -/// *over* the content rather than reserving a gutter, so adopting it never -/// reflows the wrapped list. -/// -/// `id` names the scrollbar's element state (hover/drag/fade), so it must be -/// unique per scroll area — the helper can't fall back to `Location::caller` -/// the way [`Scrollbar::vertical`] does, since every call site would then share -/// this function's line. pub(crate) fn with_vertical_scrollbar( id: impl Into<ElementId>, scroll_area: impl IntoElement, diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 03805c3c..e7124b76 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -1,10 +1,3 @@ -//! The Settings tab UI (Cmd+,): a sidebar of sections beside a scrollable -//! content pane. This module owns the panel's *state types* and its *rendering* -//! only; the lifecycle (opening/closing the tab, committing the font family, -//! applying theme/font changes) lives in `app.rs`, where it can touch the -//! shell's tabs and panes. The render methods extend `Tty7App` from here so the -//! window shell stays focused on tab/pane orchestration. - use gpui::{ AnyElement, App, Context, Div, Entity, FontWeight, Image, ImageFormat, KeyDownEvent, MouseButton, SharedString, Stateful, Subscription, Window, div, img, prelude::*, px, relative, @@ -43,23 +36,6 @@ use crate::ui::presets; use crate::ui::rounding; use crate::ui::rounding::RoundedCorners as _; -/// Which section of the settings panel is currently selected in the sidebar. -/// Sections are named for the *object* being configured (the appearance, the -/// terminal, the window) — never for a property class like "Behavior", which -/// reads fine but predicts nothing about what's inside. -/// -/// Two of these were rearranged because the old split didn't survive contact -/// with a user asking "which page is that on?": -/// -/// * **Shell** used to be its own page holding three settings, and nothing -/// distinguished "the Terminal page" from "the Shell page" from the outside. -/// Its rows are now Terminal's first group — the program a pane launches is a -/// property of the terminal, not a peer of it. (It also freed the word -/// "Shell", which the menu bar was simultaneously using for its File menu.) -/// * **Input** is new. Completion, history search, the Option/Meta split and -/// selection/clipboard behaviour were scattered through the bottom of the -/// Terminal page under four headers; they're the app's most distinctive -/// surface and they now have a name you can look for. #[derive(Clone, Copy, PartialEq, Eq)] pub(crate) enum SettingsSection { Appearance, @@ -73,9 +49,6 @@ pub(crate) enum SettingsSection { } impl SettingsSection { - /// Every section, in nav order. The single source of truth for "what - /// sections exist" — [`best_matching_section`] used to carry its own - /// hand-written copy of this list and had silently fallen two behind. pub(crate) const ALL: [SettingsSection; 8] = [ SettingsSection::Appearance, SettingsSection::Terminal, @@ -87,8 +60,6 @@ impl SettingsSection { SettingsSection::About, ]; - /// A `&'static` label for `TTY7_PROFILE` aggregation, so each section's build - /// cost and rebuild rate report under their own line. fn profile_label(self) -> &'static str { match self { SettingsSection::Appearance => "settings:appearance", @@ -103,24 +74,15 @@ impl SettingsSection { } } -/// One searchable setting for the settings-search box: the row's display title, -/// the section it lives in, and a bag of extra keywords/synonyms so a search -/// lands even when the user's word isn't in the visible label. Matching is -/// case-insensitive substring over `title` + `keywords`. struct SearchEntry { section: SettingsSection, title: &'static str, keywords: &'static str, } -/// The static index the settings search matches against — one entry per notable -/// setting, mirroring the rows each `render_settings_*` builds. Keywords carry -/// synonyms the visible label omits (e.g. "meta" → the Option/Alt row, "color" -/// → the theme) so intent-based searches still resolve to the right section. fn settings_search_entries() -> &'static [SearchEntry] { use SettingsSection::*; &[ - // ── Appearance ────────────────────────────────────────────────────── SearchEntry { section: Appearance, title: "Theme", @@ -196,7 +158,6 @@ fn settings_search_entries() -> &'static [SearchEntry] { title: "ANSI colors", keywords: "palette 16 terminal colours theme", }, - // ── Terminal ──────────────────────────────────────────────────────── SearchEntry { section: Terminal, title: "Program", @@ -257,7 +218,6 @@ fn settings_search_entries() -> &'static [SearchEntry] { title: "Open files with", keywords: "links file editor command external app path line column", }, - // ── Input ─────────────────────────────────────────────────────────── SearchEntry { section: Input, title: "Tab completion", @@ -288,7 +248,6 @@ fn settings_search_entries() -> &'static [SearchEntry] { title: "Trim trailing spaces on copy", keywords: "clipboard whitespace copy", }, - // ── SSH ───────────────────────────────────────────────────────────── SearchEntry { section: Ssh, title: "Hosts", @@ -310,10 +269,6 @@ fn settings_search_entries() -> &'static [SearchEntry] { title: "Port forwarding", keywords: "ssh tunnel local remote dynamic socks forward rule", }, - // ── Agents ────────────────────────────────────────────────────────── - // Titles mirror `HookAgent::display_name()`, which is what each row is - // rendered with; the mechanism word (hooks/plugin/extension) lives in - // `keywords`. Pinned by `agent_rows_are_in_the_search_index`. SearchEntry { section: Agents, title: "Claude Code", @@ -344,7 +299,6 @@ fn settings_search_entries() -> &'static [SearchEntry] { title: "Grok Build", keywords: "agent integration hooks install xai grok build", }, - // ── Window & Tabs ─────────────────────────────────────────────────── SearchEntry { section: WindowTabs, title: "Startup window", @@ -363,9 +317,6 @@ fn settings_search_entries() -> &'static [SearchEntry] { SearchEntry { section: WindowTabs, title: "Confirm before closing the last window", - // Both spellings of the chord: the prompt this turns off is reached - // by ⌘W on macOS and Ctrl-W everywhere else, and the user types - // whichever one their own keyboard just used. keywords: "close quit confirm prompt dialog ask again warn last window cmd-w ctrl-w", }, SearchEntry { @@ -403,7 +354,6 @@ fn settings_search_entries() -> &'static [SearchEntry] { title: "Notify threshold", keywords: "notification alert seconds duration long command delay", }, - // ── Keybindings / About ───────────────────────────────────────────── SearchEntry { section: Keybindings, title: "Keybindings", @@ -422,14 +372,10 @@ fn settings_search_entries() -> &'static [SearchEntry] { ] } -/// Does this entry match the (already lowered, trimmed) query? Matches on the -/// visible title or any of its synonym keywords, so intent-based searches land. fn entry_matches(entry: &SearchEntry, query: &str) -> bool { entry.title.to_lowercase().contains(query) || entry.keywords.contains(query) } -/// How many of `section`'s settings match `query` — the `(N)` shown beside each -/// section link while a search is active. `query` must already be lowered/trimmed. pub(crate) fn section_match_count(section: SettingsSection, query: &str) -> usize { settings_search_entries() .iter() @@ -437,171 +383,75 @@ pub(crate) fn section_match_count(section: SettingsSection, query: &str) -> usiz .count() } -/// The section a search should jump to: the one with the most matches, ties -/// broken by nav order (the first section wins). `None` when nothing matches, so -/// the caller leaves the current selection alone. -/// -/// Driven by [`SettingsSection::ALL`] rather than a hand-written list: the old -/// literal here omitted SSH and Agents, so searching "claude" or "known hosts" -/// annotated the nav with a match count and then refused to go there. pub(crate) fn best_matching_section(query: &str) -> Option<SettingsSection> { SettingsSection::ALL .into_iter() .map(|s| (s, section_match_count(s, query))) .filter(|(_, n)| *n > 0) - // `>` (not `>=`) so an equal later section never displaces the earlier one. .reduce(|best, cur| if cur.1 > best.1 { cur } else { best }) .map(|(s, _)| s) } -/// The in-app color editor for the active *editable* theme: one color picker per -/// seed color (background/foreground/accent/cursor/selection) and per ANSI slot, -/// each wired to write its change straight back to the theme's YAML file. Rebuilt -/// by `Tty7App::rebuild_theme_editor` whenever the active theme changes, so it -/// always targets (and reflects) the theme on screen. pub(crate) struct ThemeEditor { - /// The id the pickers were built for (which theme they edit). #[allow(dead_code)] pub(crate) for_id: String, - /// Seed-color pickers: `(edit target, row label, picker state)`. pub(crate) seed: Vec<(ThemeEdit, String, Entity<ColorPickerState>)>, - /// One picker per ANSI slot 0–15. pub(crate) ansi: Vec<(ThemeEdit, String, Entity<ColorPickerState>)>, - /// Background-image opacity slider; present only while the theme has an - /// image (wired to `Tty7App::set_theme_image_opacity`). pub(crate) image_opacity_slider: Option<Entity<SliderState>>, pub(crate) _subs: Vec<Subscription>, } -/// Live state for the settings panel (Cmd+,). Holds the panel's focus owner -/// (so Esc closes it), the currently selected sidebar section, and the -/// font-family text input plus its commit subscriptions. pub(crate) struct SettingsState { pub(crate) focus_handle: gpui::FocusHandle, pub(crate) section: SettingsSection, - /// Live query for the settings search box in the nav header. While non-empty - /// the nav rail lists matching settings (across every section) instead of the - /// six section links; picking one jumps to its section. pub(crate) search: Entity<InputState>, pub(crate) font_select: Entity<SelectState<SearchableVec<String>>>, - /// Bold / italic face pickers. Their first row is the `FONT_DEFAULT_LABEL` - /// sentinel, meaning "reuse the primary face with synthesized emphasis". pub(crate) font_bold_select: Entity<SelectState<SearchableVec<String>>>, pub(crate) font_italic_select: Entity<SelectState<SearchableVec<String>>>, - /// Shell program override (empty = the platform default shell). pub(crate) shell_program_input: Entity<InputState>, - /// Shell launch arguments, space-separated (e.g. `-l`). pub(crate) shell_args_input: Entity<InputState>, - /// Custom working-directory path (used when the strategy is `Custom`). pub(crate) wd_path_input: Entity<InputState>, - /// Command template run when ⌘/Ctrl-clicking a file link (Links section). Empty - /// clears the override, restoring the built-in "open in default app". pub(crate) link_file_command_input: Entity<InputState>, - /// Mouse-scroll multiplier slider (Terminal section). pub(crate) scroll_slider: Entity<SliderState>, - /// Global window-opacity slider (Appearance's Window section). Shows the - /// effective value; dragging sets the config override. pub(crate) window_opacity_slider: Entity<SliderState>, - /// The color editor for the effective (on-screen) theme, or `None` when - /// that theme is read-only (a built-in / import). pub(crate) theme_editor: Option<ThemeEditor>, - /// Whether the theme picker panel is open beside the content pane - /// (Appearance section only). Toggled from the theme card(s). pub(crate) theme_panel_open: bool, - /// Which theme choice the open picker panel writes to (see [`ThemeSlot`]). - /// Set by the card that opened the panel. pub(crate) theme_panel_slot: ThemeSlot, - /// Live filter for the theme picker panel's list. pub(crate) theme_search: Entity<InputState>, - /// `Some` while a Keybindings row is capturing a new shortcut: the action - /// being rebound plus the live keystroke interceptor that swallows and - /// records the next keypress (see `Tty7App::start_recording_key`). pub(crate) recording: Option<Recording>, - /// A transient one-line note under the Keybindings header — e.g. after a - /// captured key was already taken and its previous owner was unbound. - /// Cleared when the next capture starts. pub(crate) rebinding_note: Option<String>, - /// The SSH-profile edit form, when a profile in the SSH section is being - /// added or edited. `None` shows just the saved-profile list. Its widgets - /// (inputs) are built lazily when a profile is selected and rebuilt (a fresh - /// input set) each time, so the section never carries N profiles' worth of - /// inputs up front. See `SshProfileForm`. pub(crate) ssh_form: Option<SshProfileForm>, - /// Which detail the SSH section's right (detail) pane is showing. The section - /// is a two-column master-detail: the left column lists profiles, and this - /// tracks the selected one. `Profile(id)` pairs with `ssh_form` (the loaded - /// edit form); `None` shows the empty state (the "pick a profile" hint plus - /// the two global security toggles). pub(crate) ssh_detail: SshDetail, - /// Live filter for the SSH master list. Non-empty narrows the list to hosts - /// whose name or address matches, and force-expands every group — results - /// hiding inside a collapsed group is the same as not finding them. pub(crate) ssh_filter: Entity<InputState>, - /// Group keys (see [`ssh_group_key`]) whose section in the master list is - /// collapsed. Empty = everything expanded. pub(crate) ssh_collapsed_groups: std::collections::HashSet<String>, - /// The `user@host[:port]` box in the SSH section's empty state. An empty - /// pane whose only content is "select something" wastes the widest column on - /// the page; connecting is what someone opening this section came to do. pub(crate) ssh_quick_connect: Entity<InputState>, - /// Which machine the Agents section is showing and acting on. - /// [`HostId::LOCAL`] until the user picks one of the connected remotes. pub(crate) agent_hooks_host: HostId, - /// Install state of each agent's hook integration on - /// [`Self::agent_hooks_host`]. Cached — captured when the panel opens, - /// re-read when the section or the machine is selected, and updated after - /// each install/uninstall — because reading it is a file read per agent, - /// and on a remote machine that is a round trip per agent. pub(crate) agent_hooks_states: AgentHooksView, - /// Discriminates the load whose answer is allowed to land. Switching - /// machines while a read is in flight would otherwise let the old - /// machine's rows arrive under the new machine's name. pub(crate) agent_hooks_seq: u64, - /// Outcome of the last Agents-section hook action (install summary or - /// error), shown under that agent's row. Replaced by the next action. pub(crate) agent_hooks_note: Option<(crate::core::agent_hooks::HookAgent, String)>, pub(crate) _subs: Vec<Subscription>, } -/// What Settings → Agents has to show for the machine it is pointed at. -/// -/// Three states rather than a `Vec` that is empty when it doesn't know: reading -/// a remote machine's install state is a round trip per agent, so "still asking" -/// and "asked, nothing installed" are genuinely different answers and rendering -/// them the same is how a page silently lies for a second. #[derive(Clone)] pub(crate) enum AgentHooksView { - /// The read is in flight. Loading, - /// One row per hook-capable agent, in - /// [`crate::core::agent_hooks::HookAgent::ALL`] order. Ready(Vec<AgentHookRow>), - /// The machine can't be acted on, and the sentence says which hop gave up - /// (a failure is a resting state, not a blank). Unavailable(String), } -/// One agent's row, as read off a particular machine. #[derive(Clone)] pub(crate) struct AgentHookRow { pub(crate) agent: crate::core::agent_hooks::HookAgent, pub(crate) state: crate::core::agent_hooks::HooksState, - /// The file the integration lives in *on that machine*, `~`-abbreviated. - /// Resolved in the background with the rest of the read — it depends on the - /// machine's own home directory and separator, which render cannot ask for. pub(crate) target: String, } -/// One entry in the Agents section's machine picker. #[derive(Clone)] pub(crate) struct AgentHooksMachine { pub(crate) host: HostId, pub(crate) label: String, } -/// The theme choice a picker card / the picker panel targets. `Manual` is the -/// single `Config::theme_preset` (sync-with-system off); `Light` / `Dark` are -/// the two follow-system slots (`Config::theme_preset_light` / `_dark`). #[derive(Clone, Copy, PartialEq, Eq)] pub(crate) enum ThemeSlot { Manual, @@ -609,30 +459,17 @@ pub(crate) enum ThemeSlot { Dark, } -/// The SSH section's right-pane selection (see [`SettingsState::ssh_detail`]). #[derive(Clone, Copy, PartialEq, Eq)] pub(crate) enum SshDetail { - /// Nothing selected — the right pane shows the quick-connect empty state. None, - /// The settings every host inherits. Its own row at the top of the master - /// list rather than a block pinned under the form: "each host starts from - /// these and can override one" is a thing the list's shape can say, and a - /// paragraph under an unrelated form cannot. Defaults, - /// A profile's edit form (paired with `ssh_form`, keyed by the profile id). Profile(Uuid), } -/// The master list's bucket for `profile.group`: imported aliases first, then -/// any user-defined group, then the ungrouped ones. The key is the raw `group` -/// value (`""` for ungrouped) so it can key the collapsed-set directly. fn ssh_group_key(p: &SshProfile) -> &str { p.group.as_deref().unwrap_or("") } -/// The header text for a group key. The import bucket is labelled by the file -/// it mirrors — `Imported from ssh_config` describes a past action, and this -/// group is a live link to a file the user edits elsewhere. fn ssh_group_label(key: &str) -> &str { match key { crate::core::ssh_config::IMPORTED_GROUP => "~/.ssh/config", @@ -641,8 +478,6 @@ fn ssh_group_label(key: &str) -> &str { } } -/// Sort rank for a group key: imported first, ungrouped last, custom groups in -/// between (alphabetical among themselves). fn ssh_group_rank(key: &str) -> u8 { match key { crate::core::ssh_config::IMPORTED_GROUP => 0, @@ -651,10 +486,6 @@ fn ssh_group_rank(key: &str) -> u8 { } } -/// Whether a profile survives the master list's filter. `query` is already -/// trimmed and lowercased. Matches the name and the address separately rather -/// than the rendered `user@host:port` line, so typing a port still finds the -/// host but typing `@` doesn't match everything. fn ssh_row_matches(p: &SshProfile, query: &str) -> bool { if query.is_empty() { return true; @@ -663,40 +494,25 @@ fn ssh_row_matches(p: &SshProfile, query: &str) -> bool { hit(&p.name) || hit(&p.host) || hit(&p.user) || hit(&p.port.to_string()) } -/// The live edit-form state for one SSH profile, folded into Settings → SSH. -/// A single reusable input set, rebuilt (via `Tty7App::ssh_form_load`) each time -/// a profile is selected. Edits are committed to `Config::ssh_profiles` only on -/// Save, so the form can be abandoned freely. Mirrors the four-core-fields + -/// collapsible jump / forwards / advanced disclosure the old standalone editor -/// exposed. pub(crate) struct SshProfileForm { - /// The profile id being edited. A *new* (unsaved) profile carries a freshly - /// minted id here and is only written to config on Save. editing: Uuid, - /// The group / credential_ref carried over from the profile being edited, so - /// a Save round-trips fields the form doesn't expose. carry_group: Option<String>, carry_credential_ref: Option<CredentialRef>, - // Section expansion (progressive disclosure). show_jump: bool, show_forwards: bool, show_advanced: bool, - // Core fields. name: Entity<InputState>, host: Entity<InputState>, port: Entity<InputState>, user: Entity<InputState>, auth: AuthMode, - // Jump host (a profile name; empty = none). jump: Entity<InputState>, - // Port forwards, one row of inputs per rule (see [`ForwardRuleForm`]). forwards: Vec<ForwardRuleForm>, - // Advanced text inputs. identity_files: Entity<InputState>, proxy_command: Entity<InputState>, socks: Entity<InputState>, @@ -711,7 +527,6 @@ pub(crate) struct SshProfileForm { connect_timeout: Entity<InputState>, login_scripts: Entity<InputState>, - // Advanced booleans / tri-states. agent_forward: bool, x11: bool, skip_banner: bool, @@ -719,17 +534,9 @@ pub(crate) struct SshProfileForm { verify_host_keys: Option<bool>, warn_on_close: Option<bool>, - /// Keeps the inputs' change subscriptions alive for this form; dropped (and - /// re-created) whenever the form is rebuilt for another profile. _subs: Vec<Subscription>, } -/// One port-forward rule as live inputs. -/// -/// The whole set used to be a single multi-line text box in which each rule had -/// to be typed as `L bind_host:port target_host:port [description]` — a syntax -/// nothing on the page taught, and the only field in this form that could -/// silently drop what you entered (an unparsable line was skipped on save). pub(crate) struct ForwardRuleForm { pub(crate) kind: ForwardKind, pub(crate) bind_host: Entity<InputState>, @@ -740,9 +547,6 @@ pub(crate) struct ForwardRuleForm { } impl ForwardRuleForm { - /// Read the row back into a rule, or `None` when it is too incomplete to - /// connect: a listener needs a port, and everything but Dynamic needs a - /// target. The UI flags such a row rather than dropping it quietly. fn collect(&self, cx: &App) -> Option<ForwardRule> { let val = |e: &Entity<InputState>| e.read(cx).value().trim().to_string(); let bind_port: u16 = val(&self.bind_port).parse().ok().filter(|p| *p > 0)?; @@ -765,8 +569,6 @@ impl ForwardRuleForm { }) } - /// Whether the row has anything typed in it at all. An untouched row added - /// by "Add rule" is not an error — it just hasn't been filled in yet. fn is_blank(&self, cx: &App) -> bool { [ &self.bind_host, @@ -780,36 +582,19 @@ impl ForwardRuleForm { } } -/// In-progress capture of a new shortcut for one action (click a Keybindings -/// row). The interceptor lives here so it stays active only while recording; -/// dropping it (capture done / Esc) removes the key swallow. pub(crate) struct Recording { - /// The action name whose shortcut is being captured. pub(crate) action: String, - /// The chords captured so far, each a config spec (e.g. `["ctrl-b", "x"]`). - /// A single chord is the common case; more than one records a sequence like - /// the tmux preset's `ctrl-b x`. Committed (joined by spaces) after a short - /// pause with no further keys. pub(crate) chords: Vec<String>, - /// Keeps the keystroke interceptor alive for the duration of the capture. pub(crate) _intercept: Subscription, } -/// Sentinel first row in the bold/italic font pickers meaning "no distinct face -/// — reuse the primary family with synthesized emphasis". Chosen to be an -/// unlikely real font name. pub(crate) const FONT_DEFAULT_LABEL: &str = "Default (match primary)"; -/// How the link-click modifier is spelled in the Links copy. It's gpui's -/// `secondary` (see `Modifiers::secondary`), so it must read ⌘ on macOS and -/// Ctrl on Windows/Linux — same split `key_tokens` uses for keycaps. #[cfg(target_os = "macos")] const LINK_MODIFIER_LABEL: &str = "⌘"; #[cfg(not(target_os = "macos"))] const LINK_MODIFIER_LABEL: &str = "Ctrl"; -/// Humanize a CamelCase action name for display: "CloseActiveTab" → "Close -/// Active Tab". pub(crate) fn humanize_action(action: &str) -> String { let mut out = String::new(); for (i, ch) in action.chars().enumerate() { @@ -821,9 +606,6 @@ pub(crate) fn humanize_action(action: &str) -> String { out } -// ── SSH-profile form parsing helpers (moved here from the standalone editor) ── - -/// Parse a `host:port` fragment into a [`HostPort`], or `None` when empty/blank. fn parse_host_port(s: &str) -> Option<HostPort> { let s = s.trim(); if s.is_empty() { @@ -835,14 +617,12 @@ fn parse_host_port(s: &str) -> Option<HostPort> { } } -/// Render a `HostPort` back to `host:port` for the form (empty string for `None`). fn host_port_text(hp: &Option<HostPort>) -> String { hp.as_ref() .map(|h| format!("{}:{}", h.host, h.port)) .unwrap_or_default() } -/// Split a comma/whitespace list into non-empty items (algorithms, etc.). fn split_list(s: &str) -> Vec<String> { s.split([',', ' ', '\n']) .map(str::trim) @@ -851,7 +631,6 @@ fn split_list(s: &str) -> Vec<String> { .collect() } -/// Split a multiline input into non-empty trimmed lines. fn split_lines(s: &str) -> Vec<String> { s.lines() .map(str::trim) @@ -860,8 +639,6 @@ fn split_lines(s: &str) -> Vec<String> { .collect() } -/// The five inputs of a forward row, in tab order. Used to subscribe the row -/// (and nothing else needs to know the field names). fn forward_row_inputs(row: &ForwardRuleForm) -> [&Entity<InputState>; 5] { [ &row.bind_host, @@ -872,8 +649,6 @@ fn forward_row_inputs(row: &ForwardRuleForm) -> [&Entity<InputState>; 5] { ] } -/// Build one forward row's inputs, seeded from `rule`. Port `0` seeds an empty -/// box rather than a literal `0` — the stored default for "unset". fn seed_forward_row( window: &mut Window, cx: &mut Context<Tty7App>, @@ -890,8 +665,6 @@ fn seed_forward_row( } } -/// [`seed_input`] with a placeholder — the forward rows carry no labels of -/// their own, so the hint text is what says which box is which. fn seed_hinted( window: &mut Window, cx: &mut Context<Tty7App>, @@ -906,8 +679,6 @@ fn seed_hinted( }) } -/// Build an `InputState` seeded with `value` (single- or multi-line). A free -/// function so `window` auto-reborrows cleanly at each call site. fn seed_input( window: &mut Window, cx: &mut Context<Tty7App>, @@ -923,15 +694,11 @@ fn seed_input( } impl Tty7App { - /// Build the settings tab body: a fixed left sidebar (section nav) beside a - /// scrollable content area for the selected section. Esc closes the tab. pub(crate) fn render_settings( &self, window: &mut Window, cx: &mut Context<Self>, ) -> impl IntoElement + use<> { - // Copy the palette out (Hsla is Copy) so this borrow doesn't outlive into - // `render_settings_search_results` below, which needs `cx` mutably. let theme = cx.theme(); let (background, foreground, header_muted) = (theme.background, theme.foreground, theme.muted_foreground); @@ -943,25 +710,14 @@ impl Tty7App { s.theme_panel_open, s.search.clone(), ), - None => return div(), // not a settings tab; nothing to render + None => return div(), }; - // Live settings-search query (trimmed, lowered). Non-empty swaps the six - // section links for a cross-section list of matching settings. let query = search.read(cx).value().trim().to_lowercase(); - // The theme picker panel only makes sense beside its own page. let show_theme_panel = theme_panel_open && section == SettingsSection::Appearance; - // `TTY7_PROFILE`: time this section's whole element build and, via the - // aggregated call rate, expose whether the panel is rebuilding once (on a - // real change) or in a tight `notify` loop. Labelled per section so - // Appearance's cost stands apart from the lighter pages. let prof = crate::ui::perf::enabled() .then(|| (std::time::Instant::now(), section.profile_label())); - // Sidebar nav item that activates a section on click. While a search is - // active it also carries a trailing `(N)` count of that section's matching - // settings — the full section nav stays put and is annotated with - // per-section hit counts, rather than collapsing into a flat result list. let nav_item = |label: &'static str, target: SettingsSection, icon: Icon| { let view = cx.entity(); let count = if query.is_empty() { @@ -987,17 +743,12 @@ impl Tty7App { } }; - // The section links stay put during search — only their `(N)` suffixes - // change — so the nav never collapses out from under the user. let nav_body = SidebarMenu::new() .child(nav_item( "Appearance", SettingsSection::Appearance, Icon::new(IconName::Palette), )) - // The `>_` prompt glyph for Terminal, which now owns the shell - // program; the "Aa" glyph is the closest thing the icon set has to - // a keyboard for Input. .child(nav_item( "Terminal", SettingsSection::Terminal, @@ -1023,16 +774,11 @@ impl Tty7App { SettingsSection::WindowTabs, Icon::new(IconName::WindowRestore), )) - // The icon set ships no keyboard glyph; CaseSensitive ("Aa") - // is the closest key-ish cue available. .child(nav_item( "Keybindings", SettingsSection::Keybindings, Icon::new(IconName::CaseSensitive), )) - // Not `IconName::Info`: `icons/info.svg` is overridden app-wide with - // the detail panel's "panel with two lines" glyph, which reads as a - // document, not as *About*. This row keeps the circled `i`. .child(nav_item( "About", SettingsSection::About, @@ -1041,20 +787,12 @@ impl Tty7App { let sidebar = Sidebar::new("settings-sidebar") .collapsible(SidebarCollapsible::None) - // Match the tab sidebar's default width (`default_sidebar_width`, 220px) - // so toggling the settings overlay over the vertical rail doesn't shift - // the left column — narrower than the stock 255px too, which three short - // items don't need and which reads more native/less hollow. .w(px(220.)) .header( v_flex() .w_full() .px_2() .gap_2() - // Reserve the title-bar height at the top so the nav rail - // reaches the very top of the window (the macOS traffic lights - // rest on its surface) with the header clearing them — matching - // the tab rail's top zone. .pt(px(crate::ui::app::TITLE_BAR_HEIGHT)) .pb_1() .child( @@ -1064,30 +802,10 @@ impl Tty7App { .text_color(header_muted) .child("SETTINGS"), ) - // Settings search: type a setting or a synonym and each section - // below shows how many of its settings match, with the - // best-matching section auto-selected (see the search input's - // change subscription in `app.rs`). Styled like the tab sidebar's - // search — a leading magnifier + a borderless input sitting flush - // on the rail surface, no box, so the header reads clean. .child( h_flex() .items_center() - // Laid out to land on the nav rows below it rather than - // on the header's own inset: a `SidebarMenuItem` is - // `p_2` + a 16px icon + `gap_x_2`, so its label starts - // 32px into the rail. Matching that takes all three of - // these — the magnifier at the rows' 16px (not `small`, - // which is 14 and left the glyph reading a size below - // the column it heads), the same 8px gap after it, and - // `pl_0` on the input, which otherwise adds `input_px` - // (12px at the default size) whether or not it draws a - // box. Without them the placeholder sat 6px right of - // every label under it. .gap_2() - // Stock magnifier, not tty7's: this page's glyphs run at - // 16px, where the detail panel's redraw reads thin and - // its handle stubby. See `assets::STOCK_PREFIX`. .child( Icon::empty() .path("stock/icons/search.svg") @@ -1115,19 +833,6 @@ impl Tty7App { SettingsSection::About => self.render_settings_about(cx), }; - // One continuous, flat sheet (no cards) — one document: bold section - // headers and full-width rules carry the structure, so settings read as a - // unified document rather than a widget floating in empty space. - // - // The SSH section is the exception: it is its own two-column master-detail - // that fills the pane height, with each column owning its scroll — so it - // bypasses the shared padded, single-scroll wrapper (which would otherwise - // give the whole section one outer scrollbar and no definite height for the - // columns to fill) and is dropped in flush instead. - // A `flex_1` pane still defaults to `min-width: auto`, so on a narrow - // window it refuses to shrink below its content's intrinsic width and - // shoves the fixed 300px theme panel (and its close `×`) off the right - // edge. `min_w_0` lets the pane yield so the panel stays fully on-screen. let content_pane = if section == SettingsSection::Ssh { v_flex() .id("settings-content") @@ -1148,10 +853,6 @@ impl Tty7App { div() .px_10() .py_8() - // Cap the column tight enough (640px) that a row's - // right-aligned control stays visually paired with its - // label — the cap is what makes `settings_row`'s - // space-between layout safe. .child(div().w_full().max_w(px(640.)).child(content)), ) }; @@ -1169,17 +870,8 @@ impl Tty7App { this.close_settings(window, cx); } })) - // The Sidebar draws its own right border; no wrapper border here, or - // the two stack into one thick rule. .child(sidebar) .child(content_pane) - // The overlay covers the real title bar, so the window's own drag - // region is buried. Restore it: a transparent strip across the top - // band (the height the title bar reserved) that moves the window on - // drag and zooms it on double-click, exactly like the title bar it - // stands in for. `window_move_gesture` owns the gesture and the - // reasoning behind it; the #221 failure was worst here, because this - // strip *is* the whole top band with no immune caption beside it. .child( crate::ui::app::window_move_gesture( div() @@ -1196,20 +888,8 @@ impl Tty7App { .on_double_click(|_, window, _| window.titlebar_double_click()), ) .when(show_theme_panel, |r| r.child(self.render_theme_panel(cx))) - // Close affordance at the page's top-right corner (Esc and Cmd+, also - // close) — the intuitive "close this page" spot, and clear of the - // macOS traffic lights (top-left) and the window controls' zone. - // Hidden while the theme panel is open: it docks at the same right edge - // and carries its own ✕, so keeping this one would stack two ✕ there. .when(!show_theme_panel, |r| { r.child( - // A full chrome tile, because it stands in the same corner as - // the title bar's own: a `small` icon button is 24px, which - // reads undersized next to the 34px window-control tiles this - // spot belongs to. `right` is the window-control zone's own - // margin rather than the content inset — what this has to - // clear here is the controls, not a text column. `top` - // centres it in the title bar's band. div() .absolute() .top(px((TITLE_BAR_HEIGHT - TILE_SIZE) / 2.)) @@ -1219,9 +899,6 @@ impl Tty7App { Button::new("settings-close") .icon(Icon::new(IconName::Close)) .ghost() - // Sizing the button, not the icon: `Button::render` - // overwrites whatever size the icon was handed. - // See `BUTTON_ICON_SCALE`. .with_size(px( TILE_GLYPH_LINE / crate::ui::tab_strip::BUTTON_ICON_SCALE )) @@ -1241,8 +918,6 @@ impl Tty7App { root } - /// Just the styled section title (no margin). Shared by `section_header` and - /// `section_intro` so the two can never drift in size, weight, or color. fn header_text(&self, title: &str, cx: &Context<Self>) -> Div { div() .text_base() @@ -1251,19 +926,10 @@ impl Tty7App { .child(title.to_string()) } - /// A bold section header that introduces a group of settings. - /// With no cards, the header *is* the unit of grouping — it tells the eye - /// where one set of related controls begins. pub(crate) fn section_header(&self, title: &str, cx: &Context<Self>) -> Div { self.header_text(title, cx).mb_4() } - /// A section header paired with its one-line intro as a single unit: the - /// subtitle sits tight under the title (`gap_1`) and the block leaves a - /// consistent gap before the first control (`mb_4`). Replaces the ad-hoc - /// "header, then a loose paragraph" pattern that stranded the subtitle 16px - /// below its own title (glued instead to the controls) and used a different - /// bottom margin — `mb_1` here, `mb_2` there — in every section. fn section_intro(&self, title: &str, desc: impl Into<String>, cx: &Context<Self>) -> Div { v_flex() .mb_4() @@ -1277,22 +943,10 @@ impl Tty7App { ) } - /// A full-width hairline between sections, so the page reads as one - /// continuous sheet rather than stacked boxes. pub(crate) fn section_rule(&self, cx: &Context<Self>) -> Div { div().h(px(1.)).my_7().bg(cx.theme().border) } - /// One labelled settings row, shared by every section: title + description - /// on the left, control right-aligned. Space-between is safe here only - /// because both hosting columns are capped — the main content column at - /// 640px and the SSH detail pane at 720px — so the two never stretch apart - /// into a dead gap the way they did on an uncapped pane; widen either cap - /// and every row inside it stretches with it. A soft full-row - /// hover fill makes each row read as one scannable unit — the same quiet - /// highlight the sidebar and menus use; negative side margins let that fill - /// bleed past the text edge while labels stay aligned with the section - /// headers above. pub(crate) fn settings_row( &self, label: impl Into<String>, @@ -1322,8 +976,6 @@ impl Tty7App { .text_color(theme.foreground) .child(label.into()), ) - // Rows without a description (the theme color editor) stay - // single-line instead of carrying an empty text child. .when(!desc.is_empty(), |col| { col.child( div() @@ -1336,30 +988,6 @@ impl Tty7App { .child(h_flex().flex_shrink_0().child(control)) } - /// A segmented control for a small set of mutually-exclusive options — the - /// refined stand-in for a raw row of radio circles, which read as an unstyled - /// form beside the sheet's tuned steppers and chips. Joined segments in a - /// single outlined track, one of them filled, speak the same segmented - /// language as the −│value│+ stepper right beside them; the 24px height - /// matches the selects in the same rows. `selected` is the active index; - /// `on_pick` fires with the newly chosen one. - /// - /// # Why this is hand-rolled - /// - /// It used to be gpui-component's `ButtonGroup::outline()` with - /// `Button::selected`, and that is what issue #197 was reported against. That - /// path derives the selected segment's fill from `Theme::input` and gives it - /// the *same* border and the *same* label color as its unselected siblings — - /// so the entire selection signal was one fill, and that fill came from a - /// grey unrelated to the active theme. On Dracula it measured **1.03:1**. - /// - /// `Theme::input` is now themed (see `ui::theme::apply_theme`), which fixes - /// the stock control for inputs and selects. But a segmented control is the - /// one place in the app where several options sit visibly side by side with a - /// *static* selection, so it is precisely where a fill alone is not enough - /// (see `presets::Surface`) — and the stock button exposes no way to vary the - /// label's weight. Owning the 30 lines is cheaper than a fork patch, and it - /// puts the control on the same ladder every hand-rolled surface reads. pub(crate) fn segmented( &self, id: impl Into<SharedString>, @@ -1372,11 +1000,6 @@ impl Tty7App { self.segmented_on(sf, id, options, selected, cx, on_pick) } - /// [`Self::segmented`] for a control that does *not* sit on the settings - /// sheet. The track paints its own opaque ground, so it has to be told which - /// one: dropped on the right panel's sunk rail, a window-surface track reads - /// as a faintly darker box cut out of the column it sits in — and every rung - /// above it was derived against the wrong ground. pub(crate) fn segmented_on( &self, sf: presets::Surface, @@ -1387,8 +1010,6 @@ impl Tty7App { on_pick: impl Fn(&mut Self, usize, &mut Window, &mut Context<Self>) + 'static, ) -> AnyElement { let border = cx.theme().border; - // Taken by name rather than as a `&'static str` so per-row controls (the - // forward rules) can carry an id derived from their index. let id: SharedString = id.into(); let on_pick = std::rc::Rc::new(on_pick); let count = options.len(); @@ -1398,30 +1019,14 @@ impl Tty7App { .rounded(rounding::TRACK_RADIUS) .border_1() .border_color(border) - // The track paints its own ground rather than letting the sheet show - // through. Every rung of the ladder was derived against this colour, - // so painting it is what makes those ratios true — a control that - // leaves its ground to whatever it happens to be composited over is - // the shape of the bug this whole change is about. .bg(gpui::rgb(sf.base)) - // A backstop for content overflow, and nothing more. This used to be - // what shaped the end segments' fills to the track's rounding, and it - // cannot do that: gpui's overflow mask is a square, unantialiased - // scissor (issue #236, see `ui::rounding`). The segments carry their - // own radii below. .overflow_hidden() .children(options.iter().enumerate().map(|(i, label)| { let active = i == selected; let on_pick = on_pick.clone(); - // The two end segments cap the track, so their fills have to draw - // the corner themselves — one border-width tighter than the - // track's own radius, so the arc nests inside the border instead - // of bulging past it into the square clip. let corners = rounding::segment_corners(i, count, rounding::TRACK_RADIUS, rounding::HAIRLINE); h_flex() - // A per-segment id keeps each one unique across the several - // segmented controls on the page. .id(gpui::ElementId::NamedInteger(id.clone(), i as u64)) .items_center() .justify_center() @@ -1430,14 +1035,7 @@ impl Tty7App { .text_sm() .cursor_pointer() .rounded_corners(corners) - // Hairlines *between* segments only — the track already owns - // its outer edge, and a border on the first segment would - // double it. .when(i > 0, |s| s.border_l_1().border_color(border)) - // Both channels, every time. The fill locates the selection in - // the row; the label color and weight say it is the one — and - // keep saying it on a translucent window, where the fill is - // washing over whatever is behind the sheet. .when(active, |s| { s.bg(gpui::rgb(sf.selected)) .text_color(gpui::rgb(sf.text_selected)) @@ -1447,8 +1045,6 @@ impl Tty7App { s.text_color(gpui::rgb(sf.text_resting)) .hover(|h| h.bg(gpui::rgb(sf.hover))) }) - // Pressed reads past selected, so pushing the segment that is - // already chosen still acknowledges the click. .active(|s| s.bg(gpui::rgb(sf.pressed))) .child(*label) .on_click(cx.listener(move |this, _, window, cx| { @@ -1458,14 +1054,10 @@ impl Tty7App { .into_any_element() } - /// Appearance section: theme, font size, font family. fn render_settings_appearance(&self, cx: &mut Context<Self>) -> AnyElement { let theme = cx.theme(); let foreground = theme.foreground; let border = theme.border; - // Hover comes off the ladder; `stepper_bg` stays a soft resting tint — - // it decorates a container rather than signalling a state, which is the - // one job an alpha-multiplied grey is still fine for. let hover_bg = gpui::rgb(cx.global::<presets::Surfaces>().window.hover); let stepper_bg = theme.secondary.opacity(0.35); let font_size = self.font_size; @@ -1488,18 +1080,6 @@ impl Tty7App { .any(|(tag, value)| tag == "liga" && *value != 0) }); - // Unified −/value/+ stepper plus a quiet Reset. `slot` is the glyph's - // place in the three-slot track (−│value│+): it draws the internal - // hairline, and — because a hover fill in an end slot would otherwise - // square off the track's corner (issue #236) — the corner radii. - // - // `h_full` rather than `py_1` is load-bearing for that second job. A - // padded, auto-height glyph box measures 31px (14px text × gpui's φ line - // height, plus 8px of padding) against a 22px content box, so the track's - // `items_center` centres it and `overflow_hidden` crops the overhang — - // the fill still reaches the corner, but the box's own rounded corner is - // 4½px outside the visible strip, where it does nothing. Pinning the box - // to the track's content height puts the arc back where the corner is. let step = move |id: &'static str, glyph: &'static str, slot: usize| { let corners = rounding::segment_corners(slot, 3, rounding::TRACK_RADIUS, rounding::HAIRLINE); @@ -1517,15 +1097,7 @@ impl Tty7App { .hover(|h| h.bg(hover_bg)) .child(glyph) }; - // One shared height for every small control in this section (matches - // gpui-component's own Size::Small button height) so the stepper pill - // and the font-family select sit at the same visual weight instead of - // each defaulting to its own padding. let control_h = px(24.); - // The −│value│+ pill plus its quiet Reset — one shape shared by the - // font-size and line-height rows; callers hand in the wired buttons. - // Reset sits *before* the pill: with controls right-aligned, the pill - // holds the row's hard right edge and the quiet action tucks inboard. let stepper_row = move |dec: Stateful<Div>, value: String, inc: Stateful<Div>, reset: Button| { h_flex() @@ -1540,16 +1112,11 @@ impl Tty7App { .bg(stepper_bg) .border_1() .border_color(border) - // Overflow backstop only — `step` rounds its own - // corners, because this clip is square (see - // `ui::rounding`). .overflow_hidden() .child(dec) .child( div() .min_w(px(40.)) - // Hairline on the value's left edge so both internal - // seams read (−│value│+); the `+` supplies the right one. .border_l_1() .border_color(border) .py_1() @@ -1592,17 +1159,12 @@ impl Tty7App { .on_click(cx.listener(|this, _, _w, cx| this.reset_line_height(cx))), ); - // One font dropdown, shared shape for primary / bold / italic pickers. let font_dropdown = |state: &Entity<SelectState<SearchableVec<String>>>| { Select::new(state) .small() .w(px(180.)) .h(control_h) .search_placeholder("Search fonts…") - // Cap the popup's own height so browsing doesn't dump the - // OS's entire font catalog on screen at once — it just - // scrolls from here. Every font is still in the list and - // reachable by typing; this only trims what's shown. .menu_max_h(px(224.)) .into_any_element() }; @@ -1633,8 +1195,6 @@ impl Tty7App { this.set_cursor_style(style, cx); }, ); - // Blink lives here beside the shape — one Cursor home, not "shape is - // appearance, blink is behavior" split across two pages. let blink_switch = crate::ui::theme::switch("cursor-blink", cx) .checked(cursor_blink) .on_click(cx.listener(|this, on: &bool, _w, cx| this.set_cursor_blink(*on, cx))) @@ -1647,9 +1207,6 @@ impl Tty7App { cx, )) .child(self.render_theme_selection(cx)) - // Custom-theme management (duplicate / edit colors / open folder) is - // *about* themes, so it lives with the picker rather than stranded at - // the foot of the page after Cursor. .child(self.render_custom_themes(cx)) .child(self.section_rule(cx)) .child(self.render_window_section(cx)) @@ -1708,12 +1265,6 @@ impl Tty7App { .into_any_element() } - /// Window section (Appearance): the global opacity slider and blur switch - /// that apply to every theme, then the inactive-pane dimming switch. The - /// first two are config *overrides* — until touched they follow the active - /// theme's own `opacity`/`blur`, and "Follow theme" clears them back to that - /// state; the dimming switch is a plain flag no theme carries a value for, - /// so it sits below that button and "Follow theme" leaves it alone. fn render_window_section(&self, cx: &mut Context<Self>) -> AnyElement { let Some(slider) = self .active_settings() @@ -1753,9 +1304,6 @@ impl Tty7App { .into_any_element(); v_flex() - // Not "Window": Settings → Window & Tabs owns that word for the - // window's lifecycle, and two groups called Window on two pages is - // how a user ends up on the wrong one. .child(self.section_header("Transparency", cx)) .child(self.settings_row( "Opacity", @@ -1770,8 +1318,6 @@ impl Tty7App { blur_switch, cx, )) - // Only offered while an override is active; otherwise the values - // already follow the theme and the button would be a no-op. .when(overridden, |this| { this.child( h_flex().mt_2().child( @@ -1784,8 +1330,6 @@ impl Tty7App { ), ) }) - // Below "Follow theme", which resets the two rows above it and not - // this one — a plain setting with no theme value behind it. .child(self.settings_row( "Dim inactive panes", "Fade unfocused panes in a split so the active one stands out.", @@ -1795,9 +1339,6 @@ impl Tty7App { .into_any_element() } - /// Custom themes section. On an editable theme, the color editor; on a - /// read-only built-in / import, a "Duplicate to edit" button that forks it - /// into an editable file. The folder button is always available. fn render_custom_themes(&self, cx: &mut Context<Self>) -> AnyElement { let editor = self.active_settings().and_then(|s| s.theme_editor.as_ref()); @@ -1807,7 +1348,6 @@ impl Tty7App { .on_click(cx.listener(|this, _, _w, cx| this.open_themes_folder(cx))); if let Some(editor) = editor { - // Snapshot the picker handles so the render borrow of `self` ends. let seed: Vec<_> = editor .seed .iter() @@ -1820,8 +1360,6 @@ impl Tty7App { .collect(); let image_opacity_slider = editor.image_opacity_slider.clone(); - // The theme's current image, for the filename label and the - // opacity readout (the slider owns its own thumb position). let theme = presets::by_id(cx, &crate::ui::theme::effective_preset_id(cx)); let image = theme.image.clone(); let image_name = image.as_ref().map(|i| { @@ -1915,8 +1453,6 @@ impl Tty7App { .into_any_element(); } - // Read-only theme (built-in or import): offer to duplicate it into an - // editable copy, plus the folder affordance. v_flex() .mt_5() .child(self.section_intro( @@ -1929,9 +1465,6 @@ impl Tty7App { h_flex() .gap_3() .child( - // Plain (not `.primary()`): a solid near-black fill reads - // far too heavy against this soft, mostly-outline sheet — - // it matches the "Open themes folder" button beside it. Button::new("duplicate-theme") .label("Duplicate to edit") .small() @@ -1944,9 +1477,6 @@ impl Tty7App { .into_any_element() } - /// One color-editor row: a label paired with its picker. The picker's own - /// `Change` event (wired in `rebuild_theme_editor`) writes the edit to the - /// theme file, so the row itself is purely presentational. fn render_theme_color_row( &self, label: String, @@ -1957,23 +1487,12 @@ impl Tty7App { self.settings_row(label, "", control, cx) } - /// SSH section: saved connection profiles plus the global security toggles - /// (host-key verification default and warn-on-close; a per-profile override - /// still wins where set). - /// - /// A two-column master-detail (like the theme picker): the **left** column is - /// a fixed-width, self-scrolling master — Import / Add on top, then the profile - /// list; the **right** column is the flex-1, self-scrolling detail pane showing - /// the selected profile's edit form (or a "pick a profile" hint) with the - /// global security defaults always below. Selection is tracked in - /// [`SettingsState::ssh_detail`]. fn render_settings_ssh(&self, cx: &mut Context<Self>) -> AnyElement { let border = cx.theme().border; h_flex() .size_full() .items_start() .child( - // LEFT (master): fixed width, its own scroll, a right divider. v_flex() .id("ssh-master") .flex_shrink_0() @@ -1985,15 +1504,12 @@ impl Tty7App { .child(self.render_ssh_master(cx)), ) .child( - // RIGHT (detail): flex-1, its own scroll. v_flex() .id("ssh-detail") .flex_1() .h_full() .overflow_y_scroll() .child( - // Clear the title-bar drag strip / close ✕ up top, and cap - // the detail width so the form stays readable on wide panes. div() .pt(px(crate::ui::app::TITLE_BAR_HEIGHT)) .px_8() @@ -2009,17 +1525,8 @@ impl Tty7App { .into_any_element() } - /// The left (master) column: a Hosts header carrying the add / overflow - /// affordances, a live filter, then the list — `Defaults` pinned on top and - /// every saved host bucketed by group into a collapsible section. - /// - /// The filter leads, and Add / Import shrank to icon affordances, because - /// that is the order the column is actually used in: past a dozen hosts, - /// finding one *is* the job. Two full-width buttons on top read as a page - /// header while pushing the content that matters below the fold. fn render_ssh_master(&self, cx: &mut Context<Self>) -> AnyElement { let muted = cx.theme().muted_foreground; - // Rows in this list paint on the settings sheet, i.e. the window surface. let sf = cx.global::<presets::Surfaces>().window; let profiles = cx.global::<Config>().ssh_profiles.clone(); let (filter, collapsed, detail) = match self.active_settings() { @@ -2031,78 +1538,53 @@ impl Tty7App { None => return div().into_any_element(), }; let query = filter.read(cx).value().trim().to_lowercase(); - // Which profiles have a connected pane right now: each row's dot, and the - // count a collapsed group header keeps showing. let live = self.live_ssh_profiles(cx); let menu_app = cx.entity().downgrade(); - let header = v_flex() - .gap_2() - .child( - // The same weight every other section leads with. Tried as the nav - // rail's small-caps label instead, and it read as a sub-header of - // the nav rather than the title of a column: every other page in - // Settings opens with a title at this size, and this column is - // where this page starts. So it gets the line to itself. - self.header_text("Hosts", cx), - ) - .child( - // One toolbar row: the filter, then the two affordances that act on - // the list. The borderless magnifier-then-input is the nav header's - // settings search, laid out the same way — a boxed field would be - // the only outlined control on a sheet that has none, and would read - // as a different kind of search from the one two columns to its left. - h_flex() - .items_center() - .gap_2() - .child( - // Stock magnifier, not tty7's: at this size the redraw - // reads thin and its handle stubby. See `assets::STOCK_PREFIX`. - Icon::empty() - .path("stock/icons/search.svg") - .size(px(16.)) - .text_color(muted), - ) - .child( - div() - .flex_1() - .min_w_0() - .child(Input::new(&filter).appearance(false).pl_0()), - ) - .child( - h_flex() - .flex_shrink_0() - .gap_0p5() - .child( - Button::new("ssh-profiles-add") - .icon(Icon::new(IconName::Plus)) - .ghost() - .small() - .on_click(cx.listener(|this, _, window, cx| { - this.add_new_profile(window, cx) - })), - ) - .child( - Button::new("ssh-profiles-more") - // Stock `⋯`, not tty7's: the redraw's filled - // `r=2` dots smear at this size. See the row - // menu below and `assets::STOCK_PREFIX`. - .icon(Icon::empty().path("stock/icons/ellipsis.svg")) - .ghost() - .small() - .dropdown_menu_with_anchor( - gpui::Anchor::TopRight, - move |menu, _window, _cx| { - Self::ssh_master_menu(menu, &menu_app) - }, - ), - ), - ), - ); + let header = v_flex().gap_2().child(self.header_text("Hosts", cx)).child( + h_flex() + .items_center() + .gap_2() + .child( + Icon::empty() + .path("stock/icons/search.svg") + .size(px(16.)) + .text_color(muted), + ) + .child( + div() + .flex_1() + .min_w_0() + .child(Input::new(&filter).appearance(false).pl_0()), + ) + .child( + h_flex() + .flex_shrink_0() + .gap_0p5() + .child( + Button::new("ssh-profiles-add") + .icon(Icon::new(IconName::Plus)) + .ghost() + .small() + .on_click(cx.listener(|this, _, window, cx| { + this.add_new_profile(window, cx) + })), + ) + .child( + Button::new("ssh-profiles-more") + .icon(Icon::empty().path("stock/icons/ellipsis.svg")) + .ghost() + .small() + .dropdown_menu_with_anchor( + gpui::Anchor::TopRight, + move |menu, _window, _cx| { + Self::ssh_master_menu(menu, &menu_app) + }, + ), + ), + ), + ); - // Bucket by group, keeping each group's config order. Filtering happens - // before bucketing, so a group the query empties drops out whole instead - // of leaving a header standing over nothing. let mut groups: Vec<(String, Vec<SshProfile>)> = Vec::new(); for p in profiles.iter().filter(|p| ssh_row_matches(p, &query)) { let key = ssh_group_key(p).to_string(); @@ -2117,9 +1599,6 @@ impl Tty7App { .then_with(|| a.0.cmp(&b.0)) }); - // Defaults sits above the groups and outside the filter: it owns the - // security toggles, and hiding those behind a query nobody thinks to type - // is how a setting becomes undiscoverable. let mut list = v_flex().gap_0p5().w_full().child(self.render_ssh_row( "ssh-defaults-row", "Defaults", @@ -2151,8 +1630,6 @@ impl Tty7App { } for (key, bucket) in groups { - // A live query force-expands every group: a match hiding inside a - // collapsed section is the same as no match at all. let is_collapsed = query.is_empty() && collapsed.contains(&key); let live_here = bucket.iter().filter(|p| live.contains(&p.id)).count(); list = list.child(self.render_ssh_group_header( @@ -2179,16 +1656,12 @@ impl Tty7App { v_flex() .p_2() .gap_2() - // Clear the title-bar drag strip up top so the buttons stay clickable. .pt(px(crate::ui::app::TITLE_BAR_HEIGHT)) .child(header) .child(list) .into_any_element() } - /// The master column's overflow menu: the `~/.ssh/config` link lives here - /// rather than on a permanent full-width button, because it is a once-in-a- - /// while action and the list beneath it is not. fn ssh_master_menu(menu: PopupMenu, app: &gpui::WeakEntity<Self>) -> PopupMenu { menu.min_w(px(200.)) .item(PopupMenuItem::new("Import from ~/.ssh/config").on_click({ @@ -2210,9 +1683,6 @@ impl Tty7App { })) } - /// One collapsible group header in the master list. Collapsed, it keeps - /// showing how many of its hosts are connected — folding a section away - /// should hide the rows, not the fact that something in there is live. fn render_ssh_group_header( &self, key: &str, @@ -2265,7 +1735,6 @@ impl Tty7App { .into_any_element() } - /// One host row: status dot, name over `user@host:port`, and a hover `⋯`. fn render_ssh_host_row( &self, p: &SshProfile, @@ -2305,9 +1774,6 @@ impl Tty7App { ) } - /// The shared shape of a master-list row (Defaults and every host). `dot` - /// is `None` for rows that can't be connected; `menu_for` adds the hover `⋯` - /// and right-click menu for a saved profile. #[allow(clippy::too_many_arguments)] fn render_ssh_row( &self, @@ -2337,8 +1803,6 @@ impl Tty7App { .py_2() .px_2() .rounded_md() - // The window ladder, both channels — see issue #197: multiplying a - // soft grey by alpha is how a fill silently disappears. .when(selected, |r| r.bg(gpui::rgb(sf.selected))) .when(!selected, |r| r.hover(|s| s.bg(gpui::rgb(sf.hover)))) .on_mouse_down(MouseButton::Left, move |ev, window, cx| { @@ -2352,9 +1816,6 @@ impl Tty7App { .size(px(6.)) .rounded_full() .when(live, |d| d.bg(success)) - // A hollow ring when idle, so the dot column reads as a - // status slot rather than appearing only for live hosts - // and shunting every other row's text left. .when(!live, |d| d.border_1().border_color(border)), ) }) @@ -2367,9 +1828,6 @@ impl Tty7App { div() .text_sm() .truncate() - // The label channel: a selected row steps up in colour - // and weight, so which row is loaded reads from the - // type and not from the fill alone. .when(selected, |d| { d.text_color(gpui::rgb(sf.text_selected)) .font_weight(FontWeight::MEDIUM) @@ -2386,19 +1844,13 @@ impl Tty7App { ), ); - // `.context_menu()` wraps the row in a different element type, so the two - // cases can't be a `when_some` — they're branched into `AnyElement` here. let Some(id) = menu_for else { return row.into_any_element(); }; - // Weak handles so the hover `⋯` dropdown and the right-click menu drive - // the same handlers. let menu_app = cx.entity().downgrade(); let ctx_app = cx.entity().downgrade(); let row_idx = id.as_u128() as usize; row.child( - // The wrapper swallows the mouse-down so opening the menu never also - // fires the row's select click. div() .flex_shrink_0() .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) @@ -2424,8 +1876,6 @@ impl Tty7App { .into_any_element() } - /// Saved profiles with a connected pane open right now, by id. Read off the - /// live panes rather than tracked separately, so it can't go stale. fn live_ssh_profiles(&self, cx: &App) -> std::collections::HashSet<Uuid> { use crate::daemon::protocol::SshPhase; let mut live = std::collections::HashSet::new(); @@ -2447,7 +1897,6 @@ impl Tty7App { live } - /// Point the detail pane at the global defaults, dropping any open edit form. pub(crate) fn select_ssh_defaults(&mut self, cx: &mut Context<Self>) { if let Some(s) = self.active_settings_mut() { s.ssh_form = None; @@ -2456,12 +1905,6 @@ impl Tty7App { cx.notify(); } - /// Collapse / expand one group of the master list. - /// - /// Collapsing the group that holds the current selection hands the detail - /// pane back to Defaults: otherwise the form stays open on a host whose row - /// is no longer anywhere on screen, and nothing on the page says which host - /// is being edited. fn toggle_ssh_group(&mut self, key: String, cx: &mut Context<Self>) { let selected_here = match self.active_settings().map(|s| s.ssh_detail) { Some(SshDetail::Profile(id)) => cx @@ -2486,10 +1929,6 @@ impl Tty7App { cx.notify(); } - /// The right (detail) pane: a selected profile's edit form, or — with nothing - /// selected — a "pick a profile" hint. The global security defaults render - /// below either state: tucked into the empty state alone they vanished the - /// moment a profile was selected, so they were easy to never discover. fn render_ssh_detail(&self, cx: &mut Context<Self>) -> AnyElement { let detail = self .active_settings() @@ -2502,16 +1941,10 @@ impl Tty7App { { self.render_ssh_profile_form(cx) } - // No selection (or a stale profile whose form is gone). _ => self.render_ssh_empty_state(cx), } } - /// The detail pane with nothing selected: a quick-connect box, and — when - /// `~/.ssh/config` holds aliases tty7 hasn't linked — an offer to link them. - /// - /// This replaces a one-line "select a profile to edit" hint that left the - /// widest column on the page doing nothing. fn render_ssh_empty_state(&self, cx: &mut Context<Self>) -> AnyElement { let muted = cx.theme().muted_foreground; let Some(input) = self.active_settings().map(|s| s.ssh_quick_connect.clone()) else { @@ -2521,9 +1954,6 @@ impl Tty7App { let parsed = crate::core::ssh_profile::parse_quick_connect(&target); let saved = cx.global::<Config>().ssh_profiles.len(); - // How many `~/.ssh/config` aliases aren't in the list yet. Read on render: - // the file is small, this section is not on a hot path, and a stale count - // would advertise work that is already done. let unlinked = { let known: std::collections::HashSet<String> = cx .global::<Config>() @@ -2563,9 +1993,6 @@ impl Tty7App { .label("Connect") .primary() .small() - // Off until the box holds something that parses: a - // Connect that can only fail is worse than one that - // says it isn't ready. .disabled(parsed.is_none()) .on_click(cx.listener(|this, _, window, cx| { this.ssh_quick_connect_from_settings(window, cx) @@ -2611,13 +2038,9 @@ impl Tty7App { ); } - // No extra top padding: the detail pane already clears the title bar, and - // the heading here has to land on the same baseline as `Hosts` beside it. body.into_any_element() } - /// Connect the empty state's quick-connect target, closing Settings first so - /// the new session is what's on screen. fn ssh_quick_connect_from_settings(&mut self, window: &mut Window, cx: &mut Context<Self>) { let Some(target) = self .active_settings() @@ -2632,9 +2055,6 @@ impl Tty7App { self.quick_connect(qc, window, cx); } - /// The `Defaults` row's detail: what every host inherits, plus the state of - /// the `~/.ssh/config` link. Its own page rather than a block pinned under - /// the profile form, where it read as part of whichever host was open. fn render_ssh_defaults_detail(&self, cx: &mut Context<Self>) -> AnyElement { let muted = cx.theme().muted_foreground; let imported = cx @@ -2687,11 +2107,6 @@ impl Tty7App { .into_any_element() } - /// Build the per-profile overflow menu shared by the hover ⋯ dropdown and the - /// row's right-click context menu: Connect, Copy address, Duplicate, then the - /// destructive Delete — rendered last, set apart by a separator and drawn in - /// danger red. Each item drives the same `Tty7App` handler the old inline - /// buttons did, via the weak `app` handle. fn ssh_profile_row_menu( menu: PopupMenu, id: Uuid, @@ -2735,7 +2150,6 @@ impl Tty7App { })) .separator(); - // Destructive, last, in danger red and set apart by the separator above. menu.item( PopupMenuItem::element(move |_window, _cx| div().text_color(danger).child("Delete")) .on_click({ @@ -2747,9 +2161,6 @@ impl Tty7App { ) } - /// Security block: the global host-key verification default and warn-on-close - /// toggle (both overridable per profile). Always visible in the detail pane, - /// under the form or the empty-state hint. fn render_ssh_security_block(&self, cx: &mut Context<Self>) -> AnyElement { let verify = cx.global::<Config>().verify_host_keys; let verify_switch = crate::ui::theme::switch("ssh-verify-host-keys", cx) @@ -2787,16 +2198,10 @@ impl Tty7App { .into_any_element() } - // ── SSH profile edit form (folded into Settings → SSH) ─────────────────── - - /// The open SSH edit form, mutably (for section toggles / auth / switches). fn ssh_form_mut(&mut self) -> Option<&mut SshProfileForm> { self.active_settings_mut().and_then(|s| s.ssh_form.as_mut()) } - /// Build the edit-form inputs seeded from `profile` and open the form. A fresh - /// input set each call (the old set drops with the previous form), so the SSH - /// section never carries every profile's inputs at once. pub(crate) fn ssh_form_load( &mut self, profile: &SshProfile, @@ -2872,10 +2277,6 @@ impl Tty7App { ); let login_scripts = seed_input(window, cx, &profile.login_scripts.join("\n"), true); - // Every input in the form is subscribed, not just the few whose values - // are echoed elsewhere: the header's Save button is enabled by comparing - // the whole form against the saved profile, so any field going stale - // would leave Save claiming there is nothing to write. let mut subs = Vec::new(); let mut watch = vec![ &name, @@ -2948,15 +2349,11 @@ impl Tty7App { let editing = form.editing; if let Some(s) = self.active_settings_mut() { s.ssh_form = Some(form); - // Loading a form selects that profile in the master-detail layout, so - // its row highlights and the detail pane shows the form. s.ssh_detail = SshDetail::Profile(editing); } cx.notify(); } - /// Read the edit form back into an [`SshProfile`], preserving the id and the - /// carried-over group / credential_ref. fn ssh_form_collect(&self, cx: &App) -> Option<SshProfile> { let form = self.active_settings()?.ssh_form.as_ref()?; let id = form.editing; @@ -3008,7 +2405,6 @@ impl Tty7App { }) } - /// Save the edit form into `Config::ssh_profiles` (upsert by id). pub(crate) fn save_editing_profile(&mut self, cx: &mut Context<Self>) -> Option<Uuid> { let profile = self.ssh_form_collect(cx)?; let id = profile.id; @@ -3022,17 +2418,11 @@ impl Tty7App { Some(id) } - /// Save the form, leaving it open on the same host. - /// - /// It used to close back to an empty pane. With the host list permanently - /// beside the form that reads as the selection being thrown away; staying - /// put also lets the now-disabled Save double as the "saved" acknowledgement. pub(crate) fn save_ssh_form(&mut self, cx: &mut Context<Self>) { self.save_editing_profile(cx); cx.notify(); } - /// Save the current form, then close Settings and connect the saved profile. pub(crate) fn save_and_connect_profile(&mut self, window: &mut Window, cx: &mut Context<Self>) { if let Some(id) = self.save_editing_profile(cx) { self.close_settings(window, cx); @@ -3040,13 +2430,11 @@ impl Tty7App { } } - /// Add a fresh blank profile and open it in the edit form. pub(crate) fn add_new_profile(&mut self, window: &mut Window, cx: &mut Context<Self>) { let profile = SshProfile::new(String::new()); self.ssh_form_load(&profile, window, cx); } - /// Duplicate a saved profile (new id, "… (copy)" name) and edit the copy. pub(crate) fn duplicate_profile( &mut self, id: Uuid, @@ -3068,7 +2456,6 @@ impl Tty7App { self.ssh_form_load(&profile, window, cx); } - /// Delete a saved profile and its frecency entry. pub(crate) fn delete_profile(&mut self, id: Uuid, cx: &mut Context<Self>) { self.update_config(cx, |cfg| { cfg.ssh_profiles.retain(|p| p.id != id); @@ -3077,15 +2464,12 @@ impl Tty7App { let editing_deleted = self.active_settings().map(|s| s.ssh_detail) == Some(SshDetail::Profile(id)); if let Some(s) = self.active_settings_mut().filter(|_| editing_deleted) { - // The deleted profile was selected: drop its form and clear the - // selection back to the empty state. s.ssh_form = None; s.ssh_detail = SshDetail::None; } cx.notify(); } - /// Import `~/.ssh/config` aliases as profiles (idempotent upsert by name). pub(crate) fn import_ssh_config_profiles(&mut self, cx: &mut Context<Self>) { let imported = crate::core::ssh_config::import_profiles(); if imported.is_empty() { @@ -3097,7 +2481,6 @@ impl Tty7App { cx.notify(); } - /// Copy a saved profile's `user@host:port` to the clipboard (FR-P5). pub(crate) fn copy_profile_connect_string(&mut self, id: Uuid, cx: &mut Context<Self>) { if let Some(profile) = cx .global::<Config>() @@ -3110,11 +2493,6 @@ impl Tty7App { } } - /// Remove any keychain-stored password for this profile's endpoint - /// (`user@host:port`). The profile itself is untouched — the next connect will - /// prompt again. A no-op if nothing was stored. Returns a status line for the - /// caller to surface as a notification. Credentials are keyed by endpoint, not - /// profile, so this only matches when the profile pins an explicit user. pub(crate) fn forget_profile_password( &mut self, id: Uuid, @@ -3136,8 +2514,6 @@ impl Tty7App { ) } - /// The inline edit form: four core fields + collapsible jump / forwards / - /// advanced, rendered below the profile list for the selected profile. fn render_ssh_profile_form(&self, cx: &mut Context<Self>) -> AnyElement { let Some(form) = self.active_settings().and_then(|s| s.ssh_form.as_ref()) else { return div().into_any_element(); @@ -3146,9 +2522,6 @@ impl Tty7App { let muted = cx.theme().muted_foreground; let success = cx.theme().success; - // The header identifies the host and offers the one action this page - // exists for. It used to say "Edit profile" beside a ‹ Back — a title - // that named the *screen*, on a screen whose subject is a machine. let saved = cx .global::<Config>() .ssh_profiles @@ -3156,8 +2529,6 @@ impl Tty7App { .find(|p| p.id == editing) .cloned(); let collected = self.ssh_form_collect(cx); - // A never-saved profile is always dirty; otherwise compare field by field - // so Save reads as "there is something to save". let dirty = collected != saved; let address = collected .as_ref() @@ -3219,15 +2590,10 @@ impl Tty7App { Button::new("ssh-form-save") .label("Save") .small() - // Off with nothing to write: the button is the page's - // unsaved-changes indicator, so it has to be honest. .disabled(!dirty) .on_click(cx.listener(|this, _, _w, cx| this.save_ssh_form(cx))), ) .child( - // The one action this page exists for, so it carries the - // solid fill — unlike the master column's Add, which sits - // over a list and would shout. Button::new("ssh-form-connect") .label("Connect") .primary() @@ -3244,9 +2610,6 @@ impl Tty7App { self.settings_row( "Name", "A label for this connection.", - // Explicit widths on every text control: `settings_row` right-aligns - // the control in a shrink-to-fit slot, so a bare Input has no - // definite width to fill. 260px matches the Shell section's inputs. div() .w(px(260.)) .child(Input::new(&form.name).small()) @@ -3258,7 +2621,6 @@ impl Tty7App { self.settings_row( "Host", "Hostname or IP address.", - // Host + port split the shared 260px control width. h_flex() .gap_2() .child(div().w(px(172.)).child(Input::new(&form.host).small())) @@ -3313,7 +2675,6 @@ impl Tty7App { .into_any_element() } - /// A collapsible section header (▸/▾ label + summary), toggling `open`. fn disclosure_header( &self, id: &'static str, @@ -3437,8 +2798,6 @@ impl Tty7App { ), ) .child( - // The direction letters carry the whole meaning of a rule, and - // `L`/`R` are the one pair people reliably mix up. h_flex() .gap_3() .pt_1() @@ -3451,7 +2810,6 @@ impl Tty7App { .into_any_element() } - /// One forward rule: direction, listener, target, description, remove. fn render_forward_rule_row( &self, idx: usize, @@ -3460,23 +2818,14 @@ impl Tty7App { ) -> AnyElement { let muted = cx.theme().muted_foreground; let danger = cx.theme().danger; - // Dynamic listens locally and proxies wherever the client asks, so it has - // no fixed target. The boxes stay in place (dimmed) rather than - // disappearing, so switching direction doesn't reflow the row. let needs_target = row.kind != ForwardKind::Dynamic; let kind_idx = match row.kind { ForwardKind::Local => 0, ForwardKind::Remote => 1, ForwardKind::Dynamic => 2, }; - // Filled in but not connectable — flagged here rather than dropped - // silently on save, which is what the old text box did. let incomplete = row.collect(cx).is_none() && !row.is_blank(cx); - // `xsmall` rather than the sheet's usual `small`: five controls share this - // row, and 24px is the height the segmented track beside them is fixed at. - // The row reads as one compact table cell — that internal alignment beats - // matching the full-width single inputs in the rows above. let endpoint = |host: &Entity<InputState>, port: &Entity<InputState>| { h_flex() .gap_1() @@ -3545,7 +2894,6 @@ impl Tty7App { .into_any_element() } - /// Append a blank forward rule to the open form and subscribe its inputs. fn add_forward_rule(&mut self, window: &mut Window, cx: &mut Context<Self>) { let row = seed_forward_row(window, cx, &ForwardRule::default()); let subs: Vec<_> = forward_row_inputs(&row) @@ -3566,7 +2914,6 @@ impl Tty7App { cx.notify(); } - /// Drop one forward rule from the open form. fn remove_forward_rule(&mut self, idx: usize, cx: &mut Context<Self>) { if let Some(f) = self.ssh_form_mut() && idx < f.forwards.len() @@ -3606,8 +2953,6 @@ impl Tty7App { this.settings_row( label.to_string(), desc.to_string(), - // Same explicit control width as the core fields above — a bare - // Input has nothing to fill in the row's right-aligned slot. div() .w(px(260.)) .child(Input::new(input).small()) @@ -3616,7 +2961,6 @@ impl Tty7App { ) }; - // Verify host keys / warn-on-close tri-states (Default / On / Off). let on_off = |b: bool| if b { "on" } else { "off" }; let vhk_default = on_off(cx.global::<Config>().verify_host_keys); let woc_default = on_off(cx.global::<Config>().ssh_warn_on_close); @@ -3789,9 +3133,6 @@ impl Tty7App { ) .child(self.settings_row( "Verify host keys", - // Name the value `Default` actually resolves to. "Overrides the - // global setting" tells you a mechanism exists but not what it - // currently does, which is the only part worth reading here. format!("Default follows Defaults, which is {vhk_default}."), self.segmented( "ssh-form-vhk", @@ -3835,17 +3176,6 @@ impl Tty7App { section.into_any_element() } - /// The Shell group at the top of the Terminal section: the program tty7 - /// launches in each new pane, its launch arguments, and where a fresh shell - /// starts. All 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 7 when installed, else Windows - /// PowerShell, on Windows). - /// - /// This used to be a section of its own, which left a three-row page and no - /// way for a user to guess whether a given knob was filed under "Terminal" - /// or under "Shell". The program a pane runs is a property of the terminal, - /// so it opens the Terminal page instead. fn render_shell_group(&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() { @@ -3858,8 +3188,6 @@ impl Tty7App { }; let wd_strategy = cx.global::<Config>().working_directory.strategy; - // Name what an empty Program field falls back to, so the default - // behaviour is legible without the user having to know it. let platform_default = if cfg!(windows) { "PowerShell" } else { @@ -3895,7 +3223,6 @@ impl Tty7App { this.set_working_directory_strategy(s, cx); }, ); - // The custom path input only matters for `Custom`; show it there. let wd_path_control = if wd_strategy == WdStrategy::Custom { div() .w(px(260.)) @@ -3949,16 +3276,6 @@ impl Tty7App { .into_any_element() } - /// Terminal section: what a pane runs and how the terminal surface itself - /// behaves — the shell, scrolling, the mouse, the bell, links. Plain - /// switches and segmented controls driven straight off the `Config` global - /// (each control's handler mutates + saves it). Small groups on purpose: - /// each header names exactly what it contains, so it doubles as the landmark - /// you scan for. - /// - /// Typing, selection and the clipboard used to live down here too, under - /// four more headers; they moved to their own Input section, which is both - /// findable by name and short enough to read in one screen. fn render_settings_terminal(&self, cx: &mut Context<Self>) -> AnyElement { let foreground = cx.theme().foreground; let cfg = cx.global::<Config>(); @@ -3969,8 +3286,6 @@ impl Tty7App { let scroll_mult = cfg.mouse_scroll_multiplier; let mouse_reporting = cfg.mouse_reporting; let bell = cfg.bell; - // Map the persisted scrollback depth onto its preset radio index (default - // to 10k's slot for any off-preset value a hand-edit might leave). let scrollback_idx = match cfg.scrollback_limit { n if n <= 1_000 => 0, n if n <= 10_000 => 1, @@ -4045,7 +3360,6 @@ impl Tty7App { this.set_bell_mode(mode, cx); }, ); - // Slider + a live readout of the current multiplier beside it. let scroll_control = h_flex() .items_center() .gap_3() @@ -4132,15 +3446,6 @@ impl Tty7App { .into_any_element() } - /// Input section: everything about putting text *in* and taking text *out* — - /// the completion and history menus at the prompt, the Option/Meta split, - /// and how selection reaches the clipboard. - /// - /// A section of its own because these are the settings that distinguish tty7 - /// from a plain terminal, and they were previously the last four groups of a - /// seven-group Terminal page — findable only by scrolling past everything - /// else, and not findable by search at all (completion and history search - /// had no index entries). fn render_settings_input(&self, cx: &mut Context<Self>) -> AnyElement { let cfg = cx.global::<Config>(); let option_as_alt = cfg.macos_option_as_alt; @@ -4170,8 +3475,6 @@ impl Tty7App { .checked(clip_trim) .on_click(cx.listener(|this, on: &bool, _w, cx| this.set_clipboard_trim(*on, cx))) .into_any_element(); - // macOS only: the Option/special-character split this toggle resolves - // doesn't exist on other platforms, where Alt always carries Meta. let option_alt_row = cfg!(target_os = "macos").then(|| { let switch = crate::ui::theme::switch("term-option-as-alt", cx) .checked(option_as_alt) @@ -4237,14 +3540,6 @@ impl Tty7App { .into_any_element() } - /// Agents section: a machine picker, then one row per hook-capable agent on - /// that machine — install state + actions per row, copy kept terse. - /// - /// The picker is first because everything under it is *about* the chosen - /// machine: the paths, the states, and what Install writes. An agent running - /// in a remote workspace's pane runs on the remote box and reads that box's - /// `~/.claude/settings.json`, so installing here and expecting status there - /// was the whole gap this page closes. fn render_settings_agents(&self, cx: &mut Context<Self>) -> AnyElement { use crate::core::agent_hooks::HooksState; @@ -4270,9 +3565,6 @@ impl Tty7App { page = page.children(self.agent_hooks_machine_picker(selected_host, cx)); match view { - // A spinner would be four agents' worth of motion for a read that is - // usually instant; the page just says what it is doing and keeps its - // shape, so nothing jumps when the rows arrive. AgentHooksView::Loading => { return page .child( @@ -4284,8 +3576,6 @@ impl Tty7App { ) .into_any_element(); } - // A resting state that says which hop gave up and what to do - // next, rather than rows that would silently write nowhere. AgentHooksView::Unavailable(reason) => { return page .child(div().py_4().text_sm().text_color(warning).child(reason)) @@ -4294,15 +3584,11 @@ impl Tty7App { AgentHooksView::Ready(rows) => { for (i, row) in rows.into_iter().enumerate() { let agent = row.agent; - // Status: a colored dot + one word; the dot is the only color - // on the page, so state reads at a glance. let (dot_color, status_text) = match row.state { HooksState::NotInstalled => (muted_fg, "Not installed"), HooksState::Installed => (success, "Installed"), HooksState::Outdated => (warning, "Outdated"), }; - // The primary action reads as what it will *do* from this - // state. let primary_label = match row.state { HooksState::NotInstalled => "Install", HooksState::Installed => "Reinstall", @@ -4313,8 +3599,6 @@ impl Tty7App { .filter(|(for_agent, _)| *for_agent == agent) .map(|(_, text)| text.clone()); - // items_end: the whole stack shares the row's right edge, so - // status, buttons, and note line up across every agent row. let control = v_flex() .gap_2() .items_end() @@ -4347,9 +3631,6 @@ impl Tty7App { ) }), ) - // Width-capped so a long note (error text) wraps instead - // of inflating the shrink-proof control column and - // crushing the label to zero width. .when_some(row_note, |col, text| { col.child( div() @@ -4374,17 +3655,6 @@ impl Tty7App { page.into_any_element() } - /// The Agents section's machine picker: this computer plus every connected - /// remote, one chosen at a time. - /// - /// `None` when this computer is the only machine there is — a picker with a - /// single choice is a control that asks a question with one answer, and the - /// page below it already says where the files go. - /// - /// Hand-rolled rather than [`Self::segmented`] because the options are - /// machines, not a fixed `&'static [&'static str]` — but it reads off the - /// same interaction ladder, so it is the same control the rest of the sheet - /// speaks. fn agent_hooks_machine_picker(&self, selected: HostId, cx: &mut Context<Self>) -> Option<Div> { let sf = cx.global::<presets::Surfaces>().window; let border = cx.theme().border; @@ -4417,10 +3687,6 @@ impl Tty7App { .bg(rgb(sf.base)) .text_sm() .cursor_pointer() - // Both channels, every time: the fill locates the - // selection, the label colour and weight say it is - // the one — and keep saying it on a translucent - // window, where the fill washes over the desktop. .when(active, |s| { s.bg(rgb(sf.selected)) .text_color(rgb(sf.text_selected)) @@ -4437,10 +3703,6 @@ impl Tty7App { })) })), ) - // A saved machine that isn't connected is absent from the row above, - // and an absence explains nothing. Say the count and the next move - // rather than listing fifty `~/.ssh/config` aliases, most of which - // are git transports that could never host a workspace anyway. .when(offline > 0, |col| { col.child(div().text_xs().text_color(muted_fg).child(format!( "{offline} more saved machine{} not connected — open a workspace on one to \ @@ -4451,7 +3713,6 @@ impl Tty7App { ) } - /// Window & Tabs section: the app window's lifecycle and tab placement. fn render_settings_window_tabs(&self, cx: &mut Context<Self>) -> AnyElement { let cfg = cx.global::<Config>(); let startup_idx = match cfg.startup_mode { @@ -4476,16 +3737,11 @@ impl Tty7App { crate::core::config::SidebarGrouping::Repo => 0, crate::core::config::SidebarGrouping::None => 1, }; - // Notifications are app-level, not terminal-level: the tray menu already - // exposed the same `NotifyMode` at the top of its own menu while the - // setting itself sat at the bottom of the Terminal page. let notify_idx = match cfg.notify_on_command_finish { NotifyMode::Never => 0, NotifyMode::Unfocused => 1, NotifyMode::Always => 2, }; - // Map the persisted threshold onto its preset radio index (nearest slot - // for any off-preset value a hand-edit might leave). let threshold_idx = match cfg.notify_threshold_secs { n if n <= 5 => 0, n if n <= 10 => 1, @@ -4494,8 +3750,6 @@ impl Tty7App { }; let notify_radio = self.segmented( "wt-notify", - // Same order and casing as the tray's Notifications submenu, which - // writes this very setting — the two used to disagree on both. &["Never", "When Unfocused", "Always"], notify_idx, cx, @@ -4615,19 +3869,12 @@ impl Tty7App { remember_window_switch, cx, )) - // "Session" already means "a shell running in the background" all - // over this app; using it here for "the saved arrangement of tabs" - // made the one word mean two things on the same page. The thing - // being restored is the layout. .child(self.settings_row( "Restore last layout", "Reopen the last window's tabs, splits, and directories on launch. Off starts with a single fresh terminal.", restore_switch, cx, )) - // Phrased around what stays true either way: the prompt is there to - // teach that closing isn't ending, so the row that turns it off is - // the last chance to say so. .child(self.settings_row( "Confirm before closing the last window", "Ask first, since that close also quits tty7. Off closes straight away — \ @@ -4663,9 +3910,6 @@ impl Tty7App { sidebar_grouping_radio, cx, )) - // Phrased around what *stays*: the worry this row answers is "will - // turning it off cost me the branch and the numbers", and the - // answer is no — only the click goes. .child(self.settings_row( "Open diff preview from sidebar counts", "Click a row's +N −N to open the working-tree diff in an overlay. Off keeps the \ @@ -4690,22 +3934,11 @@ impl Tty7App { .into_any_element() } - /// Theme gallery: one clickable card per theme (built-ins + user files), each - /// a mini-terminal preview painted in its own colors. The selected card gets a - /// soft ring + a check; clicking switches the active theme live via - /// `set_preset`. - /// The mini terminal preview for a theme: thin "lines of code" bars in the - /// theme's own colors over its background. Fills its container's width, so a - /// narrow "Current theme" card and the wider picker panel reuse one shape. fn theme_preview(&self, p: &presets::Theme) -> Div { let to_u32 = |(r, g, b): (u8, u8, u8)| (r as u32) << 16 | (g as u32) << 8 | b as u32; let accent = rgb(p.accent); let ansi = |i: usize| rgb(to_u32(p.ansi16[i])); let fg = rgb(p.foreground); - // A "line of code": thin rounded bars whose widths are *fractions* of the - // preview, so the same shape reads well in the narrow "Current theme" card - // and the wider picker instead of clustering at the left edge. Rows stay - // ragged-right like real terminal text. let bar = |frac: f32, color: gpui::Rgba| { div().h(px(4.)).w(relative(frac)).rounded(px(1.5)).bg(color) }; @@ -4746,9 +3979,6 @@ impl Tty7App { ) } - /// The theme choice block on the Appearance page: the "Sync with system" - /// switch, then either the single manual-theme card or — while following - /// the OS — one card per light/dark slot. fn render_theme_selection(&self, cx: &mut Context<Self>) -> AnyElement { let follow = cx.global::<Config>().theme_follow_system; let follow_switch = crate::ui::theme::switch("theme-follow-system", cx) @@ -4773,11 +4003,6 @@ impl Tty7App { } } - /// One compact theme card: a preview of the slot's theme beside its caption - /// (kind + light/dark mode for the manual card, the slot's role for the - /// follow-system cards), its name, and its six chromatic ANSI swatches; the - /// whole row a click target that opens the picker panel on the right, - /// aimed at this slot. fn render_theme_card(&self, slot: ThemeSlot, cx: &mut Context<Self>) -> AnyElement { let theme = cx.theme(); let border = theme.border; @@ -4794,7 +4019,6 @@ impl Tty7App { }; let active = presets::by_id(cx, &active_id); let name = active.name.clone(); - // A user file (duplicated or dropped in the themes folder) vs a built-in. let kind = if active.path.is_some() { "Custom" } else { @@ -4805,8 +4029,6 @@ impl Tty7App { let mode = if active.dark { "Dark" } else { "Light" }; format!("{kind} · {mode}") } - // The slot cards are captioned by their role; the one matching the - // current OS appearance is the theme actually on screen. ThemeSlot::Light if !crate::ui::theme::system_dark(cx) => { format!("Light mode · {kind} · Active") } @@ -4816,9 +4038,6 @@ impl Tty7App { } ThemeSlot::Dark => format!("Dark mode · {kind}"), }; - // The six chromatic ANSI slots (red…cyan) as tiny swatches — the part of - // a theme the mini preview's few bars can't show, and what actually - // distinguishes two same-background themes at a glance. let to_u32 = |(r, g, b): (u8, u8, u8)| (r as u32) << 16 | (g as u32) << 8 | b as u32; let swatches = h_flex().gap_1().mt_1p5().children((1..=6).map(|i| { div() @@ -4881,16 +4100,11 @@ impl Tty7App { .into_any_element() } - /// The theme picker: a right-hand column of searchable preview - /// cards. Opened from the "Current theme" card; applying a theme keeps the - /// panel open (with its own `×`) so several looks can be tried in a row. fn render_theme_panel(&self, cx: &mut Context<Self>) -> AnyElement { let theme = cx.theme(); let border = theme.border; let foreground = theme.foreground; let muted_fg = theme.muted_foreground; - // A hair off the content pane (like the settings rail) so the panel reads - // as its own surface rather than an extension of the page. let bg = theme.sidebar; let (search, query, slot) = match self.active_settings() { @@ -4902,9 +4116,6 @@ impl Tty7App { None => return div().into_any_element(), }; let config = cx.global::<Config>(); - // Guard against a slot that no longer exists in the current mode (the - // sync switch flipped while the panel was open re-aims it, but stale - // state must still render something sensible). let slot = match (config.theme_follow_system, slot) { (false, _) => ThemeSlot::Manual, (true, ThemeSlot::Manual) => { @@ -4936,15 +4147,6 @@ impl Tty7App { .child("Themes"), ) .child( - // The panel is docked to the window's top edge, so this `×` sits - // inside the settings overlay's stand-in title-bar strip — an - // absolute `WindowControlArea::Drag` band across the top 40px - // (see `root` below). On Windows that band is `HTCAPTION`, and - // unless something on top registers a mouse-blocking hitbox the - // OS takes the press as a window-drag and the button's `on_click` - // never fires. `occlude()` stops hit-testing here, the same way - // the tab-strip chips and the page's own `×` do. No-op elsewhere; - // the rest of the header still drags the window. div().occlude().child( Button::new("theme-panel-close") .icon(IconName::Close) @@ -4965,22 +4167,14 @@ impl Tty7App { ThemeSlot::Dark => "Choose the theme for dark mode.", }); - // Plain text input, the same shape the Shell section uses — our own - // field, not a bespoke pill. The Input fills its parent, but a percent - // width needs a *definite* one to resolve against, so the wrapper is sized - // explicitly (panel 300 − px_4 gutters). Placeholder labels it as search; - // a leading magnifier keeps that reading at a glance. let search_box = div().px_4().pb_3().child( div().w(px(268.)).child( - Input::new(&search) - .small() - // Stock magnifier — same reason as the page header's. - .prefix( - Icon::empty() - .path("stock/icons/search.svg") - .small() - .text_color(muted_fg), - ), + Input::new(&search).small().prefix( + Icon::empty() + .path("stock/icons/search.svg") + .small() + .text_color(muted_fg), + ), ), ); @@ -4991,11 +4185,6 @@ impl Tty7App { } let id = p.id.clone(); let is_active = active_id == id; - // Here the preview sits *flush* inside the card's border (the - // "Current theme" card pads it, so it keeps its own 8px there). Flush - // means its corner has to nest one hairline inside the card's, or it - // bulges past the border into the square overflow clip — the corner - // then reads as a hard step instead of an arc (issue #236). let preview = self.theme_preview(&p).rounded(rounding::inner_radius( rounding::TRACK_RADIUS, rounding::HAIRLINE, @@ -5007,10 +4196,6 @@ impl Tty7App { .gap_1p5() .cursor_pointer() .child( - // Percent width (`w_full` in the preview) only resolves - // against a *definite* parent, so pin the card to the - // panel's content width (300 − px_4 gutters) — same reason - // the search box above is sized explicitly. div() .w(px(268.)) .rounded(rounding::TRACK_RADIUS) @@ -5074,7 +4259,6 @@ impl Tty7App { .into_any_element() } - /// Keybindings section: the effective shortcut list (defaults + overrides). fn render_settings_keybindings(&self, cx: &mut Context<Self>) -> AnyElement { let (foreground, muted, border, kbd_bg, accent) = { let t = cx.theme(); @@ -5087,8 +4271,6 @@ impl Tty7App { ) }; - // Config-derived state, read into owned values so the `cx` borrow is - // free for `effective_bindings` and the click listeners below. let (preset, prefix, overridden) = { let cfg = cx.global::<Config>(); let overridden: std::collections::HashSet<String> = @@ -5102,8 +4284,6 @@ impl Tty7App { let tmux = preset == "tmux"; let effective = crate::ui::keymap::effective_bindings(cx); - // The row currently capturing a shortcut (action + chords so far), and - // any pending takeover note. let recording = self .active_settings() .and_then(|s| s.recording.as_ref()) @@ -5112,7 +4292,6 @@ impl Tty7App { .active_settings() .and_then(|s| s.rebinding_note.clone()); - // One key glyph as a small keycap, so a shortcut reads like real keys. let keycap = move |tok: String| { div() .flex() @@ -5130,12 +4309,6 @@ impl Tty7App { .child(tok) }; - // Preset and prefix are each a one-of-two choice among visible siblings — - // a segmented control, and now built as one. They used to be loose - // `Button::selected` pairs, which put them on gpui-component's - // `tokens.button_active`: another field nothing set, so the "on" button - // wore a stock grey with no relation to the theme (issue #197's failure - // mode, in a different corner of the same page). let preset_control = self.segmented( "kb-preset", &["Default", "tmux"], @@ -5194,9 +4367,6 @@ impl Tty7App { let is_recording = recording.as_ref().is_some_and(|(a, _)| a == &action); let is_overridden = overridden.contains(&action); - // Keycap clusters for a spec: one cluster per whitespace-separated - // chord (a sequence like `ctrl-b x` draws as two clusters), with a - // wider gap between clusters than within one. let keycaps = |spec: &str| { h_flex().gap_2().children( crate::ui::keymap::key_chords(spec) @@ -5205,8 +4375,6 @@ impl Tty7App { ) }; - // Right side: the live capture (chords so far + hint), the keycap - // sequence, or "—". let captured: gpui::AnyElement = if is_recording { let chords = recording .as_ref() @@ -5234,7 +4402,6 @@ impl Tty7App { keycaps(&key).into_any_element() }; - // The whole right cell is clickable to start capturing this row. let action_for_click = action.clone(); let capture = div() .id(SharedString::from(format!("kb-{action}"))) @@ -5314,20 +4481,6 @@ impl Tty7App { .into_any_element() } - /// "How sessions work": the four-line explanation of the app's own model — - /// what closing a window does, what Stop does, what Delete does, what Quit - /// does. - /// - /// This is tty7's central idea and the thing that most surprises a user - /// arriving from another terminal, and until now it was explained *only* - /// inside the confirmation dialogs — that is, at the moment the user is - /// already committing to an action, and never before. Stating it once, in - /// the one page that describes what the app is, means the dialogs confirm a - /// model the user has already met instead of teaching it under pressure. - /// - /// Deliberately a plain definition list rather than settings rows: nothing - /// here is configurable, and giving it switch-shaped chrome would suggest - /// otherwise. fn render_session_model(&self, cx: &mut Context<Self>) -> AnyElement { let theme = cx.theme(); let (foreground, muted_fg) = (theme.foreground, theme.muted_foreground); @@ -5383,13 +4536,10 @@ impl Tty7App { .into_any_element() } - /// About section: app identity and stack. fn render_settings_about(&self, cx: &mut Context<Self>) -> AnyElement { let theme = cx.theme(); let (foreground, muted_fg) = (theme.foreground, theme.muted_foreground); - // Startup update check (see `core::update`): a newer release, if one was - // found, plus the toggle that controls whether we look at all. let update = cx .try_global::<crate::core::update::UpdateStatus>() .and_then(|s| s.available.clone()); @@ -5433,8 +4583,6 @@ impl Tty7App { v_flex() .mt_5() .gap_2() - // Mirrors the README's positioning line and stack sub-line, so - // the app and the repo describe tty7 in the same words. .child( div() .text_sm() @@ -5452,9 +4600,6 @@ impl Tty7App { ), ) .child(self.render_session_model(cx)) - // Updates: the startup check drops a newer version here if it found - // one. We never self-update — "Download" just opens the Releases - // page; the toggle turns the check off (see `core::update`). .child( v_flex() .mt_6() @@ -5476,9 +4621,6 @@ impl Tty7App { format!("Version {} is available.", upd.version), )) .child( - // Match the sibling "Restart daemon…" button - // (default style, not the dark `.primary()` - // fill) so About reads as one panel. Button::new("download-update") .label("Download") .small() @@ -5510,11 +4652,6 @@ impl Tty7App { ), ), ) - // Manage that daemon. A fresh process is the only way to pick up a - // macOS permission granted after it started (e.g. Full Disk Access), - // to recover if it wedges, or to start clean — quitting/reopening the - // window alone never restarts it. Ends every running session, so the - // action confirms first. .child( v_flex() .mt_6() @@ -5549,9 +4686,6 @@ impl Tty7App { mod tests { use super::*; - /// Every section must carry at least one index entry, or the search box can - /// annotate the nav with a count it can never jump to — and, worse, a whole - /// page of settings becomes unreachable by search. #[test] fn every_section_has_search_entries() { for section in SettingsSection::ALL { @@ -5567,8 +4701,6 @@ mod tests { } } - /// `best_matching_section` must be able to reach every section — it used to - /// be driven by a hand-written list that had fallen behind by two. #[test] fn best_matching_section_can_reach_every_section() { for section in SettingsSection::ALL { @@ -5586,8 +4718,6 @@ mod tests { } } - /// Settings that had no index entry at all before this pass — searching for - /// any of them returned an empty result on a page that plainly had the knob. #[test] fn previously_unsearchable_settings_are_findable() { use SettingsSection::*; @@ -5614,13 +4744,8 @@ mod tests { } } - /// The close-confirmation toggle is the one people go looking for *after* - /// the dialog has annoyed them, so it has to be reachable by what they'd - /// type in that moment — not just by its own title. #[test] fn close_confirmation_toggle_is_findable() { - // Not a bare "confirm": SSH's own close warning owns that word just as - // legitimately, and the nav's per-section counts are what disambiguate. for query in [ "ask again", "closing the last window", @@ -5636,10 +4761,6 @@ mod tests { } } - /// The index names rows, so a title that no longer matches the rendered row - /// sends the user to the right page and then leaves them hunting. This - /// pins the ones that had drifted (the index said "Working directory"; the - /// row says "Start in"). #[test] fn index_titles_match_rendered_row_labels() { for title in [ @@ -5662,12 +4783,6 @@ mod tests { } } - /// The Agents rows are titled by [`HookAgent::display_name`], so the index - /// is derived rather than pinned: every hook-capable agent must have an - /// Agents-section entry under exactly that name. Adding an agent to - /// `HookAgent::ALL` without indexing it — how Grok Build became - /// unsearchable — fails here, as does renaming an agent without moving its - /// index entry. #[test] fn agent_rows_are_in_the_search_index() { for agent in crate::core::agent_hooks::HookAgent::ALL { @@ -5691,8 +4806,6 @@ mod tests { assert_eq!(humanize_action("Quit"), "Quit"); } - /// A host is findable by every part of the address it is displayed with, - /// not just its name — an imported alias often *is* its hostname. #[test] fn the_host_filter_matches_name_address_and_port() { let mut p = SshProfile::new("prod-web"); @@ -5708,8 +4821,6 @@ mod tests { assert!(!ssh_row_matches(&p, "staging")); } - /// The filter is case-insensitive on a lowercased query, which is what the - /// master column hands it. #[test] fn the_host_filter_ignores_case() { let mut p = SshProfile::new("Prod-Web"); @@ -5718,8 +4829,6 @@ mod tests { assert!(ssh_row_matches(&p, "example.com")); } - /// Imported aliases lead, ungrouped hosts trail, and anything the user named - /// sits between them — so the `~/.ssh/config` bucket is never buried. #[test] fn group_buckets_sort_imported_first_and_ungrouped_last() { let mut keys = vec!["", "Work", crate::core::ssh_config::IMPORTED_GROUP]; @@ -5730,8 +4839,6 @@ mod tests { ); } - /// The import bucket is labelled by the file it mirrors: it is a live link - /// to something edited elsewhere, not a record of a past import. #[test] fn group_labels_name_the_file_and_the_app() { assert_eq!( @@ -5742,8 +4849,6 @@ mod tests { assert_eq!(ssh_group_label("Work"), "Work"); } - /// A profile's bucket comes off its own `group`, with `None` collapsing to - /// the same key the ungrouped section uses. #[test] fn group_key_falls_back_to_the_ungrouped_bucket() { let mut p = SshProfile::new("a"); @@ -5758,7 +4863,6 @@ mod tests { let hp = parse_host_port("example.com:2222").unwrap(); assert_eq!(hp.host, "example.com"); assert_eq!(hp.port, 2222); - // No colon → host only, port 0. assert_eq!(parse_host_port("host").unwrap().port, 0); } } @@ -5778,8 +4882,6 @@ mod gpui_tests { cx.set_global(Config::default()); crate::ui::keymap::init(cx); }); - // Wrapped in a `Root` like `main.rs` does — the gpui-component widgets on - // the settings sheet reach for it on the window. let window = cx.add_window(|window, cx| { let app = cx.new(|cx| Tty7App::with_session(None, Some(Session::default()), window, cx)); @@ -5798,18 +4900,6 @@ mod gpui_tests { (app, vcx) } - /// Appearance is where issue #236's controls live: the cursor-shape segmented - /// track, the −/value/+ steppers and the theme picker's flush previews all - /// compute their own corner radii now (`ui::rounding`) instead of leaning on - /// `overflow_hidden`, and the stepper's glyph boxes were re-laid-out to - /// `h_full` so those radii land on the track's content box. - /// - /// gpui's test platform lays out and paints but never rasterizes, so this - /// cannot assert what the corners *look* like. What it can do is put the page - /// through a real layout and paint pass — twice, at two widths, so the tracks - /// are measured more than once — which is the cheapest seam that catches a - /// panic or a broken constraint in that arithmetic before a human ever sees - /// the window. #[gpui::test] fn appearance_section_lays_out_with_its_rounded_controls(cx: &mut TestAppContext) { let (app, mut vcx) = harness(cx); @@ -5820,17 +4910,12 @@ mod gpui_tests { vcx.simulate_resize(size(px(1100.), px(800.))); vcx.run_until_parked(); - // The flush-mounted theme previews only render with the picker docked - // open, so the page has to be paint-tested in both states to reach every - // site this change touched. app.update_in(&mut vcx, |app, _, cx| { if let Some(s) = app.active_settings_mut() { s.theme_panel_open = true; } cx.notify(); }); - // Narrow enough that the theme list re-measures against a different - // available width than the pass above. vcx.simulate_resize(size(px(720.), px(560.))); vcx.run_until_parked(); diff --git a/src/ui/sftp.rs b/src/ui/sftp.rs index c4ad67fc..f9f7a65a 100644 --- a/src/ui/sftp.rs +++ b/src/ui/sftp.rs @@ -1,24 +1,3 @@ -//! Pane-contextual SFTP file panel (Workstream 5). -//! -//! Renders as a bottom-docked panel (tabby-style) over the lower part of the -//! terminal body for the focused **native-SSH** pane (a PTY pane, or a -//! foreground `ssh` typed into a local shell, has no russh connection to browse, -//! so the panel doesn't open). Mirrors the `ui::forwards` pattern: a set of -//! `impl Tty7App` render helpers plus a [`SftpPanelState`] held on `Tty7App`, and -//! one-shot [`RemoteTerminal`] control calls to the daemon (`sftp_list` / -//! `sftp_op` / `sftp_transfer_*`) — the blocking round-trips run on a background -//! executor so directory navigation never freezes the UI. -//! -//! Layout (interaction modelled on tabby's SFTP panel): a breadcrumb path bar -//! whose root reads `SFTP` and which double-clicks into a "type a path" text -//! input; a toolbar (refresh / filter / new folder / upload / go-to-shell-cwd); -//! a filter box hidden behind the toolbar's Filter toggle; a dir-first entry -//! list led by a `..` parent row (when not at the root) whose per-row actions -//! (open/download / follow-symlink / rename / chmod / delete) live in a -//! right-click context menu (PRD §6.3: hotkeys + right-click, not a permanent -//! toolbar); an inline edit form; and a bottom transfer tray that polls job -//! progress while the panel is open. - use std::collections::HashSet; use std::path::{Path, PathBuf}; use std::time::Duration; @@ -42,9 +21,6 @@ use crate::daemon::ssh::sftp::{remote_basename, remote_join, remote_parent, safe use crate::terminal::RemoteTerminal; use crate::ui::app::{CONTENT_INSET, Tty7App}; -/// The remote Files header's `⋯` entries. The directory-wide actions that used -/// to be a row of toolbar buttons; per-row actions stay on the row's own -/// right-click menu. #[derive(Clone, Copy)] enum SftpMenuAction { NewFolder, @@ -54,7 +30,6 @@ enum SftpMenuAction { ToggleHistory, } -/// One in-progress inline edit form in the panel. pub(crate) enum SftpEdit { NewFolder(gpui::Entity<InputState>), NewFile(gpui::Entity<InputState>), @@ -64,24 +39,11 @@ pub(crate) enum SftpEdit { }, Chmod { path: String, - /// The entry's current mode as `rwxr-xr-x`, shown beside the form's octal - /// field. The row itself no longer has room for a permissions column, so - /// this is where the readable form lives now. readable: String, input: gpui::Entity<InputState>, }, } -/// Which SSH connection this panel's requests run on — the *only* thing that -/// differs between an SSH pane and a remote workspace. -/// -/// A plain `Copy`-able bundle rather than a lookup at each call site, because -/// every request runs on a background executor: the pane entity is not reachable -/// from there, so the route has to be resolved on the UI thread and moved in. -/// Both arms end at the same `SftpManager` on the same daemon; a remote -/// workspace's transfers are local-daemon SFTP over the workspace's own -/// connection, which is what makes "drag a file to Finder" land on *this* -/// machine. #[derive(Clone, Debug)] pub(crate) struct SftpRoute { pane_id: u64, @@ -89,11 +51,6 @@ pub(crate) struct SftpRoute { } impl SftpRoute { - /// The route to a pane, given the workspace it belongs to (`None` for a - /// pane that owns its own connection). Public to the crate because the - /// panel is not the only caller any more: Tab completion lists a remote - /// directory over the same route, and must not grow a second copy of the - /// pane-vs-workspace decision. pub(crate) fn new(pane_id: u64, workspace: Option<crate::terminal::PaneWorkspace>) -> Self { Self { pane_id, workspace } } @@ -165,56 +122,24 @@ impl SftpRoute { } } -/// State for the remote file browser. One pane's listing at a time, bound to a -/// pane id — the detail panel shows one pane, so the browser follows it. pub(crate) struct SftpPanelState { - /// The pane whose listing is on screen, or `None` while the Files tab is - /// showing a local tree. Set by the Files render path from the detail pane, - /// not by a toggle: the browser is a *view of the pane*, so which pane you're - /// looking at is the only thing that decides it. pub(crate) open_pane_id: Option<u64>, - /// The remote workspace the open pane belongs to, when it is one. - /// Captured beside `open_pane_id` because every SFTP call needs it and - /// the calls run on a background executor, where the pane entity is out of - /// reach. `None` — the case for SSH panes — keeps the pane-addressed path. pub(crate) open_workspace: Option<crate::terminal::PaneWorkspace>, - /// The remote directory currently listed (absolute POSIX path). pub(crate) cwd: String, - /// Where each pane was last browsing, so switching panes — or tabs — and - /// coming back lands where you left rather than back at the shell cwd. Keyed - /// by pane id and dropped with the pane. pub(crate) cwds: std::collections::HashMap<u64, String>, pub(crate) entries: Vec<SftpEntry>, pub(crate) filter_input: gpui::Entity<InputState>, - /// Last listing error, shown in place of the list. pub(crate) error: Option<String>, - /// Latest transfer-job snapshots for the tray. pub(crate) jobs: Vec<SftpJobProgress>, - /// Job ids the user dismissed from the tray; filtered out until a fresh - /// transfer (a new id) reopens it. Cleared when the panel closes/reopens. dismissed_jobs: HashSet<u64>, - /// When set, the transfers footer is pinned open and shows the full history - /// (every job, including dismissed ones), toggled from the Files `⋯` menu. show_history: bool, - /// Whether the transfers footer is showing its per-job list. Collapsed by - /// default: a running transfer is a glance, not a watch. tray_expanded: bool, - /// A directory listing is in flight (the daemon round-trip runs off-thread, - /// so the UI never blocks). Guards feedback while the old listing stays up. pub(crate) loading: bool, - /// Bumped on every navigation so a slow/stale listing reply is discarded when - /// a newer navigation has already superseded it. nav_gen: u64, pub(crate) editing: Option<SftpEdit>, - /// When `Some`, the breadcrumb is replaced by a path text input ("type a - /// path" mode). Committed on Enter, cancelled on Esc/blur. pub(crate) editing_path: Option<gpui::Entity<InputState>>, - /// Keeps the path-input subscription alive while [`editing_path`] is set. editing_path_sub: Vec<Subscription>, - /// Bumped on every (re)open so a stale poll loop exits. pub(crate) poll_gen: u64, - /// Scroll position of the remote listing, owned here so the Files tab's - /// overlay scrollbar has a handle to read and drag (see `ui::scrollbar`). scroll: gpui::ScrollHandle, _subs: Vec<Subscription>, } @@ -222,7 +147,6 @@ pub(crate) struct SftpPanelState { impl SftpPanelState { pub(crate) fn new(window: &mut Window, cx: &mut Context<Tty7App>) -> Self { let filter_input = cx.new(|cx| InputState::new(window, cx).placeholder("Search")); - // Re-render the panel (and thus re-filter the list) on every keystroke. let sub = cx.subscribe_in(&filter_input, window, |_this, _input, ev, _w, cx| { if matches!(ev, gpui_component::input::InputEvent::Change) { cx.notify(); @@ -252,17 +176,11 @@ impl SftpPanelState { } } -// --------------------------------------------------------------------------- -// Pure helpers (tested). -// --------------------------------------------------------------------------- - fn is_dir_like(e: &SftpEntry) -> bool { matches!(e.kind, SftpEntryKind::Dir) || (matches!(e.kind, SftpEntryKind::Symlink) && e.target_is_dir) } -/// Directory-first, then case-insensitive by name; substring-filtered (case -/// insensitive). Returns borrows into `entries` in display order. pub(crate) fn sorted_filtered_entries<'a>( entries: &'a [SftpEntry], filter: &str, @@ -274,15 +192,12 @@ pub(crate) fn sorted_filtered_entries<'a>( .collect(); out.sort_by(|a, b| { let (ad, bd) = (is_dir_like(a), is_dir_like(b)); - // Directories first, then name. bd.cmp(&ad) .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) }); out } -/// Split a remote path into clickable breadcrumb segments: `(label, full_path)`, -/// always starting with the root `("/", "/")`. pub(crate) fn breadcrumb_segments(path: &str) -> Vec<(String, String)> { let mut out = vec![("/".to_string(), "/".to_string())]; let mut acc = String::new(); @@ -294,7 +209,6 @@ pub(crate) fn breadcrumb_segments(path: &str) -> Vec<(String, String)> { out } -/// Compact human-readable byte size (`1.5M`). fn human_size(bytes: u64) -> String { const UNITS: [&str; 5] = ["B", "K", "M", "G", "T"]; let mut value = bytes as f64; @@ -310,7 +224,6 @@ fn human_size(bytes: u64) -> String { } } -/// A `-rwxr-xr-x`-style mode string from Unix permission bits (low 9 bits). fn mode_string(mode: u32) -> String { let rwx = |bits: u32| { format!( @@ -328,7 +241,6 @@ fn mode_string(mode: u32) -> String { ) } -/// The daemon-process home directory used as the local base for transfers. fn local_home() -> PathBuf { std::env::var_os("HOME") .or_else(|| std::env::var_os("USERPROFILE")) @@ -336,35 +248,13 @@ fn local_home() -> PathBuf { .unwrap_or_else(|| PathBuf::from(".")) } -/// Where downloads land locally: `~/Downloads` (created on demand by the daemon). fn local_download_dir() -> PathBuf { local_home().join("Downloads") } -// --------------------------------------------------------------------------- -// Tty7App: open / navigate / operations. -// --------------------------------------------------------------------------- - impl Tty7App { - /// `ToggleSftp` / the palette's "SSH: Remote Files": show the remote browser, - /// which means putting the detail panel on its Files tab. The tab renders the - /// browser by itself once it's looking at a native-SSH pane, so there is no - /// separate panel to open — showing it is entirely "take me there". - /// - /// It still earns the `Toggle` in its name: pressing it again while the panel - /// is already sitting on Files closes the panel. A key you bound to reach - /// something should put it away too, and without this the binding is a dead - /// press whenever you're already there. - /// - /// A pane with no native connection (a foreground `ssh` typed into a local - /// shell, or a plain PTY) has nothing to list; the Files tab shows its local - /// tree instead, which is the right answer rather than an error. pub(crate) fn toggle_sftp(&mut self, _window: &mut Window, cx: &mut Context<Self>) { use crate::core::config::RightPanelTab; - // This window's own panel state, not `right_panel_open`: this toggles - // exactly what `toggle_right_panel` does, so the two agree on what - // "open" means even with no tabs to render into. (And not the config - // either — that is only what a *new* window starts with.) if self.right_panel_visible && self.right_panel_tab == RightPanelTab::Files { self.toggle_right_panel(cx); return; @@ -372,12 +262,6 @@ impl Tty7App { self.set_right_panel_tab(RightPanelTab::Files, cx); } - /// Point the browser at `pane_id`, or tear it down when the Files tab has - /// moved to a local pane (`None`). Called from the Files render path, so the - /// browser's lifetime is exactly "the detail panel is showing this remote - /// pane" — no open/close state of its own to fall out of step. - /// - /// Returns `true` when the caller should render the remote browser. pub(crate) fn sftp_sync_pane( &mut self, pane_id: Option<u64>, @@ -396,9 +280,6 @@ impl Tty7App { true } - /// Stop browsing: drop the listing and retire the poll loops. Transfers are - /// untouched — they run in the daemon and keep running; only this view of - /// them goes away. pub(crate) fn sftp_close_browser(&mut self, cx: &mut Context<Self>) { self.sftp_panel.open_pane_id = None; self.sftp_panel.entries.clear(); @@ -406,21 +287,12 @@ impl Tty7App { self.sftp_panel.editing = None; self.sftp_panel.editing_path = None; self.sftp_panel.editing_path_sub.clear(); - // The jobs are one pane's. Leaving them would have the footer report the - // old pane's transfers under the next one until its first poll lands — - // the same flash `sync_procs` clears the process list for. self.sftp_panel.jobs.clear(); self.sftp_panel.open_workspace = None; - // Invalidate the poll loop. self.sftp_panel.poll_gen = self.sftp_panel.poll_gen.wrapping_add(1); cx.notify(); } - /// How this panel's requests reach the far side: the open pane's own - /// connection, or — for a remote-workspace pane — the workspace's. - /// - /// Resolved on the UI thread and cloned into every background call, because - /// the pane entity is not reachable from a background executor. fn sftp_route(&self) -> SftpRoute { SftpRoute { pane_id: self.sftp_panel.open_pane_id.unwrap_or_default(), @@ -428,7 +300,6 @@ impl Tty7App { } } - /// The remote workspace a pane belongs to, read off the pane itself. fn pane_workspace( &self, pane_id: u64, @@ -456,7 +327,6 @@ impl Tty7App { self.sftp_poll_jobs(cx); self.sftp_start_polling(cx); - // Where this pane was last time you looked, else the shell's cwd. if let Some(start) = self .sftp_panel .cwds @@ -467,17 +337,9 @@ impl Tty7App { self.sftp_navigate(start, cx); return; } - // Neither: ask the far side where "." is. The shell only reports its cwd - // when tty7's shell integration is installed on the remote — which on a - // host you just connected to it usually isn't — and `/` is a poor place - // to open a file browser. SFTP's own REALPATH resolves to the login - // directory, which is where a fresh session actually is. self.sftp_navigate_login_dir(pane_id, cx); } - /// Resolve the session's login directory (`realpath "."`) and open there. - /// Falls back to `/` when the round-trip fails — a browser at the root still - /// works, and the error would be noise on a path nobody typed. fn sftp_navigate_login_dir(&mut self, pane_id: u64, cx: &mut Context<Self>) { self.sftp_panel.loading = true; let route = self.sftp_route(); @@ -486,8 +348,6 @@ impl Tty7App { .background_spawn(async move { route.op(SftpOp::Realpath { path: ".".into() }) }) .await; let _ = this.update(cx, |this, cx| { - // The browser may have moved on (pane switch, or the user typed a - // path) while the round-trip was out. if this.sftp_panel.open_pane_id != Some(pane_id) { return; } @@ -501,7 +361,6 @@ impl Tty7App { .detach(); } - /// The focused pane's OSC-7 cwd as an absolute remote path, if tracked. fn pane_shell_cwd(&self, pane_id: u64, window: &Window, cx: &App) -> Option<String> { let leaf = self .tabs @@ -517,16 +376,10 @@ impl Tty7App { s.starts_with('/').then_some(s) } - /// List `path` on the pane's SFTP session and show it. The daemon round-trip - /// (`sftp_list`) is a blocking socket request, so it runs on a background - /// executor — the UI thread keeps painting while a big or high-latency - /// directory loads. The old listing stays visible until the new one arrives; - /// errors are surfaced in the panel body rather than thrown away. pub(crate) fn sftp_navigate(&mut self, path: String, cx: &mut Context<Self>) { let Some(pane_id) = self.sftp_panel.open_pane_id else { return; }; - // A newer navigation invalidates any listing still in flight. self.sftp_panel.nav_gen = self.sftp_panel.nav_gen.wrapping_add(1); let generation = self.sftp_panel.nav_gen; self.sftp_panel.loading = true; @@ -539,8 +392,6 @@ impl Tty7App { .background_spawn(async move { route.list(&list_path) }) .await; let _ = this.update(cx, |this, cx| { - // Drop the reply if the panel moved on (closed, switched pane, or a - // later navigation started). if this.sftp_panel.open_pane_id != Some(pane_id) || this.sftp_panel.nav_gen != generation { @@ -550,20 +401,14 @@ impl Tty7App { match result { Ok(mut entries) => { entries.sort_by(|a, b| a.name.cmp(&b.name)); - // Remember where this pane got to, so coming back to it - // resumes rather than restarts. Recorded on arrival, not - // on the way out: only a directory that actually listed is - // worth returning to. this.sftp_panel.cwds.insert(pane_id, path.clone()); this.sftp_panel.cwd = path; this.sftp_panel.entries = entries; this.sftp_panel.error = None; - // Leave "type a path" mode once we've landed somewhere. this.sftp_panel.editing_path = None; this.sftp_panel.editing_path_sub.clear(); } Err(e) => { - // Keep the old listing; just report the failure. this.sftp_panel.error = Some(e); } } @@ -583,11 +428,6 @@ impl Tty7App { self.sftp_navigate(parent, cx); } - // --- editable path bar (tabby "type a path" mode) ---------------------- - - /// Replace the breadcrumb with a text input pre-filled with the current - /// directory, so you can type a destination directly. Enter navigates, - /// Esc/blur cancels back to the breadcrumb. pub(crate) fn sftp_begin_edit_path(&mut self, window: &mut Window, cx: &mut Context<Self>) { if self.sftp_panel.open_pane_id.is_none() { return; @@ -619,8 +459,6 @@ impl Tty7App { cx.notify(); return; } - // A successful navigate stays put; a failed one keeps the old listing and - // surfaces the error (breadcrumb is already restored above). self.sftp_navigate(value, cx); } @@ -630,7 +468,6 @@ impl Tty7App { cx.notify(); } - /// Clear the always-visible search box (bound to Esc while it is focused). pub(crate) fn sftp_clear_filter(&mut self, window: &mut Window, cx: &mut Context<Self>) { self.sftp_panel .filter_input @@ -638,9 +475,6 @@ impl Tty7App { cx.notify(); } - /// Enter an entry if it is a directory (or symlink-to-directory); do nothing - /// for a file. Bound to a row double-click — downloading a file is only ever - /// triggered explicitly from the right-click menu, never by clicking. pub(crate) fn sftp_enter_dir(&mut self, entry: SftpEntry, cx: &mut Context<Self>) { if is_dir_like(&entry) { let target = remote_join(&self.sftp_panel.cwd, &entry.name); @@ -648,8 +482,6 @@ impl Tty7App { } } - /// Context-menu primary action: enter a directory (or symlink-to-directory), - /// or download a file/other symlink. pub(crate) fn sftp_open_entry(&mut self, entry: SftpEntry, cx: &mut Context<Self>) { let target = remote_join(&self.sftp_panel.cwd, &entry.name); if is_dir_like(&entry) { @@ -663,10 +495,6 @@ impl Tty7App { let Some(pane_id) = self.sftp_panel.open_pane_id else { return; }; - // The entry name is server-supplied: a traversing name (`..`, `a/b`, - // absolute — which `Path::join` would let replace the base entirely) - // must not become the local destination. Same guard the daemon applies - // to names discovered during the recursive walk. if !safe_local_name(&entry.name) { self.sftp_panel.error = Some(format!("refusing unsafe remote name {:?}", entry.name)); cx.notify(); @@ -695,8 +523,6 @@ impl Tty7App { return; }; let path = remote_join(&self.sftp_panel.cwd, &entry.name); - // A directory (not a symlink to one) deletes recursively; everything else - // is a plain file unlink. let op = if matches!(entry.kind, SftpEntryKind::Dir) { SftpOp::RemoveDir { path } } else { @@ -705,9 +531,6 @@ impl Tty7App { self.sftp_run_op(pane_id, op, cx); } - /// Follow a symlink: readlink, then navigate to the resolved target's - /// directory (or the target itself when it is a directory). The readlink - /// round-trip runs off-thread so a slow link never freezes the UI. pub(crate) fn sftp_follow_symlink(&mut self, entry: SftpEntry, cx: &mut Context<Self>) { let Some(pane_id) = self.sftp_panel.open_pane_id else { return; @@ -730,7 +553,6 @@ impl Tty7App { } else { remote_join(&cwd, &target) }; - // Navigate to the target if it's a directory, else its parent. let dest = if entry.target_is_dir { resolved } else { @@ -749,8 +571,6 @@ impl Tty7App { .detach(); } - /// Run a one-shot SFTP op (mkdir/rename/chmod/delete) off-thread, then refresh - /// the listing on success. Keeps the UI responsive during the round-trip. fn sftp_run_op(&mut self, pane_id: u64, op: SftpOp, cx: &mut Context<Self>) { let route = self.sftp_route(); cx.spawn(async move |this, cx| { @@ -774,8 +594,6 @@ impl Tty7App { .detach(); } - // --- inline edit forms ------------------------------------------------- - pub(crate) fn sftp_begin_new_folder(&mut self, window: &mut Window, cx: &mut Context<Self>) { let input = cx.new(|cx| InputState::new(window, cx).placeholder("New folder name")); self.sftp_panel.editing = Some(SftpEdit::NewFolder(input)); @@ -880,10 +698,6 @@ impl Tty7App { } } - // --- uploads (picker + drag&drop) -------------------------------------- - - /// FR-T5 fallback / toolbar action: open a native file picker and upload the - /// chosen paths into the current remote directory. pub(crate) fn sftp_pick_upload(&mut self, cx: &mut Context<Self>) { if self.sftp_panel.open_pane_id.is_none() { return; @@ -902,8 +716,6 @@ impl Tty7App { .detach(); } - /// Upload local paths into the current remote directory (used by the picker - /// and by FR-T5 Finder drops). Directories upload recursively. pub(crate) fn sftp_upload_paths(&mut self, paths: Vec<PathBuf>, cx: &mut Context<Self>) { let Some(pane_id) = self.sftp_panel.open_pane_id else { return; @@ -931,34 +743,24 @@ impl Tty7App { } self.sftp_poll_jobs(cx); self.sftp_start_polling(cx); - // A little later the uploaded entries will exist; refresh the listing now - // so at least already-finished small files appear. self.sftp_refresh(cx); } - // --- transfer tray ----------------------------------------------------- - pub(crate) fn sftp_cancel_job(&mut self, job_id: u64, cx: &mut Context<Self>) { self.sftp_panel.jobs = RemoteTerminal::sftp_transfer_cancel(job_id); cx.notify(); } - /// Toggle the transfers/history view (header button): when on, the tray is - /// pinned open and lists every transfer, dismissed or not. pub(crate) fn sftp_toggle_history(&mut self, cx: &mut Context<Self>) { self.sftp_panel.show_history = !self.sftp_panel.show_history; cx.notify(); } - /// Expand/collapse the transfers footer's per-job list (clicking its summary - /// line). History mode forces it open, so this only bites outside history. pub(crate) fn sftp_toggle_tray(&mut self, cx: &mut Context<Self>) { self.sftp_panel.tray_expanded = !self.sftp_panel.tray_expanded; cx.notify(); } - /// Close the transfers tray: leave the history view and hide every - /// currently-known job. A later transfer (a new job id) reopens the auto-tray. pub(crate) fn sftp_dismiss_tray(&mut self, cx: &mut Context<Self>) { let ids: Vec<u64> = self.sftp_panel.jobs.iter().map(|j| j.job_id).collect(); self.sftp_panel.dismissed_jobs.extend(ids); @@ -966,8 +768,6 @@ impl Tty7App { cx.notify(); } - /// Reveal a finished download in the OS file manager (Finder), which opens its - /// containing folder with the file selected. pub(crate) fn sftp_reveal_download(&self, local: String, cx: &mut Context<Self>) { cx.reveal_path(Path::new(&local)); } @@ -979,8 +779,6 @@ impl Tty7App { } } - /// Spawn a background poll loop that refreshes the tray every 500ms while the - /// panel is open. `poll_gen` guards against overlapping loops after re-opens. fn sftp_start_polling(&mut self, cx: &mut Context<Self>) { self.sftp_panel.poll_gen = self.sftp_panel.poll_gen.wrapping_add(1); let generation = self.sftp_panel.poll_gen; @@ -989,33 +787,15 @@ impl Tty7App { cx.background_executor() .timer(Duration::from_millis(500)) .await; - // Read the pane still bound to this generation. let pane = this .update(cx, |this, cx| { if this.sftp_panel.poll_gen != generation { return None; } - // The browser has no window of its own — it's a view - // inside the detail panel — so a closed panel means - // nobody is looking, and this loop is a daemon - // round-trip plus a full re-render twice a second for - // a column that isn't on screen. - // - // The render path retires the browser too, and does it - // a frame sooner. This is the backstop: owning the - // check here means the loop's lifetime doesn't depend - // on `render_right_panel` being called on every frame, - // which is a property of a caller it can't see. - // Retire rather than pause — reopening the panel on - // Files runs the browser's normal open path, which - // starts a fresh loop. if !this.right_panel_open(cx) { this.sftp_close_browser(cx); return None; } - // The route, not just the id: a remote workspace's - // jobs are filed under the workspace, so polling by - // pane id would come back empty every time. this.sftp_panel .open_pane_id .is_some() @@ -1024,8 +804,6 @@ impl Tty7App { .ok() .flatten(); let Some(route) = pane else { break }; - // Poll off the main thread so the blocking control round-trip - // doesn't jank the UI. let jobs = cx .background_spawn(async move { route.transfer_list() }) .await; @@ -1047,22 +825,6 @@ impl Tty7App { .detach(); } - // --------------------------------------------------------------------- - // Rendering. - // --------------------------------------------------------------------- - - /// The Files tab's remote mode: the pane's SFTP browser, rendered as the - /// panel's own column rather than the bottom dock it used to be. Same - /// interaction as before — a breadcrumb you can type into, a filter, a - /// dir-first list led by `..`, per-row right-click actions — relaid out for a - /// ~260px column: the toolbar collapses to a refresh tile plus a `⋯`, and the - /// permissions column goes (it's still on the right-click `chmod…`), because - /// name + size + mode can't share this width without all three truncating. - /// - /// `host` names the machine in the header's count slot. It earns that slot: - /// this tab silently swaps between a local tree and a remote filesystem as the - /// detail pane changes, and the list carries rename and delete — so which - /// machine you're deleting on is not something to leave implicit. pub(crate) fn render_panel_sftp( &mut self, host: String, @@ -1072,9 +834,6 @@ impl Tty7App { let controls = self.sftp_controls(cx); let title = self.panel_title("Files", Some(host), Some(controls), window, cx); let breadcrumb = self.render_sftp_breadcrumb(cx); - // The shared panel search box, plus the one behaviour the old SFTP header - // had that the local tree's doesn't: Esc clears the filter rather than - // falling through to the terminal. let filter = div() .id("panel-sftp-filter") .child(self.panel_search(&self.sftp_panel.filter_input.clone(), cx)) @@ -1099,18 +858,12 @@ impl Tty7App { list, &self.sftp_panel.scroll, )) - // FR-T5: a Finder drop uploads onto the current directory. .on_drop(cx.listener(|this, paths: &ExternalPaths, _window, cx| { this.sftp_upload_paths(paths.paths().to_vec(), cx); })) .into_any_element() } - /// The remote Files header's controls: refresh, and a `⋯` for everything that - /// isn't a per-row action. Two tiles is what the header has room for beside a - /// hostname, and refresh is the one that earns a permanent slot — a remote - /// listing has no watcher behind it, so it's the only way to see a change - /// somebody else made. fn sftp_controls(&self, cx: &mut Context<Self>) -> AnyElement { let history = self.sftp_panel.show_history; let tile = |button: Button, selected: bool, cx: &mut Context<Self>| { @@ -1125,10 +878,6 @@ impl Tty7App { .items_center() .gap(px(2.)) .child( - // `occlude()` like the `⋯` beside it: `panel_title` is a - // `WindowControlArea::Drag` now (every header in the window is), - // which on Windows maps to HTCAPTION — the OS claims the press - // before gpui hit-tests, so a bare button never fires its click. div().occlude().child( tile( Button::new("panel-sftp-refresh") @@ -1194,8 +943,6 @@ impl Tty7App { .into_any_element() } - /// One arm per `⋯` entry. A single dispatcher rather than five closures each - /// re-deriving the weak handle, since the menu items all need `&mut Window`. fn sftp_menu_action( &mut self, action: SftpMenuAction, @@ -1217,10 +964,6 @@ impl Tty7App { } } - /// The path bar. Normally a clickable breadcrumb (root shown as `SFTP`, like - /// tabby); double-clicking anywhere on it switches to a text input so you can - /// type a destination directly. Enter navigates, Esc/blur returns to the - /// breadcrumb. fn render_sftp_breadcrumb(&self, cx: &mut Context<Self>) -> Stateful<Div> { if let Some(input) = &self.sftp_panel.editing_path { return h_flex() @@ -1228,8 +971,6 @@ impl Tty7App { .px(px(CONTENT_INSET)) .pb(px(2.)) .child(Input::new(input).xsmall()) - // Esc cancels back to the breadcrumb (blur also cancels, via the - // input subscription). .on_key_down(cx.listener(|this, ev: &gpui::KeyDownEvent, _window, cx| { if ev.keystroke.key == "escape" { this.sftp_cancel_edit_path(cx); @@ -1239,7 +980,6 @@ impl Tty7App { let foreground = cx.theme().foreground; let muted = cx.theme().muted_foreground; - // Double-click anywhere on the bar enters "type a path" mode. let mut row = h_flex() .id("sftp-breadcrumb") .flex_wrap() @@ -1250,11 +990,6 @@ impl Tty7App { .on_double_click( cx.listener(|this, _, window, cx| this.sftp_begin_edit_path(window, cx)), ); - // Root: `/`, the actual path — the header above already says which machine - // this is, so the old "SFTP" label would be naming the protocol in the one - // place the user is reading a path. The current (last) segment reads in - // full ink; ancestors are muted but still clearly legible (the theme - // `accent` was near-invisible here). let segments = breadcrumb_segments(&self.sftp_panel.cwd); let last = segments.len().saturating_sub(1); for (i, (label, path)) in segments.into_iter().enumerate() { @@ -1284,11 +1019,9 @@ impl Tty7App { ), ); } - // A flex-grow spacer so the double-click target spans the whole row. row.child(div().flex_1().min_w(px(20.)).h(px(16.))) } - /// The active inline edit form (new folder / rename / chmod), if any. fn render_sftp_edit_form(&self, cx: &mut Context<Self>) -> Option<Div> { let secondary = cx.theme().secondary; let border = cx.theme().border; @@ -1344,8 +1077,6 @@ impl Tty7App { fn render_sftp_list(&self, cx: &mut Context<Self>) -> Stateful<Div> { let danger = cx.theme().danger; let muted = cx.theme().muted_foreground; - // Rows inset themselves so their hover capsule bleeds into the gutter, the - // same way the local tree's and the Changes list's do. let container = div() .id("sftp-list") .flex_1() @@ -1371,13 +1102,9 @@ impl Tty7App { let filter = self.sftp_panel.filter_input.read(cx).value().to_string(); let entries = sorted_filtered_entries(&self.sftp_panel.entries, &filter); - // A `..` parent row leads the list when not at the root and not - // actively filtering — the file-manager convention for going up. let show_go_up = self.sftp_panel.cwd != "/" && filter.trim().is_empty(); if entries.is_empty() && !show_go_up { - // Distinguish "still loading" from a genuinely empty directory so a - // slow listing doesn't read as empty. let text = if self.sftp_panel.loading { "Loading…" } else { @@ -1396,12 +1123,8 @@ impl Tty7App { container.child(list) } - /// The leading `..` parent row (shown when not at the filesystem root), styled - /// like a directory entry (WinRAR/file-manager convention) so it reads as - /// "the parent folder" and matches the rows below rather than a toolbar action. fn render_sftp_go_up_row(&self, cx: &mut Context<Self>) -> AnyElement { let foreground = cx.theme().foreground; - // Matches the directory rows below it, which paint on the popover surface. let sf = cx.global::<crate::ui::presets::Surfaces>().popover; h_flex() .id("sftp-go-up") @@ -1423,23 +1146,9 @@ impl Tty7App { .into_any_element() } - /// One entry row: icon + name (+ a `→` marker for symlinks) + a muted size. - /// The permissions column the bottom dock had is gone — at panel width, name, - /// size and mode all three truncated, and mode is a specialist datum that the - /// row's `chmod…` still reads out on demand. - /// - /// Per-row actions (open/download, follow, rename, chmod, - /// delete) live in the right-click context menu built by - /// [`sftp_row_context_menu`](Self::sftp_row_context_menu) rather than as a - /// row of inline buttons (PRD §6.3: hotkeys + right-click, not a permanent - /// toolbar). Left-click / double-click on the name still opens a directory or - /// downloads a file — the primary interaction is unchanged. fn render_sftp_row(&self, entry: &SftpEntry, cx: &mut Context<Self>) -> AnyElement { let foreground = cx.theme().foreground; let muted = cx.theme().muted_foreground; - // Directories use the full foreground ink so they read clearly against the - // monochrome UI (files stay muted); a coloured folder clashed with the - // theme, and the old `accent` was near-invisible in the light theme. let dir_color = foreground; let list_hover = cx.theme().list_hover; let entry = entry.clone(); @@ -1464,8 +1173,6 @@ impl Tty7App { let open_entry = entry.clone(); let menu_entry = entry.clone(); - // Weak app handle so the context-menu item handlers (which get `&mut App`, - // not `Context<Self>`) can call back into `Tty7App`. let app = cx.entity().downgrade(); h_flex() @@ -1478,8 +1185,6 @@ impl Tty7App { .rounded(cx.theme().radius) .cursor_pointer() .hover(|s| s.bg(list_hover)) - // Double-click enters a directory; files never download from a click - // (only from the right-click menu). .on_double_click( cx.listener(move |this, _, _w, cx| this.sftp_enter_dir(open_entry.clone(), cx)), ) @@ -1497,9 +1202,6 @@ impl Tty7App { .truncate() .child(name_label), ) - // Size trails the name, right-aligned in its own column so the sizes - // line up down the list. Directories contribute an empty string, so - // the column simply doesn't draw for them. .child(div().flex_none().text_xs().text_color(muted).child(size)) .context_menu(move |menu, _window, cx| { let danger = cx.theme().danger; @@ -1508,10 +1210,6 @@ impl Tty7App { .into_any_element() } - /// Build the per-row right-click menu: the primary open/download action - /// first, an optional follow-symlink, rename, chmod, and finally the - /// destructive delete (separated). Each item drives the same `Tty7App` - /// handler the old inline buttons did, via the weak `app` handle. fn sftp_row_context_menu( menu: gpui_component::menu::PopupMenu, entry: &SftpEntry, @@ -1522,8 +1220,6 @@ impl Tty7App { ) -> gpui_component::menu::PopupMenu { let mut menu = menu.min_w(px(180.)); - // Primary action, first: open a directory or download a file. Reuses - // `sftp_open_entry`, which dispatches on the entry kind. let primary_label = if dir_like { "Open" } else { "Download" }; menu = menu.item(PopupMenuItem::new(primary_label).on_click({ let app = app.clone(); @@ -1534,7 +1230,6 @@ impl Tty7App { } })); - // Follow symlink — only for symlinks. if is_symlink { menu = menu.item(PopupMenuItem::new("Follow symlink").on_click({ let app = app.clone(); @@ -1565,8 +1260,6 @@ impl Tty7App { })) .separator(); - // Destructive, rendered last in danger red and set apart by the - // separator above. menu.item( PopupMenuItem::element(move |_window, _cx| div().text_color(danger).child("Delete")) .on_click({ @@ -1580,26 +1273,7 @@ impl Tty7App { ) } - /// The transfers footer, pinned to the bottom of the detail panel across all - /// four of its tabs rather than living inside Files. - /// - /// That placement is deliberate: a transfer belongs to the *pane*, not to the - /// tab you happen to be reading, so going to Info to check a port shouldn't - /// make a running upload disappear. It stays pane-scoped for the same reason — - /// aggregating every pane's jobs would quietly turn the panel into a - /// window-level transfer centre, which is not what this column is. - /// - /// Nothing is lost when it goes away: the jobs live in the daemon, keyed by - /// pane (`sftp_transfer_list`), so switching panes and coming back re-queries - /// them intact. - /// - /// Collapsed by default — one line summarising the run, with its own progress - /// underline — because a transfer is something you glance at, not something - /// you watch. Clicking the line expands the per-job list. pub(crate) fn sftp_transfers_footer(&self, cx: &mut Context<Self>) -> Option<AnyElement> { - // Only the pane the panel is showing. `open_pane_id` is set by the Files - // tab, so a transfer started there stays visible from any tab — but only - // while that pane is the one on screen. self.sftp_panel.open_pane_id?; let history = self.sftp_panel.show_history; let jobs: Vec<&SftpJobProgress> = self @@ -1608,15 +1282,10 @@ impl Tty7App { .iter() .filter(|j| history || !self.sftp_panel.dismissed_jobs.contains(&j.job_id)) .collect(); - // Auto mode with nothing to show → no footer at all. History mode stays up - // (with an empty-state note) so the menu item always reveals something. if jobs.is_empty() && !history { return None; } - // Colours copied out rather than held as a `theme` binding: the expanded - // body below needs `&mut cx` for its rows, which an outstanding theme - // borrow would block. let muted = cx.theme().muted_foreground; let danger = cx.theme().danger; let accent = cx.theme().accent; @@ -1625,9 +1294,6 @@ impl Tty7App { let hover = gpui::rgb(cx.global::<crate::ui::presets::Surfaces>().sidebar.hover); let expanded = self.sftp_panel.tray_expanded || history; - // The summary line: how many are moving and how far along the run is, as - // one number. Bytes across jobs, not a mean of percentages, so a big file - // beside a small one doesn't read as half done the moment the small one is. let running = jobs .iter() .filter(|j| matches!(j.state, SftpJobState::Running)) @@ -1702,8 +1368,6 @@ impl Tty7App { ), ); - // The collapsed bar carries the run's progress as a hairline along its own - // bottom edge, so "how far along" survives the collapse. let underline = div().h(px(2.)).w_full().bg(border).child( div() .h_full() @@ -1730,8 +1394,6 @@ impl Tty7App { }; div() .id("sftp-transfers-list") - // Never more than a third of the column: the footer reports on the - // panel, it doesn't become it. .max_h(px(200.)) .overflow_y_scroll() .child(inner) @@ -1789,7 +1451,6 @@ impl Tty7App { }; let job_id = job.job_id; let running = matches!(job.state, SftpJobState::Running); - // A finished download can be revealed in Finder from its local path. let done_download = matches!(job.state, SftpJobState::Done) && matches!(job.kind, SftpTransferKind::Download) && !job.local.is_empty(); @@ -1835,7 +1496,6 @@ impl Tty7App { }), ) .child( - // A thin progress bar. div().h(px(3.)).w_full().rounded_full().bg(border).child( div() .h_full() @@ -1874,7 +1534,6 @@ mod tests { ("deploy".to_string(), "/home/deploy".to_string()), ] ); - // Unicode components survive and build correct cumulative paths. assert_eq!( breadcrumb_segments("/项目/子"), vec![ @@ -1899,7 +1558,6 @@ mod tests { .iter() .map(|e| e.name.as_str()) .collect(); - // Dir-likes first (Alpha, apple, link-to-dir), then files/other symlinks. assert_eq!( sorted, vec![ @@ -1920,9 +1578,6 @@ mod tests { entry("src", SftpEntryKind::Dir, false), entry("Cargo.toml", SftpEntryKind::File, false), ]; - // Filter "a" matches "Cargo.toml" (lowercase a) and "README.md" (the - // uppercase A) — exercising case-insensitive substring matching — but not - // "src". Sorted by name, "Cargo.toml" precedes "README.md". let names: Vec<&str> = sorted_filtered_entries(&entries, "a") .iter() .map(|e| e.name.as_str()) @@ -1962,8 +1617,6 @@ mod gpui_tests { cx.set_global(Config::default()); crate::ui::keymap::init(cx); }); - // Wrapped in a `Root` like `main.rs` does — gpui-component widgets in the - // panel reach for it on the window. let window = cx.add_window(|window, cx| { let app = cx.new(|cx| Tty7App::with_session(None, Some(Session::default()), window, cx)); @@ -1982,9 +1635,6 @@ mod gpui_tests { (app, vcx) } - /// The *window's* panel state, not the config's: the config is only what a - /// newly opened window starts with, so asserting on it would pass even if - /// this window's panel never moved. fn panel(app: &Entity<Tty7App>, vcx: &mut VisualTestContext) -> (bool, RightPanelTab) { vcx.update(|_, cx| { let app = app.read(cx); @@ -1992,31 +1642,23 @@ mod gpui_tests { }) } - /// `ToggleSftp` has to earn its name: it takes you to Files, and pressing it - /// again there puts the panel away. It used to only ever open, so a key bound - /// to it was a dead press once you'd arrived. #[gpui::test] fn toggle_sftp_opens_files_then_closes_the_panel(cx: &mut TestAppContext) { let (app, mut vcx) = harness(cx); - // From closed: opens the panel on Files. app.update_in(&mut vcx, |app, window, cx| { app.right_panel_visible = false; app.toggle_sftp(window, cx); }); assert_eq!(panel(&app, &mut vcx), (true, RightPanelTab::Files)); - // Already there: puts it away rather than re-selecting the same tab. app.update_in(&mut vcx, |app, window, cx| app.toggle_sftp(window, cx)); assert!(!panel(&app, &mut vcx).0, "second press should close"); - // And back again. app.update_in(&mut vcx, |app, window, cx| app.toggle_sftp(window, cx)); assert_eq!(panel(&app, &mut vcx), (true, RightPanelTab::Files)); } - /// Open on another tab, `ToggleSftp` is still "take me there" — it switches to - /// Files rather than closing a panel the user is using for something else. #[gpui::test] fn toggle_sftp_switches_tabs_before_it_closes(cx: &mut TestAppContext) { let (app, mut vcx) = harness(cx); diff --git a/src/ui/ssh_connect.rs b/src/ui/ssh_connect.rs index 64226b36..06c6699b 100644 --- a/src/ui/ssh_connect.rs +++ b/src/ui/ssh_connect.rs @@ -1,20 +1,3 @@ -//! Pre-connect credential resolution (WS3): the single place OS-keychain secrets -//! and profile references are resolved into a self-contained [`NativeSshSpec`] for -//! the daemon's native (russh) path. -//! -//! [`build_native_ssh_spec`] turns a stored [`SshProfile`] into the wire spec: -//! it looks up the endpoint password and per-key passphrases from the keychain, -//! resolves the `jump_host` profile chain into nested specs, expands `%h`/`%r` -//! identity-file placeholders, and maps the profile's proxy / forwards / algorithm -//! fields onto the protocol types. The daemon never reads the keychain or the -//! profile store — everything it needs rides this spec once, over the local socket -//! (secrets redacted in `Debug`; see `NativeSshSpec`). -//! -//! WS6 wires the UI entry points to this module: the palette connect flow, the -//! profile editor, QuickConnect, and the reconnect/restore paths all resolve -//! their specs through here (see [`Tty7App::connect_ssh_profile`], -//! [`Tty7App::quick_connect`], and [`resolve_persisted_ssh_spec`]). - use std::collections::{HashMap, HashSet}; use uuid::Uuid; @@ -31,10 +14,6 @@ use crate::daemon::protocol::{ use super::app::Tty7App; impl Tty7App { - /// Resolve a stored profile into a fully self-contained [`NativeSshSpec`], - /// pulling secrets from the OS keychain and the jump chain from the profile - /// store. The one place secrets enter a spec (WS3). Reads the global - /// `ssh_profiles` (for jump-host resolution) and `verify_host_keys` fallback. pub(crate) fn native_ssh_spec_for_profile( &self, profile: &SshProfile, @@ -49,8 +28,6 @@ impl Tty7App { ) } - /// Connect a saved profile (PRD FR-P3) over the native (russh) engine — the - /// only SSH path. Bumps the profile's frecency. pub(crate) fn connect_ssh_profile( &mut self, profile_id: uuid::Uuid, @@ -71,14 +48,6 @@ impl Tty7App { self.open_native_ssh_tab(spec, window, cx); } - /// QuickConnect to a typed `user@host[:port]` target (PRD FR-P4), always via - /// the native path. Builds a transient profile so keychain lookup by endpoint - /// still applies (a QuickConnect can reuse a remembered password). - /// - /// `ssh <target>` semantics: a host naming a `~/.ssh/config` alias resolves - /// through it (HostName/User/Port/IdentityFile/ProxyJump), with the typed - /// `user@` / `:port` overriding the config's values. The palette lists only - /// saved profiles, so this is how a config alias connects without importing. pub(crate) fn quick_connect( &mut self, qc: crate::core::ssh_profile::QuickConnect, @@ -114,12 +83,6 @@ impl Tty7App { self.open_native_ssh_tab(spec, window, cx); } - /// Reconnect the focused native-SSH pane after it dropped (PRD FR-E4). A - /// no-op unless the focused pane is a *dead* native-SSH pane. Re-resolves - /// credentials from the saved profile when the pane's persisted spec names one - /// (`profile_id`), otherwise reuses the secret-free spec and lets the auth - /// sheets fill in. Respawns in the same tab/split slot; the daemon rebuilds - /// the profile's preconfigured forwards on connect. pub(crate) fn restart_ssh_session( &mut self, window: &mut gpui::Window, @@ -142,9 +105,6 @@ impl Tty7App { self.respawn_native_ssh_in_place(&view, resolved, window, cx); } - /// If the persisted (secret-free) spec names a saved profile that still - /// exists, rebuild it from the profile so keychain secrets are re-applied; - /// otherwise return the spec unchanged (the auth sheets will prompt). fn resolve_restart_spec( &self, spec: Box<crate::daemon::protocol::NativeSshSpec>, @@ -153,7 +113,6 @@ impl Tty7App { resolve_persisted_ssh_spec(spec, cx) } - /// The focused pane's terminal view, if any. fn focused_pane_view( &self, window: &gpui::Window, @@ -165,7 +124,6 @@ impl Tty7App { .focused_or_first(window, cx) } - /// Record a connect against a profile's frecency stats (FR-P3). fn bump_ssh_frecency(&mut self, profile_id: uuid::Uuid, cx: &mut gpui::Context<Self>) { self.update_config(cx, |cfg| { let entry = cfg.ssh_profile_frecency.entry(profile_id).or_default(); @@ -175,12 +133,6 @@ impl Tty7App { } } -/// Re-resolve a persisted (secret-free) [`NativeSshSpec`] for reconnection -/// (FR-E4/C2). When the spec names a saved profile that still exists, rebuild it -/// from that profile so keychain secrets are re-applied; otherwise return the -/// spec unchanged and let the in-pane auth sheets prompt. A free function so both -/// the in-place reconnect and session-restore (which has no `Tty7App` yet) share -/// it. pub(crate) fn resolve_persisted_ssh_spec( spec: Box<crate::daemon::protocol::NativeSshSpec>, cx: &gpui::App, @@ -202,10 +154,6 @@ pub(crate) fn resolve_persisted_ssh_spec( } } -/// Build a [`NativeSshSpec`] from `profile`, resolving keychain secrets via -/// `store`, jump hosts against `profiles`, and using `global_verify_host_keys` -/// when the profile leaves its `verify_host_keys` unset. Pure and store-injected -/// so it is unit-testable with an in-memory keychain. pub(crate) fn build_native_ssh_spec( profile: &SshProfile, profiles: &[SshProfile], @@ -232,8 +180,6 @@ fn build_spec_inner( ) -> NativeSshSpec { let identity_files = profile.expanded_identity_files(); - // Password: only resolve when the auth mode could use one (Auto or Password), - // so a pure-key profile doesn't pin a stale keychain read into the spec. let password = if matches!(profile.auth, AuthMode::Auto | AuthMode::Password) { store .password_for(&profile.user, &profile.host, profile.port) @@ -243,8 +189,6 @@ fn build_spec_inner( None }; - // Key passphrases: keyed by identity-file path (as it appears in the spec's - // `identity_files`), resolved from the key's content hash (WS1's scheme). let mut key_passphrases: HashMap<String, String> = HashMap::new(); if matches!(profile.auth, AuthMode::Auto | AuthMode::PublicKey) { for path in &identity_files { @@ -258,8 +202,6 @@ fn build_spec_inner( } } - // Jump chain: resolve the referenced profile and recurse, guarding against - // cycles (a profile that jumps through itself, directly or transitively). let jump = profile .jump_host .and_then(|id| { @@ -317,8 +259,6 @@ fn map_auth_mode(auth: AuthMode) -> SshAuthMode { } } -/// Proxy precedence: an explicit `ProxyCommand` wins, then SOCKS5, then HTTP. -/// (A jump host is carried separately on `NativeSshSpec::jump`.) fn map_proxy(profile: &SshProfile) -> SshProxy { if let Some(cmd) = &profile.proxy_command { if !cmd.trim().is_empty() { @@ -369,24 +309,12 @@ fn map_algorithms(a: &Algorithms) -> SshAlgorithms { } } -/// Resolves a raw `~/.ssh/config` jump hop into a transient profile plus its own -/// raw `ProxyJump`. Injected so [`native_spec_from_transient_profile`] is testable -/// without touching the real `~/.ssh/config` (production passes a closure over -/// [`crate::core::ssh_config::resolve_alias_to_profile`]). pub(crate) type AliasResolver<'a> = dyn Fn(&str) -> Option<(SshProfile, Option<String>)> + 'a; -/// The standard [`AliasResolver`]: resolve against the live `~/.ssh/config`. -/// Shared by every typed-connect path (QuickConnect, "SSH: Add Connection…"). pub(crate) fn config_alias_resolver(alias: &str) -> Option<(SshProfile, Option<String>)> { crate::core::ssh_config::resolve_alias_to_profile(alias).map(|r| (r.profile, r.proxy_jump)) } -/// Build a [`NativeSshSpec`] from a **transient** (unsaved) profile — resolved -/// from a `~/.ssh/config` alias or a typed connect line — whose jump host is a -/// raw string rather than a stored profile id. The base spec is built like any -/// profile (keychain lookup by endpoint still applies); the raw `proxy_jump` is -/// then resolved into the nested jump chain via `resolve_alias` (recursing through -/// config alias hops and `user@host[:port]` targets), guarding against cycles. pub(crate) fn native_spec_from_transient_profile( profile: &SshProfile, proxy_jump: Option<String>, @@ -397,7 +325,6 @@ pub(crate) fn native_spec_from_transient_profile( let mut spec = build_native_ssh_spec(profile, &[], store, global_verify_host_keys); if let Some(raw) = proxy_jump { let mut visited = HashSet::new(); - // Guard against an alias whose jump chain leads back to itself. visited.insert(profile.name.clone()); spec.jump = resolve_jump_chain( &raw, @@ -410,9 +337,6 @@ pub(crate) fn native_spec_from_transient_profile( spec } -/// Resolve a (possibly comma-separated) `ProxyJump` value into a nested jump spec. -/// A chain `a,b` connects `a` first, then tunnels to `b`; `b` is this connection's -/// direct jump and `a` is `b`'s jump (deepest = first-connected). fn resolve_jump_chain( raw: &str, store: &dyn CredentialStore, @@ -436,13 +360,9 @@ fn build_jump_from_hops( visited: &mut HashSet<String>, ) -> Option<Box<NativeSshSpec>> { let (last, earlier) = hops.split_last()?; - // Cycle guard: a hop already on the chain terminates the recursion. if !visited.insert((*last).to_string()) { return None; } - // A config alias resolves to its own transient profile (and its own ProxyJump, - // honored only when this hop wasn't given an explicit earlier chain); an - // unknown hop is parsed as a `user@host[:port]` target. let (profile, own_jump) = match resolve_alias(last) { Some((profile, own_jump)) => (profile, if earlier.is_empty() { own_jump } else { None }), None => (transient_profile_from_target(last)?, None), @@ -458,7 +378,6 @@ fn build_jump_from_hops( Some(Box::new(spec)) } -/// A transient profile from a bare `user@host[:port]` jump/connect target. fn transient_profile_from_target(target: &str) -> Option<SshProfile> { let qc = crate::core::ssh_profile::parse_quick_connect(target)?; let mut profile = SshProfile::new(qc.host.clone()); @@ -498,7 +417,6 @@ mod tests { let spec = build_native_ssh_spec(&p, &[], &store, true); assert_eq!(spec.password.as_deref(), Some("hunter2")); - // A key-only profile must not pull the password into the spec. p.auth = AuthMode::PublicKey; let spec = build_native_ssh_spec(&p, &[], &store, true); assert_eq!(spec.password, None); @@ -522,7 +440,6 @@ mod tests { #[test] fn jump_cycle_is_broken_not_infinite() { - // Two profiles that jump through each other. let mut a = profile("a", "a.example.com", "u"); let mut b = profile("b", "b.example.com", "u"); a.jump_host = Some(b.id); @@ -530,7 +447,6 @@ mod tests { let profiles = vec![a.clone(), b.clone()]; let store = InMemoryCredentialStore::new(); - // Must terminate; the cycle is cut when a profile is revisited. let spec = build_native_ssh_spec(&a, &profiles, &store, true); let jump = spec.jump.expect("first hop resolves"); assert_eq!(jump.host, "b.example.com"); @@ -546,7 +462,6 @@ mod tests { assert!(!build_native_ssh_spec(&p, &[], &store, false).verify_host_keys); assert!(build_native_ssh_spec(&p, &[], &store, true).verify_host_keys); - // A profile override wins over the global. p.verify_host_keys = Some(false); assert!(!build_native_ssh_spec(&p, &[], &store, true).verify_host_keys); } @@ -554,10 +469,8 @@ mod tests { #[test] fn transient_profile_maps_and_resolves_alias_jump_chain() { let store = InMemoryCredentialStore::new(); - // A transient alias profile with a raw ProxyJump naming another alias. let mut prod = profile("prod", "10.0.0.5", "deploy"); prod.port = 2222; - // Fake resolver: `bastion` is a known alias that itself jumps to `edge`. let resolve = |a: &str| -> Option<(SshProfile, Option<String>)> { match a { "bastion" => Some((profile("bastion", "bastion.example.com", "jump"), None)), @@ -583,7 +496,6 @@ mod tests { fn transient_profile_jump_falls_back_to_user_host_port() { let store = InMemoryCredentialStore::new(); let prod = profile("prod", "10.0.0.5", "deploy"); - // No alias resolves → the raw hop is parsed as user@host:port. let resolve = |_: &str| None; let spec = native_spec_from_transient_profile( &prod, @@ -602,7 +514,6 @@ mod tests { fn transient_profile_jump_cycle_is_broken() { let store = InMemoryCredentialStore::new(); let prod = profile("prod", "10.0.0.5", "deploy"); - // `bastion` jumps back to `prod`, which is the top-level alias → cut. let resolve = |a: &str| -> Option<(SshProfile, Option<String>)> { match a { "bastion" => Some(( diff --git a/src/ui/ssh_prompt.rs b/src/ui/ssh_prompt.rs index 21c12fd8..58a12b7b 100644 --- a/src/ui/ssh_prompt.rs +++ b/src/ui/ssh_prompt.rs @@ -1,21 +1,3 @@ -//! In-pane native-SSH auth & host-key sheets (WS3). -//! -//! When the daemon's russh connect task needs a decision only the user can make -//! (a password, a key passphrase, keyboard-interactive answers, or a host-key -//! confirmation) it sends a `DaemonMsg::AuthPrompt` over the pane's own stream. -//! `RemoteTerminal` queues it; the view emits `AuthPromptReady`; `Tty7App` drains -//! it here into a keyboard-first sheet rendered over the pane. The user's answer -//! goes back as a `ClientMsg::AuthResponse` via `RemoteTerminal::respond_auth`. -//! -//! Structure: a **pure** [`PromptModel`] + the submit/keychain decision functions -//! (unit-tested with no window), and the gpui [`SshPromptState`] + `impl Tty7App` -//! rendering that wraps them. Prompts are keyed to the pane that raised them, so -//! switching tabs never loses or misroutes a pending sheet. -//! -//! Security posture (PRD §5.3): an unknown host is a neutral confirm; a *changed* -//! host key is a red MITM warning whose default action is ABORT — trusting it -//! requires typing an explicit confirmation, never a bare Enter. - use gpui::{ AnyElement, Context, Entity, FocusHandle, IntoElement, ParentElement as _, Styled as _, Subscription, Window, div, prelude::*, px, @@ -31,27 +13,18 @@ use crate::terminal::view::TerminalView; use super::app::Tty7App; -// ───────────────────────────────────────────────────────────────────────────── -// Pure model + decision logic (no gpui) — the unit-tested core. -// ───────────────────────────────────────────────────────────────────────────── - -/// One keyboard-interactive prompt row. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct KiRow { pub text: String, - /// Whether keystrokes echo (false ⇒ masked input, e.g. a 2FA code field). pub echo: bool, } -/// The active sheet and the data it displays. Pure — holds no widgets. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum PromptModel { Password { user: String, host: String, port: u16, - /// FR-A6: a stored password we auto-supplied was rejected by the server, - /// so warn and offer to overwrite/clear the keychain entry. rejected: bool, }, KeyPassphrase { @@ -78,8 +51,6 @@ pub(crate) enum PromptModel { }, } -/// What to do with the OS keychain after a secret submit. Deliberately explicit so -/// FR-A6's "delete only in the rejection path" is auditable in one place. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum KeychainWrite { None, @@ -89,15 +60,11 @@ pub(crate) enum KeychainWrite { port: u16, secret: String, }, - /// Delete the stale stored password (only reached in the FR-A6 rejection path - /// when the user declines to remember the new one). DeletePassword { user: String, host: String, port: u16, }, - /// Store a key passphrase. The account is the key's content hash, computed by - /// the applier from `key_path` (WS1's `key_account_from_contents`). SetKeyPassphrase { key_path: String, secret: String, @@ -105,10 +72,6 @@ pub(crate) enum KeychainWrite { } impl PromptModel { - /// Build a model from an incoming prompt, given the pane's endpoint (for the - /// port, which the `Password` prompt omits) and whether this connect - /// auto-supplied a stored password (FR-A6). Returns `None` for a `Banner` - /// (handled out-of-band — banners never block). pub(crate) fn from_prompt( kind: AuthPromptKind, endpoint: Option<(String, u16)>, @@ -168,8 +131,6 @@ impl PromptModel { }) } - /// How many text inputs this sheet needs (KI has one per prompt; the changed - /// host-key sheet has a single confirmation field; host-key-unknown has none). fn input_count(&self) -> usize { match self { PromptModel::Password { .. } | PromptModel::KeyPassphrase { .. } => 1, @@ -180,15 +141,6 @@ impl PromptModel { } } -/// Resolve a password submit into a response and a keychain action (FR-A6). -/// -/// - `remember` ⇒ store (overwrite) the new password. -/// - not remembered, but this was the **rejection** path (a stored password had -/// been auto-supplied and the server rejected it) ⇒ delete the stale entry. -/// - otherwise ⇒ leave the keychain untouched. -/// -/// Crucially the delete only ever happens in the rejection path, so a network -/// error / timeout / other-method failure never clears a good credential. pub(crate) fn password_submit( user: &str, host: &str, @@ -216,7 +168,6 @@ pub(crate) fn password_submit( (AuthResponse::Secret(secret), write) } -/// Resolve a key-passphrase submit. Remember ⇒ store by key-content hash. pub(crate) fn passphrase_submit( key_path: &str, secret: String, @@ -233,12 +184,10 @@ pub(crate) fn passphrase_submit( (AuthResponse::Secret(secret), write) } -/// Keyboard-interactive: all answers in order. pub(crate) fn ki_submit(answers: Vec<String>) -> AuthResponse { AuthResponse::Secrets(answers) } -/// Unknown host: `trust` ⇒ accept + remember (write known_hosts); else abort. pub(crate) fn host_key_unknown_decision(trust: bool) -> AuthResponse { AuthResponse::HostKeyDecision { accept: trust, @@ -246,14 +195,10 @@ pub(crate) fn host_key_unknown_decision(trust: bool) -> AuthResponse { } } -/// A changed host key is trusted ONLY when the user typed the explicit -/// confirmation. Anything else (empty, wrong word, a bare Enter) aborts. Never -/// auto-accept (PRD FR-S2). pub(crate) fn changed_confirmed(typed: &str) -> bool { typed.trim().eq_ignore_ascii_case("yes") } -/// The decision for a changed-host-key submit, given the typed confirmation. pub(crate) fn host_key_changed_decision(typed: &str) -> AuthResponse { if changed_confirmed(typed) { AuthResponse::HostKeyDecision { @@ -268,41 +213,16 @@ pub(crate) fn host_key_changed_decision(typed: &str) -> AuthResponse { } } -// ───────────────────────────────────────────────────────────────────────────── -// gpui state + rendering. -// ───────────────────────────────────────────────────────────────────────────── - -/// The app-owned auth-sheet state. One active prompt at a time; further prompts -/// stay queued on the raising pane's `RemoteTerminal` until this one resolves. pub(crate) struct SshPromptState { - /// The pane that raised the active prompt (for routing the response back). pane: Option<Entity<TerminalView>>, - /// The daemon pane id, so rendering keys the sheet to the right pane. pane_id: Option<u64>, - /// The `request_id` the response must carry. request_id: u64, - /// The active sheet, or `None` when nothing is pending. model: Option<PromptModel>, - /// Dismissable, non-blocking server banners (never written into scrollback — - /// bytes stay transparent per FR-C4). banners: Vec<String>, - /// Input widgets for the active sheet (secret/answer/confirm fields). inputs: Vec<Entity<InputState>>, - /// "Remember (keychain)" toggle for password / passphrase sheets. remember: bool, - /// Latest spawn phase, for a small status line. phase: Option<SshPhase>, - /// A prompt raised by a **routed** connect (a remote workspace's control - /// stream) rather than by a pane. - /// - /// Those have no pane and no `TerminalView` to answer through — the question - /// arrives while the route is still being set up, before anything exists to - /// render it — so the answer goes back down this channel instead. The sheet - /// itself is the same one, which is the point: one auth UI, two producers. routed: Option<crate::ui::remote_connect::PendingAuth>, - /// The machine [`routed`](Self::routed) belongs to, kept separately because - /// answering *takes* the prompt and the queue still has to be released - /// afterwards. routed_host: Option<tty7_core::host::HostId>, focus_handle: FocusHandle, _subs: Vec<Subscription>, @@ -334,9 +254,6 @@ impl SshPromptState { self.inputs.clear(); self.remember = false; self._subs.clear(); - // A routed prompt still parked here was never answered — a connect - // thread is blocked on it. Cancelling is the safe direction and the same - // answer an unanswered one times out into. if let Some(pending) = self.routed.take() { pending.answer(AuthResponse::Cancelled); } @@ -344,11 +261,6 @@ impl SshPromptState { } impl Tty7App { - /// Drain the raising pane's pending prompts/phase into the sheet state. Called - /// from the `AuthPromptReady` subscription (single build site in - /// `new_terminal`). Banners are collected; the first real prompt becomes the - /// active sheet. If a sheet is already active, later prompts stay queued on the - /// pane and are picked up when the current one resolves. pub(crate) fn on_auth_prompt_ready( &mut self, view: Entity<TerminalView>, @@ -356,13 +268,10 @@ impl Tty7App { cx: &mut Context<Self>, ) { let pane_id = view.read(cx).pane_id; - // Snapshot the pane's endpoint / rejection flag / phase, and pull a prompt - // out — all inside a short immutable borrow, cloning what we need. let (endpoint, auto_supplied, phase, banners, next) = { let term = &view.read(cx).terminal; let mut banners = Vec::new(); let mut next: Option<(u64, AuthPromptKind)> = None; - // Only pull a new sheet if none is active; always harvest banners. let want_prompt = self.ssh_prompt.model.is_none(); loop { if want_prompt { @@ -375,11 +284,6 @@ impl Tty7App { None => break, } } else { - // A sheet is already up (another pane's): harvest banners - // only, leaving the real prompt *queued* — popping it here - // would drop it (no re-queue) and that pane's auth would - // dangle until the broker timeout. `dismiss_and_advance` - // picks queued prompts up when the active sheet resolves. match term.take_auth_banner() { Some(text) => banners.push(text), None => break, @@ -403,8 +307,6 @@ impl Tty7App { if let Some((request_id, kind)) = next { if let Some(model) = PromptModel::from_prompt(kind, endpoint, auto_supplied) { let inputs = build_inputs(&model, window, cx); - // Submit on Enter from any input (KI advances naturally; a single - // field submits directly). let mut subs = Vec::new(); for input in &inputs { subs.push(cx.subscribe_in( @@ -432,14 +334,6 @@ impl Tty7App { cx.notify(); } - /// Raise the sheet for a prompt that came off a **routed** connect (a remote - /// workspace reaching its machine), rather than off a pane. - /// - /// **Hands the prompt back** (`GiveBack`) when a sheet is already up, rather - /// than dropping it: the caller re-offers it, which is what the start-up auth - /// queue in `ui::remote_workspace` does. A dropped - /// [`PendingAuth`](crate::ui::remote_connect::PendingAuth) leaves a connect - /// thread parked until its 180s timeout. pub(crate) fn raise_routed_auth( &mut self, pending: crate::ui::remote_connect::PendingAuth, @@ -450,12 +344,7 @@ impl Tty7App { if self.ssh_prompt.model.is_some() { return SheetOutcome::GiveBack(pending); } - // No endpoint and no auto-supplied password: a routed connect's - // credentials were resolved before the route was opened, so there is no - // "the stored one was rejected" case to warn about here. let Some(model) = PromptModel::from_prompt(pending.prompt.clone(), None, false) else { - // A banner, not a question. Show it and answer so the connect - // carries on rather than waiting out the consent timeout. if let AuthPromptKind::Banner { text } = &pending.prompt { self.ssh_prompt.banners.push(text.clone()); } @@ -479,8 +368,6 @@ impl Tty7App { if let Some(first) = inputs.first() { first.update(cx, |s, cx| s.focus(window, cx)); } - // `pane_id` stays `None` so the overlay draws whatever tab is on screen: - // this sheet belongs to the *window's machine*, not to one pane in it. self.ssh_prompt.pane = None; self.ssh_prompt.pane_id = None; self.ssh_prompt.request_id = 0; @@ -494,7 +381,6 @@ impl Tty7App { SheetOutcome::Raised } - /// Reply to the active prompt and clear it, then pick up any queued prompt. pub(crate) fn submit_ssh_prompt(&mut self, window: &mut Window, cx: &mut Context<Self>) { let Some(model) = self.ssh_prompt.model.clone() else { return; @@ -522,8 +408,6 @@ impl Tty7App { passphrase_submit(key_path, secret, remember) } PromptModel::KeyboardInteractive { .. } => (ki_submit(values), KeychainWrite::None), - // Host-key sheets don't submit via Enter on an input (unknown has no - // input; changed submits through its confirm field handled here too). PromptModel::HostKeyUnknown { .. } => { (host_key_unknown_decision(true), KeychainWrite::None) } @@ -538,8 +422,6 @@ impl Tty7App { self.dismiss_and_advance(window, cx); } - /// Cancel the active prompt (Esc). Password/passphrase/KI ⇒ `Cancelled`; - /// host-key sheets ⇒ an explicit abort decision. pub(crate) fn cancel_ssh_prompt(&mut self, window: &mut Window, cx: &mut Context<Self>) { let Some(model) = self.ssh_prompt.model.clone() else { return; @@ -556,27 +438,21 @@ impl Tty7App { self.dismiss_and_advance(window, cx); } - /// Trust an unknown host (its neutral sheet's affirmative action). pub(crate) fn trust_ssh_host_key(&mut self, window: &mut Window, cx: &mut Context<Self>) { self.respond_active(host_key_unknown_decision(true), cx); self.dismiss_and_advance(window, cx); } - /// Toggle the "remember (keychain)" checkbox on the active sheet. pub(crate) fn toggle_ssh_remember(&mut self, cx: &mut Context<Self>) { self.ssh_prompt.remember = !self.ssh_prompt.remember; cx.notify(); } - /// Surface a connect-time failure (a typed line that can't be parsed into a - /// host, or an unresolvable alias) as a dismissable inline banner over the - /// focused pane — a diagnosable message rather than a silent no-op. pub(crate) fn push_ssh_connect_error(&mut self, reason: String, cx: &mut Context<Self>) { self.ssh_prompt.banners.push(reason); cx.notify(); } - /// Dismiss one banner by index. pub(crate) fn dismiss_ssh_banner(&mut self, ix: usize, cx: &mut Context<Self>) { if ix < self.ssh_prompt.banners.len() { self.ssh_prompt.banners.remove(ix); @@ -585,8 +461,6 @@ impl Tty7App { } fn respond_active(&mut self, response: AuthResponse, cx: &Context<Self>) { - // A routed prompt answers down its own channel: there is no pane, and - // the connect thread is parked on this reply. if let Some(pending) = self.ssh_prompt.routed.take() { pending.answer(response); return; @@ -598,22 +472,13 @@ impl Tty7App { fn dismiss_and_advance(&mut self, window: &mut Window, cx: &mut Context<Self>) { let pane = self.ssh_prompt.pane.clone(); - // D7: the sheet is one machine's turn. Handing it back is - // what lets the next machine's queued connect ask its question, so it - // has to happen on every exit from a routed sheet — answered, cancelled - // or dismissed. if let Some(host) = self.ssh_prompt.routed_host.take() { crate::ui::remote_workspace::release_auth_sheet(host, cx); } self.ssh_prompt.clear(); - // Another prompt may already be queued on the pane (e.g. a KI round after - // a password). Pick it up. if let Some(pane) = pane { self.on_auth_prompt_ready(pane, window, cx); } - // Still no sheet: another pane's prompt may have arrived while ours was - // up. It was deliberately left queued (see `on_auth_prompt_ready`), and - // its pane may get no further wakeup to re-raise it — find it now. if self.ssh_prompt.model.is_none() { let waiting = self .tabs @@ -627,8 +492,6 @@ impl Tty7App { cx.notify(); } - /// Apply a keychain action off the UI path. Best-effort — a keychain failure - /// never blocks the connection (the secret already went to the daemon). fn apply_keychain_write(&self, write: KeychainWrite) { let store = OsCredentialStore; match write { @@ -647,11 +510,6 @@ impl Tty7App { let _ = store.delete_password(&user, &host, port); } KeychainWrite::SetKeyPassphrase { key_path, secret } => { - // The keychain account is the key file's content hash. If the key - // can't be read we skip remember rather than store under a guessed - // account (documented WS3 fallback). The prompt's key_path is the - // spec's raw entry, which can still carry a `~` (e.g. an old - // persisted spec) — expand before reading. let path = crate::core::ssh_profile::expand_tilde(&key_path); match std::fs::read(&path) { Ok(bytes) => { @@ -666,20 +524,14 @@ impl Tty7App { } } - /// Render the auth sheet over the active pane, if a prompt is pending for the - /// currently focused pane. Also renders any dismissable banners. pub(crate) fn render_ssh_prompt_overlay( &self, window: &mut Window, cx: &mut Context<Self>, ) -> Option<AnyElement> { - // Nothing to show if there's no active model and no banners. if self.ssh_prompt.model.is_none() && self.ssh_prompt.banners.is_empty() { return None; } - // Per-pane keying: only draw the sheet when the pane that raised it is the - // one currently on screen, so switching tabs never misroutes it (the - // prompt state is retained until that pane is focused again). let focused_pane_id = self .tabs .get(self.active) @@ -691,7 +543,6 @@ impl Tty7App { let mut stack = v_flex().gap_2().items_center(); - // Banners first (non-blocking, dismissable). for (ix, banner) in self.ssh_prompt.banners.iter().enumerate() { stack = stack.child(self.render_ssh_banner(ix, banner, cx)); } @@ -776,7 +627,6 @@ impl Tty7App { } else { cx.theme().border }) - // Esc cancels/aborts from anywhere in the sheet. .on_key_down(cx.listener(|this, ev: &gpui::KeyDownEvent, window, cx| { if ev.keystroke.key == "escape" { this.cancel_ssh_prompt(window, cx); @@ -795,7 +645,6 @@ impl Tty7App { PromptModel::Password { rejected, .. } => { let mut c = card; if *rejected { - // FR-A6: warn that the stored password was rejected. c = c.child( div() .text_xs() @@ -898,8 +747,6 @@ impl Tty7App { .child( h_flex() .gap_2() - // Default/primary action is ABORT — trusting requires the - // typed confirmation submitted via Enter on the field. .child( Button::new("ssh-hkc-abort") .label("Abort") @@ -931,9 +778,6 @@ impl Tty7App { } fn render_ssh_remember(&self, cx: &mut Context<Self>) -> AnyElement { - // A real checkbox, left-aligned in its own row. The old ghost Button - // stretched to the card's full width, so its selected-state fill read as - // a full-width grey bar rather than a checkbox. h_flex() .child( Checkbox::new("ssh-remember") @@ -966,7 +810,6 @@ impl Tty7App { } } -/// Build the input widgets a model needs, masking non-echo fields. fn build_inputs( model: &PromptModel, window: &mut Window, @@ -975,8 +818,6 @@ fn build_inputs( let count = model.input_count(); (0..count) .map(|i| { - // Which fields mask: password + passphrase always; KI per its `echo`; - // the changed-host confirm field is plain text. let masked = match model { PromptModel::Password { .. } | PromptModel::KeyPassphrase { .. } => true, PromptModel::KeyboardInteractive { prompts, .. } => { @@ -1053,8 +894,6 @@ mod tests { #[test] fn non_rejection_without_remember_never_touches_keychain() { - // The critical FR-A6 guarantee: a plain failed attempt (not the stored- - // password rejection path) must NOT clear anything. let (_resp, write) = password_submit("u", "h", 22, "pw".into(), false, false); assert_eq!(write, KeychainWrite::None); } @@ -1101,7 +940,6 @@ mod tests { #[test] fn changed_host_never_auto_accepts() { - // Only an explicit "yes" trusts; everything else aborts. assert!(changed_confirmed("yes")); assert!(changed_confirmed(" YES ")); assert!(!changed_confirmed("")); diff --git a/src/ui/switcher.rs b/src/ui/switcher.rs index 21378b16..bae2ad4c 100644 --- a/src/ui/switcher.rs +++ b/src/ui/switcher.rs @@ -1,39 +1,3 @@ -//! The workspace switcher: the one surface that answers "which workspace, on -//! which machine, should this window show?" -//! -//! It replaces three partial answers to that question — the title-bar chip's -//! dropdown (every workspace, but no machine on the rows, so two boxes with a -//! `tty7` checkout each rendered as two identical lines), the home page's -//! picker (closed workspaces only, six of them, and only on an *empty* window) -//! and the home page's "Connect to Host" wizard (the only place a machine was -//! ever a first-class thing, and it stopped existing the moment it connected). -//! -//! # The one idea -//! -//! **The machine is the grouping dimension.** This computer is a group like any -//! other, so local and remote read as one grammar rather than "the normal case" -//! and "the special case", and no row can be ambiguous about where it lives. -//! -//! # Why an overlay and not a `PopupMenu` -//! -//! A popup dismisses on any mouse-down outside its own bounds, so a nested menu -//! tears its host down before its own click ever lands — `ui::home` hit exactly -//! this and wrote it down. The rows here carry a `⋯` menu, so this has to be an -//! overlay in the palette's shape (a scrim plus a card) rather than a dropdown -//! hanging off the chip. That also hands us a real search field and Esc for -//! free. -//! -//! # Where the rows come from -//! -//! The view store records remote workspaces (`WindowView::host`), so a -//! machine's workspaces are listed **without connecting to it** — the client -//! remembers which ones it saw, and their display facts come from the -//! machine's mirror (`ui::machine_mirror`). Connecting only ever *adds*: the -//! remote's own tree is the authority, so its rows are merged in when a link -//! exists and anything this client had not heard of shows up then (see -//! [`Group::merge`]). That is what makes "expand a machine" a lazy, cheap -//! gesture rather than a wizard. - use std::collections::{HashMap, HashSet}; use std::path::PathBuf; @@ -55,183 +19,73 @@ use crate::ui::app::Tty7App; use crate::ui::remote_connect::{self, HostChoice, RemoteWorkspaceRow, human_bytes}; use crate::ui::remote_workspace::ConnectFlow; -/// Card width — the command palette's, to the pixel. -/// -/// These are the app's two full-window overlays and they open on the same -/// gesture-shaped question ("which one?"), so they are one card wearing two -/// contents. 460 was chosen when this panel was the narrower of the two, and it -/// showed: a row here carries four columns (name, path, state, age) where a -/// palette row carries one, and cramming them into 100px less made the panel -/// read as the smaller, lesser surface of the pair. const CARD_W: f32 = 560.0; -/// How far down the window both overlays open. The palette's, for the same -/// reason as [`CARD_W`]. const CARD_TOP: f32 = 120.0; -/// How tall the scrolling body may get before it scrolls. Sized so a typical -/// two-machine setup never scrolls at all. const BODY_MAX_H: f32 = 420.0; -/// Monogram diameter on a workspace row. const ROW_AVATAR: f32 = 20.0; -/// Every row in the panel is exactly this tall, machines one step taller. -/// -/// Fixed rather than padding-derived because the old layout set `py(6)` on some -/// rows and `py(7)` on others, which is invisible in the source and reads as a -/// stutter down the list. const ROW_H: f32 = 32.0; const HOST_H: f32 = 34.0; -/// The one column every glyph in the panel lines up in. -/// -/// A machine's icon, a workspace's monogram and a `+` all centre here, so the -/// eye finds one vertical axis instead of re-finding the start of each row. The -/// old layout had none: headers began at `px(10)`, rows at `ml(16) + px(10)`, -/// and the glyphs inside them ranged 12–19px. const GUTTER: f32 = 26.0; -/// Icon size everywhere in the panel. One value, deliberately — the old set ran -/// 12, 13 and 19 px plus two `▸`/`▾` *text* glyphs, which follow the UI font's -/// weight rather than the icon set's. const ICON: f32 = 16.0; -/// How far a machine's workspaces sit in from its own row. const KID_INDENT: f32 = 16.0; -/// Where the guide line down a remote machine's rows is drawn: the centre of -/// that machine's own icon, which is what makes it read as descending *from* -/// the machine. const RAIL_X: f32 = ROW_PAD + GUTTER / 2.; -/// Horizontal padding inside a row. const ROW_PAD: f32 = 8.0; -/// The age column. Fixed width and right-aligned so the ages read as a column -/// instead of drifting with whatever path sits to their left. -/// -/// Sized to `relative_time`'s **longest** string, "over a week ago", not to a -/// typical one: the column is fixed, so anything that does not fit wraps to a -/// second line and takes the row's height with it. ("1 min ago" over two lines -/// is what 52px bought.) const WHEN_W: f32 = 96.0; -/// Height of the install bar's track. Thin on purpose: it is a thing to glance -/// at while waiting, not a control, and anything taller starts to compete with -/// the workspace rows under it for the eye. const PROGRESS_H: f32 = 3.0; -/// What a machine's connection is doing, as far as this panel is concerned. -/// -/// Deliberately coarser than -/// [`RemoteStatus`](crate::ui::remote_workspace::RemoteStatus): that one drives -/// a window's read-only degrade and has to distinguish `Reconnecting` from -/// `Preempted`. A *list* only needs to know whether expanding this row will -/// cost a connect. #[derive(Clone, Copy, PartialEq, Eq)] enum Link { - /// This computer. Never connects, never fails. Local, - /// A live control connection exists. Connected, - /// A connect is in flight right now. Connecting, - /// The last attempt failed. A resting state, not a transient one: design - /// A failure always stays put and offers the next move, so the - /// reason rides along on the group (see [`Group::error`]). Failed, - /// No connection. **Not an error** — the rows below it are what this client - /// remembers, and they are almost certainly still running over there. Offline, } -/// One machine and the workspaces on it. struct Group { - /// Stable identity for collapse state and element ids. The local group's is - /// the empty string, which no `RemoteTarget` can render as. key: String, label: String, - /// Where this machine actually is (`thomas@10.0.4.12:2222`), when that is - /// something other than the label already on the row. - /// - /// A `~/.ssh/config` alias says nothing about the box behind it, and two - /// aliases can point at one machine; the endpoint is what tells them apart. - /// Empty for the local group and for a target whose label *is* its endpoint. endpoint: String, - /// `None` for the local group. target: Option<RemoteTarget>, link: Link, - /// The remote's `$HOME`, once a handshake has reported it. Only `Some` for - /// a connected remote — which is exactly when "New Workspace" can name a - /// directory it is not guessing at. home: Option<PathBuf>, - /// Why the last connect failed, shown under the header until the user acts - /// on it. error: Option<String>, - /// How far this machine's first install has got, while one is running. - /// - /// Shares the header's under-slot with [`error`](Self::error) and cannot - /// collide with it: a connect is either still installing or has already - /// failed. installing: Option<InstallPhase>, rows: Vec<Row>, } -/// One workspace, flattened for rendering. -/// -/// Owned rather than borrowed from the store: collecting these releases the -/// borrow on the global before the row closures capture `cx`, which is the same -/// dance `ui::home`'s picker does and for the same reason. struct Row { id: WorkspaceId, name: String, path: String, when: String, live: Liveness, - /// Shown by some window right now. open: bool, - /// Shown by *this* window. current: bool, - /// Set for a workspace that exists on the remote but has no local record - /// yet: opening it has to claim it first. `None` once it is in - /// the view store like any other. adopt: Option<Box<RemoteWorkspaceRow>>, - /// This row's id **on its own machine**, for a remote workspace. It is what - /// the remote's list is matched against — the local [`WorkspaceId`] above is - /// this client's own handle and means nothing over there. remote_id: Option<WorkspaceId>, } -/// A machine's handshake result, kept so the panel can show more than one -/// connected machine at a time. -/// -/// [`ConnectFlow`] cannot do this job: it is a *flow*, so it holds one machine -/// and forgets it the moment the user connects to another. The panel lists -/// every machine at once, so the results have to accumulate somewhere that is -/// not the flow. pub(crate) struct HostSnapshot { - /// Which machine this is. Kept alongside the id it is filed under because - /// [`HostId`](crate::ui::host_registry::HostId) is a one-way hash of the - /// connection key — a machine that connected but has no workspace on this - /// client yet has no other route back to a `RemoteTarget`, and without one - /// it could never be given a group to appear in. pub target: RemoteTarget, - /// What the remote said it had. The machine's `$HOME` is deliberately *not* - /// here — it lives in `HostLinks`, app-wide, because every window - /// needs it and only one of them ever did the connecting. pub rows: Vec<RemoteWorkspaceRow>, } -/// The switcher's own state, alive only while it is on screen. pub(crate) struct Switcher { - /// The search field. Focused on open so keystrokes stay off the PTY. pub query: Entity<InputState>, - /// Machines the user has folded away, by [`Group::key`]. collapsed: HashSet<String>, - /// Whether the "other SSH hosts" band is expanded. show_others: bool, - /// An in-place rename, by the row it is editing. renaming: Option<(WorkspaceId, Entity<InputState>)>, _subs: Vec<Subscription>, } @@ -243,10 +97,6 @@ impl Switcher { } impl Tty7App { - // ----- open / close ----------------------------------------------------- - - /// Toggle the switcher. Bound to the chip, ⌘⇧O and the palette's - /// "Switch Workspace…". pub(crate) fn toggle_switcher(&mut self, window: &mut Window, cx: &mut Context<Self>) { if self.switcher.is_some() { self.close_switcher(window, cx); @@ -256,17 +106,8 @@ impl Tty7App { } pub(crate) fn open_switcher(&mut self, window: &mut Window, cx: &mut Context<Self>) { - // The install-consent handler has to be live before any row can start a - // connect. Idempotent and last-call-wins, exactly as `begin_connect` - // registered it. remote_connect::register(cx); - // Re-enumerate this computer's WSL distributions, which are rows in the - // band below. TTL'd and backgrounded, so opening the panel twice in a - // row costs nothing and opening it once never waits on `wsl.exe`. remote_connect::sweep_wsl(cx); - // Named, because the field is the panel's only affordance that does not - // say what it does: a bare magnifier over a list of machines reads as - // "filter these rows", and it also finds machines that have no row yet. let query = cx.new(|cx| InputState::new(window, cx).placeholder("Search workspaces and machines")); query.update(cx, |state, cx| state.focus(window, cx)); @@ -291,9 +132,6 @@ impl Tty7App { pub(crate) fn close_switcher(&mut self, window: &mut Window, cx: &mut Context<Self>) { if self.switcher.take().is_some() { - // A connect that is still in the air keeps running — the window it - // lands in is chosen by the flow, not by whether this panel is - // still open. A *failure* with nobody to show it to is dropped. if matches!(self.connect, Some(ConnectFlow::Failed { .. })) { self.connect = None; } @@ -302,19 +140,9 @@ impl Tty7App { } } - // ----- the model -------------------------------------------------------- - - /// Every machine and its workspaces, in display order: this computer first, - /// then remotes by how recently anything on them was used. - /// - /// Sorting remotes by recency rather than alphabetically is the same rule - /// the rest of the app follows (the chip menu, the old picker): the list is - /// a "get back to what you were doing" surface, not a directory. fn switcher_groups(&self, cx: &mut Context<Self>) -> Vec<Group> { let now = crate::ui::home::now_secs(); let current = self.workspace; - // One shared read of the store, and the liveness sweep started before - // it so the dots are warm by the time a row asks. crate::terminal::pane_liveness::sweep(cx); let mut groups: Vec<Group> = Vec::new(); @@ -346,10 +174,6 @@ impl Tty7App { }); groups[slot].rows.push(Row { id: w.id, - // Both read the machine's mirror — the tree owns the - // layout these used to be derived from. A machine not - // pulled yet (launch's first frames; an unreached remote) - // renders the not-knowing rather than a stale guess. name: crate::ui::machine_mirror::display_name(app, w) .unwrap_or_else(|| "Untitled".to_string()), path: crate::ui::machine_mirror::subject_path(app, w) @@ -365,14 +189,6 @@ impl Tty7App { } } - // A machine the user just reached, or is reaching right now, gets a - // group of its own even with no workspace on it. - // - // Without this, picking a brand-new machine out of "Other SSH Hosts" - // looks like nothing happened: the connect runs, succeeds, and has - // nowhere to land — groups came only from workspaces this client had - // records for, and a machine that has never been used has none. That is - // also precisely the machine the user is about to make a workspace on. for target in self.pending_machines() { let key = target.to_string(); if index.contains_key(&key) { @@ -392,10 +208,6 @@ impl Tty7App { }); } - // The local group is always present and always first, even with nothing - // in it: "this computer" is not a thing that can be missing, and a panel - // that hides it on a fresh install would have no way to make the first - // workspace. if !index.contains_key("") { groups.insert( 0, @@ -423,12 +235,6 @@ impl Tty7App { } groups.sort_by(|a, b| a.key.is_empty().cmp(&b.key.is_empty()).reverse()); - // Link state, the machine's own name, and the remote's own view. - // - // The name matters: a group's label starts life as `RemoteTarget`'s - // `Display`, which for a saved profile is its *uuid* — the type - // deliberately cannot reach into the profile store, so the panel has to - // do the lookup that turns that into the name the user typed. let configured = remote_connect::available_hosts(cx); for group in &mut groups { let Some(target) = group.target.clone() else { @@ -448,22 +254,6 @@ impl Tty7App { group.error = Some(error.clone()); } let id = target.host_id(); - // Only while *this* window is the one connecting. Another window's - // install is its own business, and a bar under a row this panel is - // not driving would have no "Try Again" to turn into. - // - // `error` counts too, and is not an exception to that: a machine - // being worked on by the "Restart Server" inside this panel's own - // error card is still a machine this panel is driving. - // - // A restart is the exception, and has to be. It is offered from the - // machine's `⋯` menu whatever the link is doing — a server worth - // restarting is most often one nothing can reach — so gating it on - // *this* window's connect would hide the bar in the ordinary case. - // And it is the one flow that transfers nothing, which makes the bar - // the only thing that says the click landed, for the length of two - // timeouts. Showing another window's restart is right rather than - // merely tolerable: it is about to end the sessions in this one too. let reported = remote_connect::install_progress_for(id); if group.link == Link::Connecting || group.error.is_some() @@ -471,10 +261,6 @@ impl Tty7App { { group.installing = reported; } - // Read app-wide, not from this window's snapshot: any window's - // connect, and every reconnect, records the machine's `$HOME` — and - // that row is the only way to make a workspace on a machine, so it - // has no business depending on which window did the connecting. group.home = remote_connect::HostLinks::home(cx, id); if let Some(snapshot) = self.host_snapshots.get(&id) { group.merge(&snapshot.rows, now); @@ -483,10 +269,6 @@ impl Tty7App { groups } - /// Machines that have earned a group without owning a workspace: the one - /// being connected to right now, the one that just failed (its error is the - /// group's whole content), and every one that has answered a handshake this - /// session. fn pending_machines(&self) -> Vec<RemoteTarget> { let mut out: Vec<RemoteTarget> = self .host_snapshots @@ -499,7 +281,6 @@ impl Tty7App { out } - /// What a machine's connection is doing. fn link_state(&self, target: &RemoteTarget, cx: &mut Context<Self>) -> Link { match &self.connect { Some(ConnectFlow::Connecting { choice }) if &choice.target == target => { @@ -516,18 +297,6 @@ impl Tty7App { } } - /// The machines that have no workspace on this client, so they never earned - /// a group of their own. - /// - /// This band exists because `~/.ssh/config` is not a list of development - /// machines — it also holds git transport aliases (`github.com` and - /// friends), which can never host a workspace. Rather than guess which is - /// which (guess wrong and the user cannot reach their own box), the panel - /// sorts by *whether it has been used* and folds the rest away. - /// - /// The WSL distributions installed on this computer land here too, for the - /// same reason and with the same handling: until one has hosted a - /// workspace, it is a machine the user *could* use, not one they do. fn other_hosts(&self, groups: &[Group], cx: &App) -> Vec<HostChoice> { let known: HashSet<&str> = groups.iter().map(|g| g.key.as_str()).collect(); remote_connect::available_hosts(cx) @@ -536,10 +305,6 @@ impl Tty7App { .collect() } - // ----- actions ---------------------------------------------------------- - - /// Expand a machine — which, for one that is not connected, *is* the - /// connect. The panel stays open the whole way through. fn switcher_toggle_host(&mut self, group: &GroupRef, cx: &mut Context<Self>) { if group.link == Link::Offline && let Some(target) = group.target.clone() @@ -563,7 +328,6 @@ impl Tty7App { cx.notify(); } - /// Show a workspace, closing the panel behind it. fn switcher_open( &mut self, row: RowRef, @@ -573,19 +337,12 @@ impl Tty7App { ) { self.close_switcher(window, cx); match row.adopt { - // A workspace this client has never seen: claim it first, then it - // is an ordinary local record pointing at a remote id. Some((target, remote)) => self.open_remote_workspace(target, *remote, window, cx), None if new_window => crate::ui::windows::open(cx, Some(row.id)), None => self.reveal_workspace(row.id, window, cx), } } - /// Start an in-place rename on a row. - /// - /// The chip's own rename (`start_workspace_rename`) can only ever edit the - /// window's *current* workspace, because the field it opens is the chip. A - /// list needs to rename the row that was aimed at, so it gets its own. fn switcher_rename(&mut self, id: WorkspaceId, window: &mut Window, cx: &mut Context<Self>) { let current = crate::ui::machine_mirror::display_name_for(cx, id).unwrap_or_default(); let input = cx.new(|cx| InputState::new(window, cx).default_value(current)); @@ -617,26 +374,14 @@ impl Tty7App { if id == self.workspace { self.sync_window_title(window, cx); } - // Focus goes back to the search field, not the terminal: the panel is - // still open, and a rename is rarely the last thing the user wants. if let Some(sw) = self.switcher.as_ref() { sw.query.update(cx, |state, cx| state.focus(window, cx)); } cx.notify(); } - /// Stop holding a connection to a machine. - /// - /// The panel stays open and the machine keeps its group: its workspaces are - /// still there, still listed from what this client remembers, and the header - /// now reads "not connected" — clicking it connects again. Windows showing - /// them stay open and go read-only; see [`RemoteLinks::disconnect`] for why - /// this closes nothing. fn switcher_disconnect(&mut self, target: &RemoteTarget, cx: &mut Context<Self>) { crate::ui::remote_workspace::RemoteLinks::disconnect(cx, target.host_id()); - // A finished connect flow for this machine described an attempt that has - // just been undone; leaving it would keep painting a stale error (or a - // success) over the group that no longer holds. if self .connect .as_ref() @@ -648,22 +393,15 @@ impl Tty7App { cx.notify(); } - /// Make a workspace on a machine. Local goes through the ordinary - /// `NewWorkspace` path; a remote one lands in *that machine's* `$HOME`. fn switcher_new(&mut self, group: &GroupRef, window: &mut Window, cx: &mut Context<Self>) { self.close_switcher(window, cx); match (group.target.clone(), group.home.clone()) { (Some(target), Some(home)) => self.create_remote_workspace(target, home, window, cx), - // No home means no handshake, which the row's own visibility rule - // already prevents — belt and braces rather than inventing `~`. (Some(_), None) => {} (None, _) => crate::ui::windows::open(cx, None), } } - // ----- render ----------------------------------------------------------- - - /// The switcher overlay, or `None` when it is closed. pub(crate) fn render_switcher(&self, cx: &mut Context<Self>) -> Option<AnyElement> { self.switcher.as_ref()?; let groups = self.switcher_groups(cx); @@ -676,17 +414,8 @@ impl Tty7App { let theme = cx.theme(); let (border, card_bg) = (theme.border, theme.popover); - // Card, radius, shadow and width are the command palette's, deliberately - // — these are the app's two full-window overlays and they should read as - // one thing wearing two contents, not two panels. The only colour that - // moved is the ground: the old dim was a wash of the window's *own* - // colour, so a dimmed window and a 5%-lifted card landed at the same - // value and the whole panel read as though something were lying over it. - // `scrim_fill` is the near-black that actually drops the window away. let scrim = crate::ui::presets::scrim_fill(cx); - // 6px between machines, 1px within one: a machine and its workspaces - // are a single block, and the gap is the only thing that says so. let mut body = v_flex().gap(px(6.)); let mut shown = 0usize; for group in &groups { @@ -762,9 +491,6 @@ impl Tty7App { h_flex() .items_center() .gap(px(8.)) - // `pl` matches the body's own padding so the magnifier lands on the - // same axis as every machine icon under it, rather than 7px inside - // it as it did when the two were padded independently. .pl(px(6. + ROW_PAD)) .pr(px(12.)) .h(px(42.)) @@ -774,23 +500,13 @@ impl Tty7App { GUTTER, Icon::new(IconName::Search).size(px(ICON)).text_color(muted), )) - .children(self.switcher.as_ref().map(|sw| { - // `pl_0`: a `small` input carries 8px of its own horizontal - // padding whether or not it draws a box, and stacked on this - // row's gap it set the query text 8px right of every label - // under it — the one thing in the panel that did not share the - // list's left edge. - Input::new(&sw.query).appearance(false).small().pl_0() - })) + .children( + self.switcher + .as_ref() + .map(|sw| Input::new(&sw.query).appearance(false).small().pl_0()), + ) } - /// The one row the panel keeps below the fold — adding a machine it does not - /// know about yet — and the one gesture nothing else advertises. - /// - /// Deliberately *not* an "add host" form. A machine is - /// configured once and remote workspaces reuse whatever is already set up, - /// so this points at where that lives instead of growing a second place to - /// do it. fn render_footer(&self, cx: &mut Context<Self>) -> impl IntoElement + use<> { let theme = cx.theme(); let (muted, dim, border) = ( @@ -831,11 +547,6 @@ impl Tty7App { ); })), ) - // A plain click reuses this window, ⌘-click opens another. Every - // row's own label already says *where* the click lands (see the - // badges in `render_row`); this is the one part of the answer that - // has nowhere else to live, and it was previously discoverable only - // by accident. .child( h_flex() .items_center() @@ -856,7 +567,6 @@ impl Tty7App { ) } - /// One machine: its header, and — when expanded — its workspaces. fn render_group( &self, group: &Group, @@ -878,8 +588,6 @@ impl Tty7App { return None; } - // A search expands everything it matched: hunting for a name and then - // having to open the group it is in would make the field feel broken. let collapsed = self .switcher .as_ref() @@ -892,12 +600,6 @@ impl Tty7App { if let Some(phase) = group.installing { block = block.child(self.render_install_progress(phase, cx)); } - // A failure is a resting state — it stays on screen with its reason - // in full and its next move one click away, rather than reverting the - // panel and leaving the user to guess between VPN, keys and the box. - // Not while something is being done about it: a stale reason sitting - // above a live progress bar reads as two states at once, and the two - // buttons under it are exactly what must not be clicked twice. if let Some(error) = group.error.as_ref().filter(|_| group.installing.is_none()) { let retry = GroupRef::of(group); let replace = retry.clone(); @@ -944,11 +646,6 @@ impl Tty7App { } })), ) - // Only for the one failure a reinstall fixes. Every - // other reason a connect fails (unreachable, refused - // key, no route) would cost the user every pane on - // that machine and not help — so the button is not - // there to be misread as a general retry. .when( crate::daemon::control::is_dialect_refusal(error) && replace.target.is_some(), @@ -958,16 +655,6 @@ impl Tty7App { "switcher-replace:{}", group.key ))) - // The same words as the mismatch - // prompt's button, because it is the - // same thing to the user: this - // machine's server becomes one this - // client can talk to, and everything - // running on it ends. Whether a binary - // has to be written on the way is an - // implementation detail, and a second - // verb for it only asks the user to - // tell two identical outcomes apart. .label("Restart Server") .ghost() .xsmall() @@ -987,10 +674,6 @@ impl Tty7App { ), ); } - // A machine with no workspaces on it renders as its header alone. There - // used to be a "New Workspace" row to fill the space; it lives in the - // header's `⋯` now, so an empty group has nothing under it and must not - // draw an indent block (and a guide rail) around nothing. if expanded && !rows.is_empty() { let mut kids = v_flex().gap(px(1.)); for row in rows { @@ -1001,31 +684,13 @@ impl Tty7App { Some(block.into_any_element()) } - /// The bar under a machine that is being installed onto for the first time. - /// - /// Sits in the same slot as the failure box, indented and inset to the same - /// numbers, because it is the same kind of thing: a sentence about this - /// machine's connect that outlives a single frame. The two can never both be - /// present — a connect is either still running or has already failed — so - /// the slot needs no arbitration. - /// - /// No border, unlike the failure box. A failure is a thing to act on and - /// earns an outline; this is a thing to wait through, and a box around it - /// would give a routine 20 seconds the weight of an error. fn render_install_progress( &self, phase: InstallPhase, cx: &mut Context<Self>, ) -> impl IntoElement + use<> { let theme = cx.theme(); - // The same warning colour the header's dot and "installing…" already - // use, so the row and the bar read as one state and not two. let accent = theme.warning; - // An unknown total (no Content-Length) still gets a line of text and a - // bar — just an empty one. A bar that guessed at a fraction would be - // lying, and one that vanished would read as the install having stopped. - // A restart is the same shape for a different reason: it is two timeouts - // and no transfer, so there is nothing it could honestly fill. let fraction = phase.fraction().unwrap_or(0.0); let caption = match phase { InstallPhase::Restarting => "Restarting tty7's server\u{2026}".to_string(), @@ -1058,9 +723,6 @@ impl Tty7App { .child(caption), ) .child( - // Track and fill are one element inside another rather than a - // gauge widget: the panel has no other progress indicator to be - // consistent with, and 3px of rounded div needs no abstraction. div() .w_full() .h(px(PROGRESS_H)) @@ -1076,14 +738,6 @@ impl Tty7App { ) } - /// A machine's rows, set in from its own row — and, on a *remote* machine, - /// tied to it by a guide line descending from that machine's icon. - /// - /// The rail is the panel's one piece of pure structure, and it earns its - /// place by saying the thing the old layout made you scroll up to find out: - /// these rows are on another computer. The local group gets none, which is - /// the point — "here" needs no marking, and two rails in two greys would - /// only be one signal drawn twice. fn indent(&self, group: &Group, kids: impl IntoElement, cx: &mut Context<Self>) -> AnyElement { let rail = cx.theme().border; div() @@ -1095,10 +749,6 @@ impl Tty7App { .absolute() .left(px(RAIL_X)) .top(px(0.)) - // Stops short of the last row's baseline rather than - // running to the edge: a line that ends level with the - // final row's glyph reads as enclosing the block, one - // that runs past it reads as unfinished. .bottom(px(ROW_H / 2.)) .w(px(1.)) .bg(rail), @@ -1126,26 +776,14 @@ impl Tty7App { let app = cx.entity().downgrade(); let app2 = app.clone(); - // A machine wears the shape of what it is, which is the only thing on - // the row that says "somewhere else" before a word of it is read. Both - // glyphs are tty7's own: the stock set has no laptop and no server, and - // its nearest neighbours (`hard-drive`, `cpu`) draw a *component* - // rather than a computer. See `ui::assets`. let glyph = match group.target { None => "icons/machine-local.svg", Some(_) => "icons/machine-remote.svg", }; - // Only the states that are *not* resting get words. A green dot beside - // the word "connected" says one thing twice; a grey dot alone says - // nothing at all to someone who has not learnt this panel yet. let (dot, word): (Option<gpui::Hsla>, Option<&'static str>) = match group.link { Link::Local => (None, None), Link::Connected => (Some(gpui::rgb(crate::ui::tab_strip::LIVE_DOT).into()), None), - // "installing…" while bytes are moving: the bar underneath says how - // far along, and a header still reading "connecting…" over it would - // describe a step that finished a while ago. A restart moves no - // bytes, so it gets its own word rather than borrowing that one. Link::Connecting if matches!(group.installing, Some(InstallPhase::Restarting)) => { (Some(theme.warning), Some("restarting…")) } @@ -1193,10 +831,6 @@ impl Tty7App { .text_color(fg) .child(group.label.clone()), ) - // An alias says nothing about the box behind it, so the endpoint - // rides alongside where there is one to show — and doubles as the - // row's spacer, so it gives way to the status on its right rather - // than pushing it off the row. .when(group.endpoint.is_empty(), |head| head.child(div().flex_1())) .when(!group.endpoint.is_empty(), |head| { head.child( @@ -1213,8 +847,6 @@ impl Tty7App { .children(word.map(|w| { div() .flex_shrink_0() - // Tighter than the row's gap: the dot and the word are one - // reading, not two items. .ml(px(-2.)) .text_xs() .text_color(word_color) @@ -1229,16 +861,6 @@ impl Tty7App { .child(format!("{}", group.rows.len())), ) }) - // The machine's own actions, in the same `⋯` its rows use — but - // always on, where a row's appears on hover. Two reasons it earns - // the pixels a row's does not: there are a handful of machines and - // dozens of rows, so a permanent glyph here is one mark and not a - // column of them; and since "New Workspace" stopped being a row this - // is the *only* way to reach it, where a row's menu only duplicates - // what clicking the row already does. - // - // Without the `stop_propagation` the press underneath reaches the - // header and folds the machine away behind its own menu. .child( div() .flex_shrink_0() @@ -1268,13 +890,10 @@ impl Tty7App { .on_click(cx.listener(move |this, _: &ClickEvent, _window, cx| { this.switcher_toggle_host(&gref, cx) })) - // Right-click reaches the same menu, exactly as a row's does. .context_menu(move |menu, _window, _cx| group_menu(menu, &ctx_ref, app2.clone())) } fn render_row(&self, group: &Group, row: &Row, cx: &mut Context<Self>) -> AnyElement { - // A rename replaces the row's contents in place, so the name is edited - // where it is read. if let Some(sw) = self.switcher.as_ref() && let Some((id, input)) = sw.renaming.as_ref() && *id == row.id @@ -1307,16 +926,6 @@ impl Tty7App { let app2 = app.clone(); let key = row.id.element_key() as usize; - // "Where will this click land?" written on the row rather than left to - // be discovered: the same gesture focuses another window, swaps this - // one over, or opens a new one, and only the row knows which. - // - // Still words rather than a coloured mark. The green pill this replaces - // was the loudest thing in the panel, but the fix is to quieten it, not - // to swap it for a bar or a ring — `workspace_avatar` already argues - // that the current row is marked by *subtraction* (every other badge - // fades, this one doesn't), and a second marker on the one row needing - // no introduction would undo that. let badge = if row.current { Some(("this window", true)) } else if row.open { @@ -1354,10 +963,6 @@ impl Tty7App { .child( div() .flex_1() - // `min_w_0` + `truncate`, not `overflow_hidden`: a flex - // child will not shrink below its content without the - // former, so a long path pushed the age column off the row - // instead of ellipsing itself. .min_w_0() .truncate() .text_xs() @@ -1378,9 +983,6 @@ impl Tty7App { .child( div() .flex_shrink_0() - // Fixed and right-aligned: the ages are a column, and they - // stopped being one the moment their left edge was allowed - // to follow whatever path sat beside them. .w(px(WHEN_W)) .truncate() .text_right() @@ -1393,8 +995,6 @@ impl Tty7App { .invisible() .flex_shrink_0() .group_hover("switcher-row", |x| x.visible()) - // Without this the press also reaches the row underneath - // and opens the very workspace being renamed or removed. .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) .child( Button::new(("switcher-row-more", key)) @@ -1409,15 +1009,10 @@ impl Tty7App { .on_click(cx.listener(move |this, ev: &ClickEvent, window, cx| { this.switcher_open(click_ref.clone(), ev.modifiers().platform, window, cx) })) - // Right-click opens the same menu — the second-tier convention - // every other list in this app already uses. Last, because - // `context_menu` wraps the element and the wrapper has no - // `on_click` of its own. .context_menu(move |menu, _window, _cx| row_menu(menu, &ctx_ref, app2.clone())) .into_any_element() } - /// The machines with nothing on them yet, folded into one row. fn render_other_hosts( &self, others: &[HostChoice], @@ -1427,9 +1022,6 @@ impl Tty7App { if others.is_empty() { return None; } - // `filter_hosts` rather than a substring test: it is the same fuzzy - // ranking the machine list already used, so typing `awx` still finds - // `aws-xy` here exactly as it did in the old flow. let hits: Vec<HostChoice> = match query.is_empty() { true => others.to_vec(), false => remote_connect::filter_hosts(others, query), @@ -1462,8 +1054,6 @@ impl Tty7App { GUTTER, Icon::new(IconName::Globe).size(px(ICON)).text_color(dim), )) - // Not "Other SSH Hosts": a WSL distribution is in this band and - // is reached by spawning `wsl.exe`, with no SSH anywhere. .child(div().text_sm().text_color(muted).child("Other Machines")) .child(div().flex_1()) .child( @@ -1490,8 +1080,6 @@ impl Tty7App { ); if expanded { - // No rail here: these rows are machines, not a machine's - // workspaces, so there is no parent for a line to descend from. let mut kids = v_flex().gap(px(1.)); for (i, host) in hits.iter().enumerate() { let choice = (*host).clone(); @@ -1540,13 +1128,6 @@ impl Tty7App { } impl Group { - /// Fold the remote's own workspace list into this group. - /// - /// The remote store is the authority on what exists over there, so anything - /// it names that this client has no record of becomes an extra row marked - /// for adoption. Rows this client *does* have are left alone: their local - /// record carries window geometry and the `open` flag, which are this - /// client's business and not the remote's (the storage split). fn merge(&mut self, remote: &[RemoteWorkspaceRow], now: u64) { if self.target.is_none() { return; @@ -1561,9 +1142,6 @@ impl Group { name: r.name.clone(), path: String::new(), when: crate::ui::home::relative_time(now, r.last_active), - // The remote's list carries a pane *count*, not the pane ids a - // liveness answer is matched against — so there is nothing here - // to be right or wrong about yet. live: Liveness::Stopped, open: false, current: false, @@ -1574,8 +1152,6 @@ impl Group { } } -/// Everything a header's click handler needs, owned — the group itself borrows -/// the store, and a closure cannot hold that across a `&mut cx`. #[derive(Clone)] struct GroupRef { key: String, @@ -1597,7 +1173,6 @@ impl GroupRef { } } -/// The same, for a row. #[derive(Clone)] struct RowRef { id: WorkspaceId, @@ -1618,16 +1193,6 @@ impl RowRef { } } -/// A machine's second-tier actions. -/// -/// "New Workspace" lives here rather than in a row of its own under every -/// machine. It was the one line in the panel that was not a workspace, it -/// repeated once per machine, and on a client with four boxes it pushed the -/// thing the panel is *for* — the list — a quarter of a card further down. -/// -/// The `⋯` is also where a machine's own verbs belong now there is more than -/// one of them: expanding a machine already means "connect", so its inverse -/// needed somewhere to be said, and it is not a row either. fn group_menu( menu: gpui_component::menu::PopupMenu, group: &GroupRef, @@ -1635,9 +1200,6 @@ fn group_menu( ) -> gpui_component::menu::PopupMenu { let (a1, a2, a3) = (app.clone(), app.clone(), app); let gref = group.clone(); - // A remote machine can only be given a workspace once a handshake has said - // where its `$HOME` is — `~` guessed from this client would be the wrong - // directory on the wrong computer. This one needs no handshake. let can_create = group.target.is_none() || group.home.is_some(); let menu = menu.item( PopupMenuItem::new("New Workspace") @@ -1647,8 +1209,6 @@ fn group_menu( }), ); let Some(target) = group.target.clone() else { - // This computer. There is no connection to drop, and "Disconnect" - // greyed out under every local group would only invite the question. return menu; }; let connected = group.link == Link::Connected; @@ -1662,23 +1222,8 @@ fn group_menu( }), ); if !restartable { - // A WSL distribution's server is started by this client and a - // `LocalStdio` one is a child process per connection, so neither has a - // daemon a routed action could restart — the router refuses both, and - // `RemoteTarget::is_ssh` is where the two agree. Absent rather - // than greyed out, for the reason "Disconnect" is absent from the local - // group: a permanently disabled row only invites the question. return menu; } - // Deliberately not gated on `connected`. A server that has to be restarted - // is most often one this client *cannot* reach any more, and the action - // opens its own connection to do the work — requiring a live link would - // withhold the verb from exactly the machine that needs it. - // - // And deliberately *not* closing the panel, unlike the row's destructive - // items. This panel is where a restart has anything to show — the phase bar - // under the machine's header, its rows going dead and coming back — and the - // error card's identical button already leaves it open for that reason. menu.item( PopupMenuItem::new("Restart Server…").on_click(move |_, window, cx| { let _ = a3.update(cx, |this, cx| { @@ -1688,12 +1233,6 @@ fn group_menu( ) } -/// A row's second-tier actions. -/// -/// One `⋯` rather than a cluster of glyphs, and the destructive one behind it -/// rather than out in the open — the rule the sidebar and the old picker -/// already settled on, with the difference that what is revealed here is the -/// menu and not the delete. fn row_menu( menu: gpui_component::menu::PopupMenu, row: &RowRef, @@ -1703,9 +1242,6 @@ fn row_menu( let (id, adopt) = (row.id, row.adopt.is_some()); let stoppable = row.live; menu.item( - // A workspace this client has not adopted yet has no local record to - // rename, stop or remove — opening it is the only thing that can be - // done to it, and that is the row's own click. PopupMenuItem::new("Rename…") .disabled(adopt) .on_click(move |_, window, cx| { @@ -1745,25 +1281,14 @@ fn row_menu( ) } -/// This card's interaction ladder — the popover one, the same rungs every menu -/// and the command palette paint on. fn rungs(cx: &App) -> crate::ui::presets::Surface { cx.global::<crate::ui::presets::Surfaces>().popover } -/// The hover rung, read from the shared surface presets rather than an alpha -/// over whatever shows through. fn hover_fill(cx: &App) -> gpui::Rgba { gpui::rgb(rungs(cx).hover) } -/// One glyph, centred in a column of `w`. -/// -/// Every icon in the panel goes through this, which is the whole point: the old -/// layout let each row start its own glyph wherever its padding happened to -/// land, so nothing shared a vertical axis. A machine's column is [`GUTTER`]; -/// the rows underneath use the narrower [`ROW_AVATAR`], so their monograms -/// share one axis of their own rather than sitting 3px off the machines'. fn glyph_col(w: f32, child: impl IntoElement) -> impl IntoElement { div() .w(px(w)) diff --git a/src/ui/tab_sidebar.rs b/src/ui/tab_sidebar.rs index 4f43b641..fb33b883 100644 --- a/src/ui/tab_sidebar.rs +++ b/src/ui/tab_sidebar.rs @@ -1,16 +1,3 @@ -//! The vertical tab sidebar: the left-side alternative to the horizontal -//! [`tab_strip`](crate::ui::tab_strip), shown when `tab_bar_position` is `left`. -//! One full-width row per tab — label, inline rename, drag-to-reorder, hover -//! close — under a search + new-tab control bar at the top of the rail. -//! -//! Split out of `app.rs` as an `impl Tty7App` block, exactly like `tab_strip`. -//! It shares the model wholesale: the same `self.tabs`/`self.active` state, the -//! same `tab_label`, the same `activate`/`close_tab`/`start_rename` operations, -//! the same `DragTab` payload and reorder machinery, and the same theme tokens -//! the chips use — so the vertical list stays pixel-consistent with the strip -//! and adds no new state or business logic, only a new set of click targets in -//! a new shape. - use gpui::{ Animation, AnimationExt as _, AnyElement, Axis, Bounds, Context, Div, FontWeight, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, SharedString, Stateful, Window, canvas, @@ -32,104 +19,51 @@ use crate::ui::hints::tab_badge_label; use crate::ui::reorder::{self, Reorder, Surface}; use crate::ui::tab_strip::{DragTab, REORDER_SLIDE_MS}; -/// Minimum sidebar width, and the maximum as a fraction of the window width, so -/// a resize drag can't collapse the rail or let it swallow the terminal. const MIN_SIDEBAR_WIDTH: f32 = 180.; -/// The bare slice of the rail's top zone kept clear for grabbing the window by, -/// the same guarantee [`crate::ui::tab_strip::GRAB_HANDLE_W`] makes for the -/// horizontal strip. Smaller than that one because this row is never crowded: -/// it holds the brand mark and two tiles, and the rail's own floor -/// ([`MIN_SIDEBAR_WIDTH`]) leaves ~70px of slack even at its narrowest. const GRAB_HANDLE_W: f32 = 48.; const MAX_SIDEBAR_WIDTH_RATIO: f32 = 0.5; -/// Width (px) of the draggable resize handle's invisible hit-area, centered on -/// the rail's right border; it holds a 1px hairline that brightens on hover / -/// drag. Centered (half overhangs the body) so it clears the row close buttons. const RESIZE_HANDLE_WIDTH: f32 = 8.; -/// Gap between rows in the rail, and between the group blocks — the distance a -/// row or block travels on top of its own height when a drag passes it. const ROW_GAP: f32 = 2.; -/// Marks a live drag as a *group* drag — the sidebar counterpart to -/// [`DragTab`], and like it a stateless marker that renders nothing: the block -/// being dragged never leaves the rail, so there is no card floating over the -/// window. Its type is what tells the rail's drop handlers "this is a group, -/// not a tab". Scratch never starts one: it's pinned last by -/// [`sidebar_sections`], so it has no slot to move to. #[derive(Clone)] pub(crate) struct DragGroup; impl Render for DragGroup { fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement { - // gpui always paints *something* at the cursor for an active drag; - // an empty, zero-sized element is how this drag paints nothing. div() } } impl Tty7App { - /// The vertical tab sidebar rendered down the left edge of the body in - /// `tab_bar_position: left` mode. Only reached when at least one tab is open - /// (the caller keeps the horizontal layout for the zero-tab home page), so - /// there's no empty state to render. pub(crate) fn tab_sidebar( &self, window: &mut Window, cx: &mut Context<Self>, ) -> impl IntoElement + use<> { let active = self.active; - // The rail is a sunk column, so its rows read the sidebar ladder. let sf = cx.global::<crate::ui::presets::Surfaces>().sidebar; - // While the bare ⌘/Ctrl hold is armed (see `ui::hints`), each of the - // first nine rows swaps its close affordance for a ⌘N badge — same slot - // and footprint as the chips, so the vertical list gets the identical - // "hold to see switch digits" hint the horizontal strip has. let show_badges = self.mod_hint_badges; - // Width from the persisted/drag-updated cell, re-clamped to the live - // window so a saved value never exceeds half the (possibly smaller) - // viewport. The floor wins if half the window is somehow narrower. let max_width = (window.viewport_size().width.as_f32() * MAX_SIDEBAR_WIDTH_RATIO) .max(MIN_SIDEBAR_WIDTH); let width = self.sidebar_width.get().clamp(MIN_SIDEBAR_WIDTH, max_width); - // Live filter query from the top-bar search box; empty matches all. let query = self.sidebar_search.read(cx).value().trim().to_lowercase(); let mut list = v_flex() - // An id + `overflow_y_scroll` makes the row column scroll on its own - // when the tabs outgrow the window height, leaving the "+" footer - // pinned (same pattern the settings panel uses). `track_scroll` lets - // `activate` pull the selected row into view — and feeds the overlay - // scrollbar the wrapper below hangs over this column. .id("tab-sidebar-list") .track_scroll(&self.sidebar_scroll) .flex_1() .min_h_0() .overflow_y_scroll() - // 4px horizontal, so a row's own `pl_2` puts its content on the rail's - // 12px content inset — the same line the search row and the top - // controls use. The 8px the capsule stops short of the rail edge is - // what makes the active row read as inset rather than full-bleed. .px_1() .py_1p5() - // Tight row-to-row spacing so the tabs read as one dense list, not a - // set of far-apart cards (each row already has its own padding). .gap_0p5(); - // ── Repo grouping ───────────────────────────────────────────────────── - // Per-tab sticky group keys and the sections derived from them (both - // documented on their functions). The same pair drives - // `visual_tab_order`, so the ⌘N digits painted below and the ⌘N - // actions always agree on which row is "tab 3". let keys: Rc<Vec<Option<PathBuf>>> = Rc::new(self.sidebar_group_keys(cx)); let sections = sidebar_sections(&keys); - // Each row's position in display order — the digit its ⌘N badge shows. - // Claimed for every tab, filtered-out ones included, and read off this - // map rather than counted as rows are emitted, so neither the search - // box nor a drag's live reflow can renumber the shortcuts under you. let badge_pos: Vec<usize> = { let mut pos = vec![0usize; self.tabs.len()]; for (n, i) in sections.iter().flat_map(|s| s.tabs.iter()).enumerate() { @@ -138,14 +72,6 @@ impl Tty7App { pos }; - // Which tabs each section actually lists, and their labels. Settled - // before anything is laid out, because both drag surfaces are keyed to - // what is *visible*: a group the search box has emptied isn't rendered, - // so it must not claim a slot in the group geometry either — a phantom - // zero-sized slot would sit at the origin and swallow every crossing. - // (Matching is on the visible label; a row keeps its real tab index, so - // activate/close/reorder still hit the right tab when the list is - // narrowed.) let visible_by_section: Vec<Vec<(usize, String)>> = sections .iter() .map(|s| { @@ -157,11 +83,6 @@ impl Tty7App { }) .collect(); - // ── Live drag-reorder, part 1: the group blocks ─────────────────────── - // Repo groups can be dragged by their header to reorder the whole - // block; Scratch can't (it's pinned last, so it has neither a slot to - // move to nor one to give up), so the draggable slots are exactly the - // rendered repo groups. See [`crate::ui::reorder`] for the machinery. let pointer = window.mouse_position(); let rendered = |ix: &usize| !visible_by_section[*ix].is_empty(); let repo_slots: Vec<usize> = (0..sections.len()) @@ -179,8 +100,6 @@ impl Tty7App { .collect(); let slot_display: Vec<usize> = match &group_preview { Some(p) => { - // Same as the rows below: record what releasing right now would - // produce, so the commit doesn't depend on where the cursor is. if let (Some(from), Some(to)) = (repo_roots.get(p.from), repo_roots.get(p.target)) && let Some(order) = regrouped_order(&keys, from, to) { @@ -190,8 +109,6 @@ impl Tty7App { } None => (0..repo_groups).collect(), }; - // The blocks to lay out, as `(drag slot, section)`. Repo groups lead in - // the previewed slot order; Scratch trails them with no slot of its own. let mut blocks: Vec<(Option<usize>, usize)> = slot_display .into_iter() .map(|slot| (Some(slot), repo_slots[slot])) @@ -206,15 +123,11 @@ impl Tty7App { for (group_slot, group_ix) in blocks { let section = §ions[group_ix]; let group_key = section.key.clone(); - // Kept as concrete elements, not `AnyElement`s: a live drag - // restyles them (the slide-in offset), which can only be applied - // once every row of the group has been built. let mut rows: Vec<ContextMenu<Stateful<Div>>> = Vec::new(); let visible = visible_by_section[group_ix].clone(); let visible_tabs: Vec<usize> = visible.iter().map(|(i, _)| *i).collect(); let row_slots: Rc<RefCell<Vec<Bounds<Pixels>>>> = Rc::new(RefCell::new(vec![Bounds::default(); visible.len()])); - // ── Live drag-reorder, part 2: the rows of this group ───────────── let row_preview = reorder::preview( &self.reorder, &Surface::SidebarRows(group_key.clone()), @@ -225,39 +138,15 @@ impl Tty7App { let badge_pos = badge_pos[i]; let tab = &self.tabs[i]; let is_active = i == active; - // No status/cwd text under the title: the avatar's status dot - // already carries working/waiting/done, and the group header + the - // trailing branch tag carry the location — a "Working…" or cwd - // line would just be noise. One line per row, nothing else. - // Leading avatar inputs: the SSH connection-status colour (PRD - // FR-E2) and the coding agent running in the tab, if any — the - // avatar brands the row by whichever applies. let ssh_dot = self.tab_ssh_dot(tab, cx); let agent = tab.agent(cx); let agent_status = tab.agent_status(cx); let agent_unread = tab.agent_unread_count(cx); - // Second line, when the pane is inside a git work tree: the - // branch (flexes + truncates) with the working-tree diff pinned - // to the row's right. Kept *off* the title line on purpose — a - // long branch or a big `+426 −238` would otherwise crowd the - // title into an ellipsis. The branch is also the row's most - // volatile text (checkouts, rebases), so isolating it here means - // a change never disturbs the title; grouping keys on the repo - // root only, so a branch switch never relocates the row either - // (see `Tab::sidebar_group`). The diff counts are a quiet - // green/red readout and, unless `sidebar_diff_preview` is off, - // double as the diff-overlay toggle: click them to peek another - // session's changes in an overlay without activating this row's - // tab. The cwd they probe is the same one the status resolved - // through, so overlay and counts always describe the same repo. let git_cwd = diff_click_cwd( cx.global::<Config>(), tab.pane.focused_or_first(window, cx).and_then(|leaf| { let view = leaf.read(cx); let cwd = view.git_status_cwd()?.to_path_buf(); - // The id, not the host: opening the overlay needs no live - // connection — a disconnected machine's last diff is still - // worth showing, and the re-probe resolves the id itself. Some((view.host_id(), cwd)) }), ); @@ -276,8 +165,6 @@ impl Tty7App { .size(px(11.)) .text_color(cx.theme().muted_foreground), ) - // Branch name flexes and truncates; the counts stay - // pinned right (a long branch ellipsizes, counts don't). .child(div().flex_1().min_w_0().truncate().child(g.branch.clone())); if g.added > 0 || g.removed > 0 { let mut counts = h_flex() @@ -289,8 +176,6 @@ impl Tty7App { counts.cursor_pointer().on_mouse_down( MouseButton::Left, cx.listener(move |this, _: &MouseDownEvent, window, cx| { - // Swallow the press so the row/label - // handlers don't also activate the tab. cx.stop_propagation(); this.toggle_diff_overlay(host, cwd.clone(), window, cx); }), @@ -314,9 +199,6 @@ impl Tty7App { } line }); - // Inline rename input for this tab, if it's the one being renamed — - // the same `self.renaming` branch the strip uses, so a context-menu - // rename works identically in either layout. let rename_input = self .renaming .as_ref() @@ -328,8 +210,6 @@ impl Tty7App { .id(("sidebar-rename", i)) .flex_1() .min_w_0() - // Swallow the mouse-down (incl. double-click word-select) so - // it doesn't reach the row's activate handler below. .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) .child(Input::new(&input).appearance(false)) .into_any_element(), @@ -337,50 +217,23 @@ impl Tty7App { .id(("sidebar-label", i)) .flex_1() .min_w_0() - // A hair of air between the title and branch lines. .gap(px(2.)) - // Title line — ellipsis-truncate so a long label degrades - // gracefully in the fixed-width rail rather than hard-clipping. .child( div() .w_full() .truncate() .text_sm() - // Active row carries a hair more weight, matching the chip. .when(is_active, |d| d.font_weight(FontWeight::MEDIUM)) .child(label), ) - // Branch + diff line, when the pane sits in a git repo. .children(git_line) - // No mouse handler of its own: activation *and* the - // reorder drag both live on the row, and a child that - // swallowed the press would take the label — the - // largest part of the row — out of both. (Only the - // diff counts inside the branch line stop the press, - // deliberately: they're their own click target.) .into_any_element(), }; let row = h_flex() .id(("tab-row", i)) - // A per-row group so this row's close affordance reveals on its own - // hover without touching siblings (same trick as the chip). .group(SharedString::from(format!("tab-row-{i}"))) - // A row is first of all a switch target, so the hover - // cursor says "click me"; picking it up swaps in the - // closed hand (see `Tty7App::render`). .cursor_pointer() - // Drag anywhere on the row to reorder it (shared `DragTab`). - // On the row, not on its label: the drag's frame of - // reference is where the *row* was grabbed, which is what - // the frozen geometry below measures — hang it off the - // label and the held row rides a few pixels off the cursor, - // skewing every crossing by that much. `slot` is the row's - // position among the *visible* rows of its group; a drag - // never leaves the group, so that's the whole world it - // needs. The builder runs once, when gpui promotes the - // press into a drag, freezing the geometry as of the last - // painted frame. .on_drag(DragTab, { let state = self.reorder.clone(); let slots = row_slots.clone(); @@ -399,11 +252,6 @@ impl Tty7App { } }) .w_full() - // Size to content with a small, uniform vertical padding: a - // one-line shell tab is a short row, a two-line git tab - // (title + branch) is a taller one. The *padding* is what - // stays consistent, so rows read as harmonious even though - // heights differ. .py_1() .items_center() .justify_between() @@ -411,11 +259,6 @@ impl Tty7App { .pl_2() .pr_2() .rounded_lg() - // Sidebar-surface token scheme (gpui-component's Sidebar - // semantics), so the rows sit cohesively on the sunk rail rather - // than reading as chips: active = the sidebar-accent fill + its - // paired foreground; inactive = the muted sidebar foreground with - // a half-strength accent on hover (a natural hover→active ramp). .when(is_active, |s| { s.bg(cx.theme().sidebar_accent) .text_color(cx.theme().sidebar_accent_foreground) @@ -424,14 +267,9 @@ impl Tty7App { s.text_color(cx.theme().sidebar_foreground) .hover(|s| s.bg(gpui::rgb(sf.hover))) }) - // Held: a light dimming so the row under your cursor reads - // as picked up. Not a lift — it stays in the rail's plane. .when(row_preview.as_ref().is_some_and(|p| p.from == slot), |s| { s.opacity(0.75) }) - // Measures this row into its group's slot table — the - // geometry a drag starting on a later frame freezes. - // Absolute and empty, so it costs the layout nothing. .child( canvas( { @@ -444,13 +282,9 @@ impl Tty7App { }, |_, _, _, _| {}, ) - // `inset_0`, not `size_full` — see the strip's copy of - // this canvas for why the distinction matters. .absolute() .inset_0(), ) - // A click anywhere on the row (padding, gaps) activates it; the - // label and close children stop propagation for their own actions. .on_mouse_down( MouseButton::Left, cx.listener(move |this, _: &MouseDownEvent, window, cx| { @@ -458,17 +292,9 @@ impl Tty7App { this.activate(i, window, cx); }), ) - // Leading avatar: agent brand mark, SSH status, or shell glyph. .child(self.tab_avatar(agent, agent_status, agent_unread, ssh_dot, 22., cx)) .child(label_region) - // Trailing ⌘N badge: while the shortcut hints are armed the - // row shows its switch digit in an in-flow 20px slot (an - // all-rows-at-once modal reflow, same as the strip). The digit - // is the row's *display* position (`activate_visual` speaks the - // same order), so under grouping the rail still reads 1…9 top - // to bottom instead of scattering the tab-vector indices. .when(show_badges && badge_pos < 9, |row| { - // Bare digit, no keycap box — matches the chip badge exactly. row.child( div() .flex_shrink_0() @@ -486,25 +312,7 @@ impl Tty7App { .child(tab_badge_label(badge_pos)), ) }) - // Close affordance: out of flow, so the label runs the full - // rail width instead of always reserving a slot for a button - // that's invisible until hover (same Safari-style float as - // the strip's chips). On hover the ✕ sits over the *title - // line's* right end — pinned to the row top, not centered: - // on a two-line row a centered ✕ would straddle both lines - // and cover the branch line's `+n −n` counts, which are a - // click target of their own (the diff-overlay toggle). A - // solid backing in the row's hover fill plus a short - // gradient run-in fades covered title text out instead of - // hard-cutting mid-glyph. Nothing reflows on hover. .when(!(show_badges && badge_pos < 9), |row| { - // The row fills are composited over the rail (the accent - // carries alpha; the inactive hover is a half-strength - // wash), so flatten them against `sidebar` to get the - // opaque colour the float must match. - // Both rungs are opaque ladder colours, so the float's - // backing is just the rung itself — no flattening needed, - // and no alpha to drift out of step with the row it copies. let backing: gpui::Hsla = if is_active { gpui::rgb(sf.selected).into() } else { @@ -515,8 +323,6 @@ impl Tty7App { row.child( h_flex() .absolute() - // `py_1` row padding: the 20px button covers the - // title line exactly. .top(px(4.)) .right(px(6.)) .opacity(0.) @@ -542,9 +348,6 @@ impl Tty7App { ) }); - // Per-tab right-click menu, shared with the strip's chips; - // `below_wording` flips the trailing close to "Close Tabs Below" - // to match the vertical layout. let menu_app = cx.entity().downgrade(); rows.push(row.context_menu(move |menu, window, cx| { Tty7App::tab_context_menu(menu, i, true, &menu_app, window, cx) @@ -557,9 +360,6 @@ impl Tty7App { let row_display: Vec<usize> = match &row_preview { Some(p) => { - // Record the tab order releasing right now would produce, so - // letting go applies exactly what's on screen no matter where - // the cursor ended up (see `reorder::set_pending`). if let Some(order) = reordered_rows(&keys, &group_key, &visible_tabs, p.from, p.target) { @@ -579,11 +379,6 @@ impl Tty7App { let rows: Vec<AnyElement> = row_display .into_iter() .map(|slot| match &row_preview { - // The row in hand: drawn wherever the cursor is holding it, - // pixel for pixel, with no animation in the way. `deferred` - // keeps its slot in the layout but paints it after its - // siblings, so it passes *over* the rows it's crossing - // instead of being clipped behind them. Some(p) if p.from == slot => deferred( rows[slot] .take() @@ -592,9 +387,6 @@ impl Tty7App { .top(p.held), ) .into_any_element(), - // Slide into place rather than teleporting. `offset` is - // zero for every row the last crossing left alone, so one - // row moves at a time instead of the group re-animating. Some(p) => { let offset = p.offsets[slot].as_f32(); rows[slot] @@ -617,13 +409,6 @@ impl Tty7App { .into_any_element(), }) .collect(); - // Group header: the repo's directory name (or "Scratch"), small - // and muted so it labels without competing with the rows, plus the - // visible-row count. Not a click target — rows do the activating — - // but it *is* the whole group's drag handle: drag one project name - // and the block moves, tabs and all, with the other groups sliding - // around it exactly as rows do inside one. Scratch (`group_key == - // None`) sits out: it's pinned last, so it has nowhere to go. let header = section.name.clone().map(|name| { let label: SharedString = name.to_uppercase().into(); h_flex() @@ -638,14 +423,6 @@ impl Tty7App { .text_size(px(11.)) .text_color(cx.theme().muted_foreground) .when_some(group_slot, |header, slot| { - // Unlike a row, a header does nothing on click — its - // only affordance is the drag, so the open hand is the - // honest hover cursor (it closes once you pick it up). - // Windows has no open-hand system cursor, and gpui's - // Windows backend falls every unmapped `CursorStyle` - // through to `IDC_ARROW` — which reads as "nothing to - // do here". Point there instead: it's the same cursor - // the rows use, and it at least says "interactive". let header = if cfg!(target_os = "windows") { header.cursor_pointer() } else { @@ -662,20 +439,12 @@ impl Tty7App { slots.borrow().clone(), Axis::Vertical, px(ROW_GAP), - // The header is the handle, but the *block* - // is what moves. The header leads the block, - // so the grab point inside the header is - // also the grab point inside the block — - // it passes through unchanged. grab, )); cx.new(|_| DragGroup) } }) }) - // Count sits right next to the name (not pushed to the - // rail's right edge): the name shrinks and truncates if - // long, the count trails it as a quiet tally. .child( div() .flex_shrink(1.) @@ -692,13 +461,9 @@ impl Tty7App { ) }); - // One block per group — header plus its rows — so a header drag can - // move the whole thing as a unit and measure it as one slot. let block = v_flex() .w_full() .gap(px(ROW_GAP)) - // Held: the block you're dragging dims, exactly as a held row - // does — nothing lifts off the rail. .when( group_preview .as_ref() @@ -707,10 +472,6 @@ impl Tty7App { ) .children(header) .children(rows) - // Measures the block for the group-drag geometry. Only the - // rendered repo groups hold a slot — Scratch is pinned last and - // never moves, and a group the search box emptied isn't here at - // all — so `group_slot` indexes that list, not `sections`. .when_some(group_slot, |block, slot| { block.child( canvas( @@ -730,13 +491,9 @@ impl Tty7App { }); list = list.child(match (&group_preview, group_slot) { - // The block in hand tracks the cursor, painted over the ones it - // crosses (same treatment a held row gets inside a group). (Some(p), Some(slot)) if p.from == slot => { deferred(block.relative().top(p.held)).into_any_element() } - // Everything else slides; a slotless block (Scratch) never - // moves, so it falls through to the plain block below. (Some(p), Some(slot)) => { let offset = p.offsets[slot].as_f32(); block @@ -755,36 +512,15 @@ impl Tty7App { }); } - // The rail's own controls — new tab, and collapse — live in the top zone - // beside the traffic lights, right-aligned to the rail's content edge - // rather than sitting in the search row. Two reasons: the search row is - // for searching, and a collapse button that lives *inside* the rail would - // disappear along with it (its counterpart then appears in the title - // strip, see `tab_strip`). Right-aligned, they ride the rail's right edge, - // which is what says "these belong to this panel" when it's resized. let controls = h_flex() .flex_shrink_0() .h(px(TITLE_BAR_HEIGHT)) - // Same box as the real title bar this row stands in for, hairline - // included: gpui-component's `TitleBar` draws a `border_b_1` inside its - // own `TITLE_BAR_HEIGHT` (tty7 paints it transparent, but it still takes - // its pixel), so the bar centres its contents on 19.5 while an - // unbordered 40px row centres them on 20. Half a pixel is invisible on - // the line-art tiles, and *not* on the solid brand mark: collapsing the - // rail hands the mark from this row to the bar, and it visibly hopped up - // as it went. Reserve the same pixel here and the handover is still. .border_b_1() .border_color(cx.theme().transparent) .items_center() .justify_end() .gap(px(2.)) - // Glyph's ink, not hit box, on the content edge — see `TILE_PAD`. .pr(px(crate::ui::app::tile_trailing_inset())) - // The brand mark leads the row, on the rail's own content inset — the - // line the search magnifier and every row label below it start on, so - // it reads as the head of this column rather than a floating badge. - // The spacer is what keeps the controls pinned right once the row has - // a leading child (`justify_end` alone no longer does it). .when_some(crate::ui::app::window_mark(), |row, mark| { row.child( div() @@ -792,29 +528,11 @@ impl Tty7App { .pl(px(crate::ui::app::CONTENT_INSET)) .child(mark), ) - // The row's grab handle, and `min_w` (`GRAB_HANDLE_W`) so it - // stays one whatever the row later gains: a bare `flex_1` takes - // only leftover space, and leftover space is what a wider child - // eats first. Every header in the window has to stay grabbable — - // see `app::window_move_gesture`. (On macOS there is no mark and - // so no spacer: `justify_end` leaves the row's whole left half - // bare, which is the handle.) .child(div().flex_1().min_w(px(GRAB_HANDLE_W))) }) - // Both tiles are wrapped in an `occlude()` div, exactly like the - // title-strip chrome. This row is a `WindowControlArea::Drag` (set - // below), which on Windows maps to HTCAPTION — the OS claims the click - // as a window-drag before gpui ever hit-tests, so a bare button never - // fires its `on_click`. `occlude()` gives each a BlockMouse hitbox so - // hit-testing stops on the button. (No-op on macOS, where titlebar - // dragging doesn't gate child hit-testing — which is why this worked - // there and silently did nothing on Windows.) .child( div().occlude().flex_shrink_0().child( self.attach_new_tab_menu( - // `chrome_tile`, not `ghost()`: this "+" sits beside the - // collapse tile and the title bar's own "+", and ghost's - // hover is a heavier, differently-derived grey. crate::ui::tab_strip::chrome_tile_sized( Button::new("sidebar-add").icon(Icon::new(IconName::Plus)), crate::ui::app::TILE_SIZE, @@ -840,28 +558,12 @@ impl Tty7App { .on_click(cx.listener(|this, _, _window, cx| this.toggle_left_panel(cx))), ), ); - // The rail's head: which workspace this window is on. A row of its own, - // above the search box, because it names the thing the whole column - // enumerates. It is deliberately *not* folded into the repo group headers - // below — those are repositories, and one workspace holds several of them. let workspace_head = h_flex() .flex_shrink_0() .px(px(crate::ui::app::CONTENT_INSET - 7.)) .pt(px(4.)) .child(self.workspace_head(cx)); - // Borderless "Search tabs…" that sits directly on the sunk surface: a - // leading magnifier + an appearance-less input, no box and no divider - // under the bar, so the control row and list read as one continuous rail - // rather than stacked panels. - // Laid out to land on the workspace chip directly above it rather than - // on the rail's own inset: the magnifier takes a column the width of the - // chip's monogram and the same 6px gap after it, so glyph sits over - // glyph and "Search tabs…" over the workspace name. Getting there needs - // three numbers that were all being left to their defaults — the chip is - // an `xsmall` Button, which adds 4px of padding of its own inside the - // rail's inset, and a default-size `Input` carries 12px more whether or - // not it draws a box. let chip_inset = crate::ui::app::CONTENT_INSET - 7. + 4.; let top_bar = h_flex() .flex_shrink_0() @@ -890,10 +592,6 @@ impl Tty7App { .child(Input::new(&self.sidebar_search).appearance(false).pl_0()), ); - // ── Resize drag (mirrors the split divider in `pane.rs`) ────────────── - // A backing canvas measures the rail's bounds into a per-frame cell and, - // while the handle is held, installs window-level mouse listeners so the - // drag keeps tracking even when the pointer outruns the thin handle. let container: Rc<Cell<Option<Bounds<Pixels>>>> = Rc::new(Cell::new(None)); let backing = canvas( { @@ -905,8 +603,6 @@ impl Tty7App { let width_cell = self.sidebar_width.clone(); let dragging = self.sidebar_dragging.clone(); move |_bounds, _state, window, _cx| { - // Track the pointer while the handle is held: width = pointer - // x minus the rail's left edge, clamped to the live bounds. window.on_mouse_event({ let container = container.clone(); let width_cell = width_cell.clone(); @@ -926,8 +622,6 @@ impl Tty7App { window.refresh(); } }); - // On release, end the drag and persist the final width so it - // survives a restart (the config observer re-syncs the cell). window.on_mouse_event({ let width_cell = width_cell.clone(); let dragging = dragging.clone(); @@ -951,16 +645,6 @@ impl Tty7App { .absolute() .size_full(); - // The draggable handle at the right edge: a comfortable invisible hit-area - // centered over the border, holding a 1px line that brightens on hover / - // drag (the border stays visible underneath when idle). - // - // `occlude()` because it runs the rail's full height, which means its top - // 40px lie over the rail's title-bar stand-in — a `WindowControlArea::Drag` - // row. Without it gpui's hit test still reports that row hovered under the - // handle, so a press there arms the window move *as well as* the resize and - // the first drag moves the window instead (and on Windows HTCAPTION claims - // the press outright). See [`crate::ui::app::window_move_gesture`]. let handle_active = self.sidebar_dragging.get(); let handle = div() .group("sidebar-resize") @@ -994,31 +678,13 @@ impl Tty7App { .flex_shrink_0() .w(px(width)) .h_full() - // The whole rail is the sunk `sidebar` surface (a few % off the body), - // so the color contrast — not hard lines — separates it from the - // terminal. A single hairline right edge in the paired border token - // delineates the seam, so the rail reads as one cohesive surface. .bg(cx.theme().sidebar) .border_r_1() .border_color(cx.theme().sidebar_border) - // The measurer/listener sits behind the content, the handle on top. .child(backing) .child( v_flex() .size_full() - // A title-bar-height top zone: on macOS the traffic lights - // sit on the rail's surface here, and it aligns the search box - // with the terminal's top (which starts below the title bar), - // so the rail reads as one panel from the very top edge. The - // rail's controls ride its right end, on the title bar's own - // center line — same row as the "⋯" across the window. - // - // The real `TitleBar` — which carries the window's drag region - // — only spans the *right* column in this layout, so this strip - // would be dead space you can't grab the window by. `title_bar_drag` - // makes the controls' row act like the bar it sits level with: - // drag to move, double-click to zoom, while the buttons on the - // right keep taking their own clicks (they're `occlude()`d). .child(crate::ui::app::title_bar_drag( controls.id("sidebar-titlebar-drag"), "sidebar-titlebar-drag", @@ -1036,17 +702,6 @@ impl Tty7App { .child(handle) } - /// Each tab's sidebar group key, in tab order: the *repository home* of - /// its *first* pane's cwd (the main checkout's root — linked worktrees of - /// one repo share a group), resolved through the tab's sticky - /// `sidebar_group` cell — only a landed probe answer moves a tab (see the - /// field's doc), so an in-flight cd never reshuffles the list. The first - /// pane rather than the focused one (which the branch line follows), so - /// switching focus between splits in different repos never relocates the - /// row — the group answers "where does this tab live", not "what am I - /// touching". `None` = the Scratch group. With grouping configured off - /// every key is `None`, which collapses the list to one flat section and - /// makes the same-group drop check a no-op. fn sidebar_group_keys(&self, cx: &gpui::App) -> Vec<Option<PathBuf>> { let grouping = cx.global::<Config>().sidebar_grouping == SidebarGrouping::Repo; self.tabs @@ -1055,16 +710,6 @@ impl Tty7App { if !grouping { return None; } - // The *lookup* is per machine, so a pane never resolves its - // repo through another host's table. The key stays a bare - // path, and that is correct rather than a shortcut: a tab - // belongs to one workspace, a workspace names one machine in - // `Workspace.host`, and a window shows one workspace — design - // Mixing local and remote in one window never happens. So - // the qualified key is `(workspace.host_id(), sidebar_group)` - // with the host half held once per workspace instead of once - // per tab, and two machines can't collide here without a - // window the model does not permit. let cwd = tab.pane.first_leaf().and_then(|leaf| { let view = leaf.terminal()?.read(cx); Some((view.host_id(), view.git_status_cwd()?.to_path_buf())) @@ -1079,10 +724,6 @@ impl Tty7App { .collect() } - /// Tab indices in the order the tab UI displays them: the grouped order - /// when the vertical sidebar is grouping by repo, plain tab order in - /// every other layout. ⌘N and the hint digits both go through this, so - /// "press 3 for the third row you see" stays true under grouping. fn visual_tab_order(&self, cx: &gpui::App) -> Vec<usize> { if cx.global::<Config>().tab_bar_position != crate::core::config::TabBarPosition::Left { return (0..self.tabs.len()).collect(); @@ -1094,9 +735,6 @@ impl Tty7App { .collect() } - /// Activate the `n`-th tab *as displayed* (see - /// [`visual_tab_order`](Self::visual_tab_order)) — the ⌘N actions land - /// here so the shortcut always matches the digit badge on the row. pub(crate) fn activate_visual( &mut self, n: usize, @@ -1109,24 +747,13 @@ impl Tty7App { } } -/// One block of the sidebar: a header and the tabs under it. #[derive(Debug, PartialEq)] struct Section { - /// The repo root this group is keyed on, `None` for Scratch. Sections with - /// a key are the draggable ones (Scratch is pinned last), and it doubles as - /// the group's identity in a drag. key: Option<PathBuf>, - /// The header text, or `None` for "render flat, no header". name: Option<String>, - /// The group's tabs, in tab order. tabs: Vec<usize>, } -/// Partition per-tab group keys into the sidebar's sections: groups in -/// first-appearance order (a new repo appends, the existing ones never -/// reshuffle) with the Scratch group pinned last. A nameless single section -/// means "render flat, no headers" — used when no tab is in any repo, where a -/// lone Scratch header over everything would be noise. fn sidebar_sections(keys: &[Option<PathBuf>]) -> Vec<Section> { let mut group_order: Vec<&PathBuf> = Vec::new(); for k in keys.iter().flatten() { @@ -1164,16 +791,6 @@ fn sidebar_sections(keys: &[Option<PathBuf>]) -> Vec<Section> { sections } -/// The tab permutation for a row dropped at a new place inside its own group: -/// `visible` are the group's rows as the rail currently lists them (the search -/// box may be hiding others), and the row at `from` lands where the row at `to` -/// is now. -/// -/// Like [`regrouped_order`] this returns a whole-vector permutation rather than -/// a single move, because "third row in this group" only means something once -/// the vector is laid out the way the rail draws it: groups in their existing -/// order, each one contiguous, Scratch last. Rows the filter is hiding keep -/// their place in the group, and no other group is disturbed. fn reordered_rows( keys: &[Option<PathBuf>], group: &Option<PathBuf>, @@ -1187,8 +804,6 @@ fn reordered_rows( } let mut members: Vec<usize> = (0..keys.len()).filter(|&i| keys[i] == *group).collect(); members.retain(|&i| i != moved); - // Land it on the far side of the row it was dropped onto, so dragging down - // ends up below that row and dragging up above it. let at = members.iter().position(|&i| i == anchor)? + usize::from(to > from); members.insert(at, moved); @@ -1203,17 +818,6 @@ fn reordered_rows( Some(out) } -/// The tab permutation that moves the group rooted at `from` into `to`'s slot, -/// as old indices in their new order — or `None` when the move is a no-op (same -/// group, or either root no longer has any tab). -/// -/// Groups are ordered by first appearance in the tab vector, so the move is -/// "reorder the group list, then lay the tabs back out group by group". Each -/// group therefore comes out *contiguous*, with Scratch last, matching exactly -/// what the sidebar renders — which also settles the old caveat that a tab drag -/// inside an interleaved group could shuffle other groups' headers: after any -/// header drag the vector is compacted and interleaving is gone. Relative order -/// within a group is preserved. fn regrouped_order(keys: &[Option<PathBuf>], from: &Path, to: &Path) -> Option<Vec<usize>> { if from == to { return None; @@ -1233,20 +837,11 @@ fn regrouped_order(keys: &[Option<PathBuf>], from: &Path, to: &Path) -> Option<V for g in &order { out.extend((0..keys.len()).filter(|&i| keys[i].as_ref() == Some(*g))); } - // Scratch tabs trail the repo groups, which is where the sidebar draws them. out.extend((0..keys.len()).filter(|&i| keys[i].is_none())); Some(out) } -/// Display names for the group roots: each root's directory name, extended -/// upward by parent components only while it collides with another root's -/// (`app` stays `app` on its own; two checkouts both named `app` become -/// `work/app` and `fork/app`). Distinct roots must differ somewhere, so the -/// loop settles; a root that exhausts its components while still colliding -/// just keeps its longest suffix. fn group_names(roots: &[&PathBuf]) -> Vec<String> { - // Only the normal components — no root-dir "/" entry, so a joined suffix - // never renders as "//work/app". let comps: Vec<Vec<String>> = roots .iter() .map(|r| { @@ -1264,7 +859,6 @@ fn group_names(roots: &[&PathBuf]) -> Vec<String> { .enumerate() .map(|(i, (c, &d))| { if c.is_empty() { - // Degenerate root with no normal components (e.g. "/"). roots[i].display().to_string() } else { c[c.len().saturating_sub(d)..].join("/") @@ -1288,18 +882,6 @@ fn group_names(roots: &[&PathBuf]) -> Vec<String> { } } -/// The machine-and-cwd a sidebar row's `+N −N` counts should open the diff -/// overlay for, or `None` when they're a plain readout. Generic over the -/// payload so it stays indifferent to what identifies a repo — that pair grew a -/// `HostId` when panes learned to live on other machines, and this gate did not -/// need to know. -/// -/// One value drives both halves of the interaction: the render path hangs the -/// pointer cursor *and* the `toggle_diff_overlay` mouse handler off the same -/// `when_some`, so a `None` here provably removes both and the press falls -/// through to the row's own activate handler like any other part of the label. -/// The branch and the counts themselves don't consult this — turning the -/// preview off must not cost you the readout (issue #239). fn diff_click_cwd<T>(cfg: &Config, target: Option<T>) -> Option<T> { cfg.sidebar_diff_preview.then_some(target).flatten() } @@ -1312,9 +894,6 @@ mod tests { PathBuf::from(s) } - /// With the preview on (the default) the counts carry the repo's cwd, which - /// is what gives them the pointer cursor and the `toggle_diff_overlay` - /// handler; with it off they carry nothing and neither is attached. #[test] fn diff_preview_setting_gates_the_click_target() { let mut cfg = Config::default(); @@ -1333,8 +912,6 @@ mod tests { ); } - /// A pane outside a git work tree has no cwd to open either way — the - /// setting doesn't invent one. #[test] fn diff_click_target_needs_a_repo_either_way() { let mut cfg = Config::default(); @@ -1343,8 +920,6 @@ mod tests { assert_eq!(diff_click_cwd::<PathBuf>(&cfg, None), None); } - /// Groups appear in first-appearance order with Scratch pinned last, and - /// an all-`None` key set renders as one headerless flat section. #[test] fn sections_order_groups_by_first_appearance_scratch_last() { let keys = vec![ @@ -1367,19 +942,14 @@ mod tests { ] ); - // No tab in any repo: one headerless section over everything. let flat = sidebar_sections(&[None, None]); assert_eq!(flat.len(), 1); assert_eq!(flat[0].name, None); assert_eq!(flat[0].tabs, vec![0, 1]); } - /// A row dropped inside its group lands on the far side of the row it was - /// dropped onto, leaves every other group alone, and comes out with the - /// groups laid out contiguously the way the rail draws them. #[test] fn reordered_rows_moves_within_the_group_only() { - // alpha owns 0 and 2, interleaved with beta's 1; scratch is 3. let keys = vec![ Some(p("/w/alpha")), Some(p("/w/beta")), @@ -1387,40 +957,29 @@ mod tests { None, ]; let alpha = Some(p("/w/alpha")); - // alpha's first row dragged onto its second: alpha reads [2, 0], and - // the vector comes out grouped — alpha, beta, scratch. assert_eq!( reordered_rows(&keys, &alpha, &[0, 2], 0, 1), Some(vec![2, 0, 1, 3]) ); - // Back the other way. assert_eq!( reordered_rows(&keys, &alpha, &[0, 2], 1, 0), Some(vec![2, 0, 1, 3]) ); - // Dropping a row on itself changes nothing. assert_eq!(reordered_rows(&keys, &alpha, &[0, 2], 1, 1), None); } - /// Rows the search box is hiding aren't dragged along: the visible rows - /// reorder among themselves and the hidden one keeps its place in the group. #[test] fn reordered_rows_leaves_filtered_out_rows_alone() { let keys = vec![Some(p("/w/a")), Some(p("/w/a")), Some(p("/w/a"))]; let a = Some(p("/w/a")); - // Only rows 0 and 2 are listed; dragging 0 past 2 puts it after row 2, - // and row 1 stays between… where it was relative to the others. assert_eq!( reordered_rows(&keys, &a, &[0, 2], 0, 1), Some(vec![1, 2, 0]) ); } - /// A header drag moves the whole group into the target's slot and lays - /// every group out contiguously, Scratch last, keeping intra-group order. #[test] fn regrouped_order_moves_the_group_into_the_target_slot() { - // Groups by first appearance: alpha (0, 3), beta (2), gamma (4). let keys = vec![ Some(p("/w/alpha")), None, @@ -1428,20 +987,16 @@ mod tests { Some(p("/w/alpha")), Some(p("/w/gamma")), ]; - // gamma dropped on alpha → gamma, alpha, beta, then Scratch. assert_eq!( regrouped_order(&keys, &p("/w/gamma"), &p("/w/alpha")), Some(vec![4, 0, 3, 2, 1]) ); - // alpha dropped on gamma (a move down) → beta, gamma, alpha. assert_eq!( regrouped_order(&keys, &p("/w/alpha"), &p("/w/gamma")), Some(vec![2, 4, 0, 3, 1]) ); } - /// Dropping a group on itself, or naming a root no tab lives in, is a - /// no-op rather than a re-shuffle. #[test] fn regrouped_order_ignores_self_and_unknown_roots() { let keys = vec![Some(p("/w/alpha")), Some(p("/w/beta"))]; @@ -1450,8 +1005,6 @@ mod tests { assert_eq!(regrouped_order(&keys, &p("/w/alpha"), &p("/w/gone")), None); } - /// Same-named roots grow a parent prefix until distinct; unrelated names - /// stay short even alongside the colliding pair. #[test] fn group_names_disambiguate_only_the_collisions() { let (a, b, c) = ( @@ -1463,8 +1016,6 @@ mod tests { assert_eq!(names, vec!["work/app", "fork/app", "tty7"]); } - /// A root that runs out of components keeps its longest suffix instead of - /// looping forever, and the other side still grows past it to distinctness. #[test] fn group_names_handle_suffix_roots() { let (short, long) = (p("/app"), p("/x/app")); diff --git a/src/ui/tab_strip.rs b/src/ui/tab_strip.rs index edf6b6c0..3e291133 100644 --- a/src/ui/tab_strip.rs +++ b/src/ui/tab_strip.rs @@ -1,9 +1,3 @@ -//! The tab strip rendered into the title bar: one chip per tab (context icon, -//! label, close affordance), inline rename, drag-to-reorder, and the "+" -//! new-tab button. Split out of `app.rs` as an `impl Tty7App` block (the same -//! pattern `settings` uses) so the window-shell file stays focused on tab/pane -//! orchestration rather than chrome rendering. - use gpui::{ Animation, AnimationExt as _, AnyElement, App, Axis, Bounds, Context, FontWeight, MouseButton, MouseDownEvent, Pixels, SharedString, Window, canvas, deferred, div, ease_out_quint, @@ -27,41 +21,13 @@ use crate::ui::app::{TILE_GLYPH, TILE_GLYPH_LINE, TILE_SIZE, Tab, Tty7App, tile_ use crate::ui::hints::tab_badge_label; use crate::ui::reorder::{self, Reorder, Surface}; -/// How long a slot takes to slide out of the way of a dragged tab, and the -/// gap between chips it has to travel. Short and hard-decelerating: long -/// enough to read as motion, short enough that a fast drag across the strip -/// never queues up a backlog of sliding tabs. pub(crate) const REORDER_SLIDE_MS: u64 = 140; const CHIP_GAP: f32 = 6.; -/// The bare slice of caption the horizontal strip always keeps for grabbing the -/// window by. -/// -/// Every header in tty7 has to stay draggable however much content it holds (see -/// [`crate::ui::app::window_move_gesture`]). Most of them satisfy that for free: -/// their contents are labels, which take no hit box, so the drag falls straight -/// through them. This strip is the one that cannot — a tab chip is `occlude()`d -/// on purpose, because dragging one reorders it — so the spacer between the last -/// chip and the corner chrome *is* the whole grab region, and a bare `flex_1` -/// with no floor collapses to exactly 0px once the chip row saturates the line -/// (around 7-8 tabs on a 1440px window). At that point the only draggable caption -/// left is three 6px gaps and a ~3.5px band above and below the chips, which is -/// what "the region that works seems very small" in #221 was describing. -/// -/// 80px, and the chip row's budget pays for it: chips reach their `min_w` and -/// start truncating labels a tab or two sooner, and the window is always -/// grabbable. Chrome and Safari make the same trade for the same reason. pub(crate) const GRAB_HANDLE_W: f32 = 80.; -/// How many trailing path components a deep tab label keeps, mirroring -/// ghostty's zsh integration title `%(4~|…/%3~|%~)`: a path deeper than this -/// collapses to `…/` plus its last three components; a shallower one shows in -/// full. The home directory abbreviates to `~`. const KEEP_SEGMENTS: usize = 3; -/// Abbreviate a leading `$HOME` to `~` (an integrated shell usually already -/// does this, but absolute paths from other shells won't be). Borrows when -/// there's nothing to rewrite. pub(crate) fn abbreviate_home(path: &str) -> std::borrow::Cow<'_, str> { use std::borrow::Cow; if path.starts_with('~') { @@ -84,19 +50,11 @@ pub(crate) fn abbreviate_home(path: &str) -> std::borrow::Cow<'_, str> { } } -/// Derive a short tab label from a terminal's raw title. -/// -/// Shells emit OSC titles like `user@host:~/projects/app`; we show the tail the -/// way ghostty does — the working directory abbreviated with `~`, trimmed to -/// its last few components (`…/repo/025/tty7`) once it runs deep. We strip any -/// `user@host:` prefix first; a non-path title (a running command) passes -/// through unchanged. fn short_title(raw: &str) -> String { let raw = raw.trim(); if raw.is_empty() { return String::new(); } - // Drop a leading `user@host:` if present (only when it precedes a path). let after_host = match raw.split_once(':') { Some((head, tail)) if head.contains('@') => tail, _ => raw, @@ -108,8 +66,6 @@ fn short_title(raw: &str) -> String { let abbreviated = abbreviate_home(after_host); let path: &str = abbreviated.as_ref(); - // Classify the leading marker so it can be counted toward depth (like `~` in - // zsh's `%N~`) but dropped when the path is truncated. enum Kind { Home, Absolute, @@ -135,10 +91,8 @@ fn short_title(raw: &str) -> String { .to_string(); } - // `~` counts as one component in ghostty's depth test (`%(4~|…|…)`). let depth = segments.len() + usize::from(matches!(kind, Kind::Home)); let mut label = if depth > KEEP_SEGMENTS { - // Deep path: ellipsis plus the trailing components, leading marker dropped. let tail = &segments[segments.len() - KEEP_SEGMENTS..]; format!("…/{}", tail.join("/")) } else { @@ -148,52 +102,25 @@ fn short_title(raw: &str) -> String { Kind::Relative => segments.join("/"), } }; - // Final safety clamp for an unusually long single component. if label.chars().count() > 40 { label = format!("{}…", label.chars().take(40).collect::<String>()); } label } -/// Marks a live drag as a *tab* drag: its type is what the rail's and strip's -/// drop handlers match on, and its presence is what keeps gpui redrawing while -/// the pointer moves. It deliberately carries no state and renders nothing — -/// the tab being dragged never leaves the list, so there is no card floating -/// over the window; the reorder is drawn entirely by the list itself (see -/// [`crate::ui::reorder`]). `pub(crate)` so the vertical -/// [`tab_sidebar`](crate::ui::tab_sidebar) shares the same payload. #[derive(Clone)] pub(crate) struct DragTab; impl Render for DragTab { fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement { - // gpui always paints *something* at the cursor for an active drag; - // an empty, zero-sized element is how this drag paints nothing. div() } } -/// The shared styling for every icon tile in the window's chrome — title bar, -/// rail controls, detail-panel tabs, the editor's close button. -/// -/// `ghost()` can't be used: its hover is `secondary_hover` and its selected state -/// `secondary_active`, both solid mid-greys that read far heavier than anything -/// else here. So this spells out all four states in the tab rail's language — -/// nothing at rest, a soft grey capsule on hover, a step darker when selected — -/// which is the same "inset soft-grey capsule" the sidebar rows and the popups -/// use. (Overriding just the hover from outside doesn't work: `Button` applies -/// its own `.hover()` during render, after any the caller set.) pub(crate) fn chrome_tile_variant(cx: &gpui::App) -> ButtonCustomVariant { chrome_tile_variant_for(false, cx) } -/// The tile's paint, with the glyph weight that matches its state. -/// -/// At rest the glyphs sit at `sidebar_foreground` — the same step the tab -/// rail's inactive rows use. Full `foreground` (#111 on white) was a hard, -/// near-black row of ink against the near-white bar around it: the darkest -/// treatment in the window sitting on its lightest surface. A lit toggle still -/// gets full strength, so "on" reads as both a filled capsule *and* darker ink. pub(crate) fn chrome_tile_variant_for(selected: bool, cx: &gpui::App) -> ButtonCustomVariant { ButtonCustomVariant::new(cx) .color(cx.theme().transparent) @@ -202,44 +129,16 @@ pub(crate) fn chrome_tile_variant_for(selected: bool, cx: &gpui::App) -> ButtonC } else { cx.theme().sidebar_foreground }) - // The sidebar's own selected-row fill (`Surface::selected`, ≈#DBDBDB on - // white) at full strength: every icon tile in the chrome answers the - // pointer with the exact grey the rows do. It used to be that grey at - // 55% opacity, which on a light background is a ≈#EE tint nobody can - // see — and until the fork learned to read `hover` at all, nothing was - // painted anyway. - // - // Note this is a *hover* wearing the selected rung, which is only - // legible because that rung is the quiet one; when the ladder briefly - // folded resting selection into the palette cursor's 1.70:1, hovering a - // title-bar tile flashed a `#C0C0C0` slab. .hover(cx.theme().sidebar_accent) - // Selected and pressed paint the *same* grey, not a darker step. The - // chrome has one fill and one only: with two, a lit toggle and a hovered - // menu button sat side by side in the same corner wearing different - // greys, which reads as two styles rather than two states. What says a - // tile is on is that it is filled at all — the tiles around it are bare. .active(cx.theme().sidebar_accent) } -/// What `Button::render` multiplies its own size by before handing it to the -/// icon. The number matters here because the icon size a caller sets *doesn't*: -/// `render` ends with `.with_size(icon_size)` on whatever `Icon` it was given, -/// overwriting it unconditionally. So `Icon::size(px(18.))` on a `.xsmall()` -/// button silently rendered at `Size::XSmall` — 12px — and every chrome glyph in -/// the window had been that size regardless of what its call site asked for. -/// Sizing the *button* is the only channel that reaches the glyph. pub(crate) const BUTTON_ICON_SCALE: f32 = 0.75; -/// A chrome tile at the standard size: [`TILE_SIZE`] box, [`TILE_GLYPH`] glyph. pub(crate) fn chrome_tile(button: Button, selected: bool, cx: &gpui::App) -> Button { chrome_tile_sized(button, TILE_SIZE, TILE_GLYPH, selected, cx) } -/// The same tile with its geometry named — for the line-art glyphs, which need a -/// larger nominal size to draw the same ink, and for the body-scale tiles inside -/// a panel. Callers set their own rounding; everything else is decided here so -/// no call site can drift from the rhythm again. pub(crate) fn chrome_tile_sized( button: Button, tile: f32, @@ -255,50 +154,10 @@ pub(crate) fn chrome_tile_sized( .h(px(tile)) } -/// The colour of a live workspace's corner dot. The same green -/// [`AgentStatus::Done`](crate::core::cli_agent::AgentStatus::dot_rgb) uses — -/// deliberately *not* the brand mint, which belongs to the logo and would be -/// the only saturated pixel in a chrome that has none. pub(crate) const LIVE_DOT: u32 = 0x22C55E; -/// The colour of the dot on a workspace whose machine could not be asked. -/// -/// A neutral grey from the same family as [`LIVE_DOT`], because "unknown" is a -/// status and the app has exactly one shape for status — the corner dot. Green -/// says running, absence says stopped (§ [`workspace_avatar`]), and this says -/// neither: the machine is out of reach and the sessions on it are very -/// probably fine. Deliberately *not* amber or red — nothing has gone wrong with -/// the user's work, only with our view of it. pub(crate) const UNKNOWN_DOT: u32 = 0x9AA0A6; -/// The monogram badge for a workspace, built in the same shape as a tab -/// avatar: a neutral disc carrying the first letter, with liveness riding the -/// corner as a small dot. -/// -/// The dot is the sidebar's [`status_dot`](Tty7App::status_dot), ringed in the -/// surface it sits on — one corner-dot language for every avatar in the app. -/// Drawn bare it was the same disc, but a bare disc at this diameter is all -/// colour and no edge, and it landed on the menu as the loudest thing in it; -/// the ring spends half the dot's width on separation instead. -/// -/// A stopped workspace draws *no* dot, matching `AgentStatus::Idle`: a resting -/// thing is just its mark. An "off" indicator would be a second shape invented -/// for one list, and this app already decided that absence says it. -/// -/// A workspace whose machine could not be asked draws the same dot in -/// [`UNKNOWN_DOT`] grey. It has to be visibly *not* the stopped state: pane ids -/// are per-machine, so a box that is off or unreachable can tell us nothing -/// about its sessions, and drawing that as a bare badge would say "your work is -/// gone" every time a link blinks. One shape, three readings — green, grey, -/// nothing. -/// -/// "This is the one you are looking at" is drawn by *subtraction*: the current -/// badge renders at full strength and every other one fades to the same 0.55 -/// an unfocused pane uses. A leading checkmark would be truer to menu -/// convention, but it makes the popup reserve a whole gutter that seven of -/// eight rows leave empty and every label indent past; and marking the current -/// row by *adding* something — an inverted disc, a ring — puts the heaviest -/// pixels in the menu on the one row that needs no introduction. pub(crate) fn workspace_avatar( name: &str, live: crate::terminal::pane_liveness::Liveness, @@ -325,9 +184,6 @@ pub(crate) fn workspace_avatar( div() .size(px(size)) .rounded_full() - // `secondary`, not `muted`: a menu's fill is `popover`, and those - // two differ by half a percent — the disc came out invisible, - // leaving a bare letter with a dot floating beside it. .bg(cx.theme().secondary) .flex() .items_center() @@ -336,28 +192,11 @@ pub(crate) fn workspace_avatar( .font_weight(FontWeight::MEDIUM) .text_color(cx.theme().foreground.opacity(0.65)) .child(initial) - // The same 0.55 an unfocused pane fades to, and for the same - // reason given there: a background-tinted scrim would be - // white-on-white in a light theme. - // - // The disc fades, the dot does not — element opacity multiplies - // through the whole subtree, so a faded dot takes its separator - // ring down with it and the green underneath prints straight - // through it as a halo. Liveness is status either way; it has no - // reason to say which row you're standing on. .when(!current, |disc| disc.opacity(0.55)), ) - .children(dot.map(|rgb| { - // Ringed in `popover`, not `background`: a menu row's fill is the - // popover colour, and the two differ enough that a background-ringed - // dot draws a pale halo instead of an edge. - Tty7App::status_dot(rgb, 0, size, cx.theme().popover) - })) + .children(dot.map(|rgb| Tty7App::status_dot(rgb, 0, size, cx.theme().popover))) } -/// The `SelectWorkspace{1..9}` action for a Window-menu slot, or `None` past -/// the ninth. Shared by the title-bar chip and `ui::theme`'s Window menu so -/// both index `ui::windows::menu_order` identically. pub(crate) fn select_workspace_action(index: usize) -> Option<Box<dyn gpui::Action>> { Some(match index { 0 => Box::new(SelectWorkspace1) as Box<dyn gpui::Action>, @@ -374,78 +213,27 @@ pub(crate) fn select_workspace_action(index: usize) -> Option<Box<dyn gpui::Acti } impl Tty7App { - /// Diameter of the workspace avatar. Sized to the rail's row glyphs rather - /// than to a chrome tile: this control is the head of the tab list, so it - /// reads down the column instead of across a row of buttons. pub(crate) const AVATAR_PX: f32 = 20.0; - /// The rail's head: which workspace this window is on, plus the menu that - /// owns everything workspace-scoped — and the app-level entries the "⋯" - /// used to hold. - /// - /// This exists because the rest of it was too well hidden. Switching lived - /// in the command palette, reopening lived on the home page, ending lived - /// behind a hover, and renaming had no UI at all — each individually - /// defensible, together undiscoverable. Nothing in the window even *said* - /// which workspace it was, which starts to matter the moment there are two. - /// - /// It used to be a monogram in the window's top-right corner. Two things - /// were wrong with that. Physically: the corner it sat in is the *panel's* - /// top zone while the panel is open, so a control that has nothing to do - /// with the panel was eating width the panel's own tabs needed — at - /// `right_panel::MIN_WIDTH` the row wanted 268px. Semantically: a window's - /// workspace is exactly what the rail below enumerates (this workspace's - /// tabs), so the name belonged at the head of that column, not across the - /// window from it. - /// - /// Here it can afford the full name — the rail is a column with a width of - /// its own, and it truncates rather than pushing anything off an edge. The - /// monogram stays as a leading avatar because it is the mark that identifies - /// *this* workspace at a glance across windows. - /// - /// While a rename is in flight the control becomes the text field, so the - /// name is edited where it is displayed. pub(crate) fn workspace_head(&self, cx: &mut Context<Self>) -> impl IntoElement + use<> { if let Some(rename) = self.workspace_rename.as_ref() { - // The tile itself becomes the field — same height, same radius, and - // the hover fill standing in for "this control is being edited". - // A bordered input would drop a form control into a strip that has - // none, and `Input`'s default pill fights every other shape here; - // `appearance(false)` is what the tab rename uses for the same - // reason. return h_flex() .id("workspace-rename") .flex_shrink_0() .items_center() - // Full rail width, not the old fixed 150px: this control is a row - // in a column now, so it takes the column's width like every other - // row does. .h(px(30.)) .w_full() .px(px(7.)) .rounded_md() .bg(cx.theme().sidebar_accent) - // Swallow mouse-downs (including the double-click that selects a - // word) so they never reach the enclosing TitleBar and zoom the - // window — the tab rename learned this the hard way. .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) .child(Input::new(&rename.input).appearance(false).xsmall()) .into_any_element(); } - // Keep the liveness cache warm from here rather than only from the menu - // builder. The menu is a popup: it takes its snapshot when it opens and - // never rebuilds, so an answer that starts arriving *at* open lands in a - // menu nobody is looking at any more and the first open reads `Unknown` - // for every remote row. Sweeping from the chip means the answers are - // already in by the time it is clicked. Cheap by construction — the - // sweep is rate-limited, and past that gate each machine is only asked - // once its own TTL has run out. crate::terminal::pane_liveness::sweep(cx); let current = crate::ui::machine_mirror::display_name_for(cx, self.workspace) .unwrap_or_else(|| "tty7".to_string()); - // First character, uppercased — the whole point is a glyph that is - // recognisably *this* workspace at a glance across windows. let monogram: String = current .chars() .next() @@ -455,16 +243,6 @@ impl Tty7App { div() .occlude() .w_full() - // Right-click does nothing here — and *looked* like it did. A - // `Button` focuses on any mouse-down and gpui-component draws a - // 1.5px ring on a focused one, so a right-click left the chip - // wearing a bright ring for a menu that never opened (a left click - // hides the same ring by handing focus to the switcher's search - // field, which is why only right-click showed it). - // - // Swallowed in the *capture* phase so the button never sees the - // press at all: a bubble-phase listener runs after the button has - // already taken focus. .capture_any_mouse_down(|ev: &gpui::MouseDownEvent, _window, cx| { if ev.button == MouseButton::Right { cx.stop_propagation(); @@ -491,10 +269,6 @@ impl Tty7App { .font_weight(FontWeight::SEMIBOLD) .child(monogram), ) - // The name shrinks and truncates rather than pushing - // the chevron out — same rule the group headers below - // it follow, so a long workspace name and a long repo - // name behave identically. .child( div() .flex_shrink(1.) @@ -504,9 +278,6 @@ impl Tty7App { .font_weight(FontWeight::SEMIBOLD) .child(SharedString::from(current.clone())), ) - // A chevron, unlike the tiles in the row above: those - // do one thing on click, this opens something, and the - // glyph is what says so. .child( Icon::new(IconName::ChevronDown) .size(px(11.)) @@ -526,28 +297,11 @@ impl Tty7App { .into_any_element() } - /// The corner's app-level "⋯": Command Palette and Settings. - /// - /// These two lived in the chip's dropdown, folded in there so the corner had - /// one menu instead of two adjacent ones. That trade held while the chip - /// opened a list of workspace names; it stops holding now that the chip - /// opens a whole panel with its own search and its own per-row actions. - /// Hanging "Settings…" off the bottom of *that* stitches two different - /// altitudes together — one answers "which workspace am I going to", the - /// other "how is this app configured". - /// - /// Deliberately just these two. Help/About live in the menu bar, and - /// duplicating them here only makes the menu longer without making anything - /// reachable — but on Windows and Linux there is no menu bar to fall back - /// to, which is why the pair needs a home inside the window at all. pub(crate) fn app_menu_tile( &self, window: &Window, cx: &mut Context<Self>, ) -> impl IntoElement + use<> { - // `.menu(label, Action)` dispatches the real action, so a click and its - // shortcut travel one path and the row renders the hint for free — but - // only if the menu knows which focus handle to dispatch into. let action_ctx = self .tabs .get(self.active) @@ -574,12 +328,6 @@ impl Tty7App { ) } - /// The window's right-corner chrome: the detail-panel toggle and the overflow - /// "⋯". Built here rather than inline because it has two hosts — the title - /// strip while the panel is closed, and the panel's own top zone while it's - /// open, since whichever of the two reaches the window's right edge should - /// carry it. (Same arrangement as the rail: its controls sit on the rail when - /// it's out, and move into the strip when it's collapsed.) pub(crate) fn window_chrome( &self, window: &Window, @@ -590,36 +338,10 @@ impl Tty7App { .flex_shrink_0() .items_center() .gap(px(2.)) - // Two controls, both window-scoped, and that is the whole corner now. - // The workspace chip used to lead this group; it moved to the rail's - // head (`tab_sidebar`), where the list it names actually lives. What - // forced the move is that this group's other host is the *panel's* top - // zone, and a panel the user can drag down to `right_panel::MIN_WIDTH` - // cannot seat the panel's four tabs plus three window controls — the - // row wanted 268px. - // Nothing that has no business being scoped to the panel gets to - // compete for that width. - // - // The "⋯" glyph ends on the window's content inset like every other - // right edge in the chrome — hence `inset - TILE_PAD`, which puts the - // *glyph's ink* there instead of its hit box. .pr(px(tile_trailing_inset())) - // On Windows/Linux the window controls (─ ▢ ✕) sit on the right, right - // where the "⋯" lands, so its inset has to match *their* rhythm rather - // than add breathing room: the 34px control tiles put consecutive glyph - // centres 34px apart, and the "⋯" centre sits `16 + pr + 17` from the - // minimise glyph. `pr_1` (4px) lands it at ~37px — reading as part of - // the same row, with just enough slack to not be mistaken for a fourth - // window control. `pr_3` (12px) put it at ~45px, visibly adrift from the - // group (this is where the de3896c chrome redesign reset it — see 9b1c7bf). .when(!cfg!(target_os = "macos"), |this| this.pr_1()) .child( div().occlude().flex_shrink_0().child( - // Never drawn selected, exactly like the rail's own toggle - // (`tab_sidebar`): the panel being open is already on screen — - // it *is* the panel — so a lit capsule only restates it, and - // the two panel toggles would disagree about what a chrome tile - // means. The state lives in the tooltip's verb instead. chrome_tile( Button::new("titlebar-right-panel") .icon(Icon::empty().path("icons/panel-right.svg")), @@ -640,34 +362,8 @@ impl Tty7App { .child(self.app_menu_tile(window, cx)) } - /// The detail panel's tab tiles — icon-only, one per view. Lives here beside - /// the rest of the chrome tiles so all of them share one styling helper. - /// - /// **Chrome scale**, like everything else in that row. These were briefly - /// dropped to body scale ([`TILE_SIZE_SM`]) to buy width back after the row - /// overflowed a 200px panel — the tiles live inside a panel, and one size - /// step separates them from the window chrome beside them without spending a - /// divider on it. Both arguments hold; neither survives what it looks like. - /// A 24px tile carrying an 11px glyph next to a 32px one carrying a 13px - /// glyph doesn't read as a layer below, it reads as shrunk — the panel's - /// primary navigation, drawn smaller than the two buttons in the corner. - /// - /// The width the shrink was buying is bought by [`MIN_WIDTH`] instead: the - /// row needs 214px at this scale, so the panel's floor is what has to move. - /// That is the honest place for the constraint anyway — the tiles are as big - /// as they are, and the panel is as narrow as it can afford to be. - /// - /// [`TILE_SIZE_SM`]: crate::ui::app::TILE_SIZE_SM - /// [`MIN_WIDTH`]: crate::ui::right_panel::MIN_WIDTH pub(crate) fn right_panel_tabs(&self, cx: &mut Context<Self>) -> Vec<AnyElement> { let active_tab = self.right_panel_tab; - // Changed-file count, carried in the Changes tooltip. It used to be a - // tally in that tab's header row, and that row is gone on macOS - // (`panel_title`) — but this is the one count worth keeping reachable - // *without* switching to the tab, since it answers "did I touch anything" - // from wherever you are. Outline's count didn't survive the move: it - // needs the active leaf, which needs a `&Window` this function doesn't - // take, and its list is right there the moment you switch. let changed = match &self.right_panel.diff { Some(Some(snap)) => { let n = snap.files.len() + snap.untracked_count(); @@ -686,9 +382,6 @@ impl Tty7App { Icon::empty().path("icons/list.svg"), "Outline", ), - // git-branch (from tty7's own assets) instead of the abstract - // `Replace` glyph: Changes is a working-tree diff, and the branch - // mark reads as version control at a glance — matching the mockup. ( RightPanelTab::Changes, Icon::empty().path("icons/git-branch.svg"), @@ -727,26 +420,11 @@ impl Tty7App { .collect() } - /// The status dot pinned to a tab avatar's bottom-right corner (an agent's - /// live status, or an SSH pane's connection phase): a solid - /// `rgb` disc with a surface-colored separator ring so it reads as sitting - /// on the badge. `unread` is how many panes hold a finished turn you - /// haven't looked at: when nonzero, the dot swells into a count badge — - /// the same disc grown just enough to speak its number — so read↔unread - /// stays one element opening its mouth, not a second indicator appearing. - /// `size` is the avatar edge, `ring` the colour of the surface the badge - /// sits on — the separator is drawn in it, so a dot on a popover row rings - /// in the popover's fill rather than the window background's. fn status_dot(rgb: u32, unread: usize, size: f32, ring: gpui::Hsla) -> gpui::AnyElement { let d = (size * 0.42).max(7.); let bg = ring; if unread > 0 { - // The count badge: sized to seat a digit legibly, centred on the - // read dot's centre (same corner point) so the swell reads as the - // dot growing in place rather than a new element popping up. let nd = (size * 0.72).max(13.0); - // Panes per tab are single-digit in practice; clamp so an absurd - // split can never overflow the disc. let label = unread.min(9).to_string(); div() .absolute() @@ -779,14 +457,6 @@ impl Tty7App { } } - /// The leading avatar for a tab row/chip: a rounded badge that brands the - /// tab by what's running in it — each session fronted with an icon. A - /// recognized coding agent gets its brand mark — a white silhouette - /// (gpui tints SVGs as an alpha mask) on the vendor accent; a plain shell - /// gets a neutral terminal glyph. Live status rides the corner as a - /// [`status_dot`](Self::status_dot) — the agent's working/waiting/done, or - /// an SSH pane's connection phase (`ssh`) — one corner-dot language for - /// the whole avatar column. `size` is the badge's edge in px. pub(crate) fn tab_avatar( &self, agent: Option<crate::core::cli_agent::CLIAgent>, @@ -802,18 +472,8 @@ impl Tty7App { .flex() .items_center() .justify_center(); - // A circle for every kind — the brand mark / glyph sits - // small and centred with generous padding rather than filling the badge. match agent { Some(agent) => { - // The agent's live status as a small dot pinned to the badge's - // bottom-right corner (blue working / amber waiting / green - // done), ringed in the surface color so it reads as sitting on - // the badge rather than clipped by it. Idle (or unknown) draws - // no dot — a resting agent is just its brand mark. *Unread* - // finished turns swell the dot into a count badge ("2 results - // waiting") that shrinks back to a plain dot once you view the - // panes, without ever hiding the done state. let dot = status .and_then(|s| s.dot_rgb()) .map(|rgb| Self::status_dot(rgb, unread, size, cx.theme().background)); @@ -832,20 +492,13 @@ impl Tty7App { None => base .relative() .rounded_full() - // A clearly-visible neutral disc (a neutral grey shell badge), not a - // near-transparent tint — so the avatar column reads as a column. .bg(cx.theme().muted) .child( - // A flush `>_` prompt (not the boxed `square-terminal`) so it - // fills the badge at the same visual weight as a brand mark. gpui::svg() .path("icons/terminal.svg") .size(px(size * 0.56)) .text_color(cx.theme().foreground.opacity(0.65)), ) - // SSH connection phase as a corner status dot — the same - // element as an agent's, not a border ring around the badge - // (a ring read as a second, differently-shaped avatar style). .when_some(ssh, |b, rgb| { b.child(Self::status_dot(rgb, 0, size, cx.theme().background)) }) @@ -853,11 +506,6 @@ impl Tty7App { } } - /// The display label for a tab: the user-set name if present, otherwise the - /// focused terminal's title (shortened), falling back to - /// "Session N" when there's no title yet. Pass `window` so the label tracks - /// the focused pane in a split; `None` (no window available) uses the first - /// leaf. pub(crate) fn tab_label( &self, tab: &Tab, @@ -880,13 +528,6 @@ impl Tty7App { } } - /// Attach the "new tab" shell picker to a button: the default shell leads - /// the menu (tagged `default`), followed by every shell found on **this - /// window's machine**; clicking one opens a tab on that shell. Extracted so - /// the title-bar strip's "+" and the vertical [`tab_sidebar`] share one menu - /// definition rather than duplicating the shell iteration. - /// - /// [`tab_sidebar`]: crate::ui::tab_sidebar pub(crate) fn attach_new_tab_menu( &self, button: Button, @@ -897,16 +538,10 @@ impl Tty7App { let app = cx.entity().downgrade(); button.dropdown_menu(move |menu, _window, _cx| { let mut menu = menu.min_w(px(220.)); - // One row per detected shell. There's no separate "New Tab (…)" - // entry — it only duplicated the default shell's row, and ⌘T already - // opens a default tab in one press. The configured default is tagged - // instead so the menu still says which shell a bare new tab would use. for shell in &shells { let spec = ShellSpec { program: shell.program.clone(), args: shell.args.clone(), - // Every arg on a dropdown row was written by - // `core::shells::detect_shells`, not the user. args_are_tty7_defaults: true, }; let open = app.clone(); @@ -936,8 +571,6 @@ impl Tty7App { } })); } - // Before shell detection lands (or if it finds nothing), keep a - // single default entry so the menu is never empty. if shells.is_empty() { let open_default = app.clone(); menu = menu.item( @@ -952,12 +585,6 @@ impl Tty7App { }) } - /// Build the per-tab right-click menu, shared by the strip's chips and the - /// sidebar's rows (which passes `below_wording` so the trailing close reads - /// "Close Tabs Below" in the vertical list). Live state — tab count, the - /// tab's cwd — is read at open time through the weak `app` handle, so the - /// render loop never pays a per-frame cwd syscall and the enablement can't - /// go stale between render and click. pub(crate) fn tab_context_menu( menu: PopupMenu, index: usize, @@ -975,9 +602,6 @@ impl Tty7App { let has_cwd = cwd.is_some(); let mut menu = menu.min_w(px(200.)); - // Rename — the inline label edit's only entry point (a label - // double-click zooms the window instead, like the rest of the - // titlebar). menu = menu.item(PopupMenuItem::new("Rename Tab").on_click({ let app = app.clone(); move |_, window, cx| { @@ -985,11 +609,6 @@ impl Tty7App { } })); - // Mark as Unread — re-arm the avatar's green Done badge so a result - // you want to revisit nags again. Only agent tabs get the entry, and - // only a settled (`Done`) tab has a finished turn to mark; a busier - // status (working/waiting) owns the dot anyway, so the entry disables - // rather than promising a badge that can't show yet. let tab = this.tabs.get(index); if tab.is_some_and(|t| t.agent(cx).is_some()) { let done = tab.and_then(|t| t.agent_status(cx)) @@ -1006,12 +625,6 @@ impl Tty7App { ); } - // Worktree: an isolated checkout of this tab's repo on a fresh branch, - // opened as a new tab — parallel-agent fuel. Only offered when the - // tab's cwd actually sits in a git repository; outside one the entry - // would be pure noise. Read from the git-status cache rather than - // probed here: a menu is built synchronously, and asking the pane's - // host is a blocking call (a round trip, if that host is remote). let in_repo = this.tab_is_in_repo(index, window, cx); if in_repo { menu = menu @@ -1024,25 +637,10 @@ impl Tty7App { })); } - // Fork Session — the same kind of operation as New Worktree Tab (spin a - // parallel line of work off this one), so it sits in the same block. A - // *tab*-level ask carries no placement question, so it lands in a new - // tab; the pane right-click menu is where the split directions live - // (issue #211). Offered only for agents tty7 has a verified fork command - // for; disabled — not hidden — while the session id is still unknown, so - // the capability stays discoverable when the hooks aren't installed. - // The pane comes back with the state and is captured by the row's - // closure, like `cwd` and `session_id` below: clicking the row focuses - // the popup, which lives outside every terminal's focus path, so - // resolving the source pane again at click time would fork the tab's - // *first* leaf rather than the one this row was labelled for. let agent_session = this.tab_agent_session(index, window, cx); if let Some((source, session)) = &agent_session && let Some(label) = session.fork_label { - // Open the block ourselves when the worktree row above didn't - // (this tab's cwd isn't in a repo), so the row never glues onto - // "Mark as Unread". if !in_repo { menu = menu.separator(); } @@ -1065,9 +663,6 @@ impl Tty7App { })); } - // Splits act on the right-clicked tab: activate it first (a no-op when - // it already is), then split its focused pane — one code path with the - // keyboard actions. menu = menu .separator() .item(PopupMenuItem::new("Split Right").on_click({ @@ -1101,11 +696,6 @@ impl Tty7App { }), ); - // Copy Session ID, beside Copy Working Directory — the agent's own - // native id, the one its `--resume` takes. Offered for every agent tab - // (there is nothing agent-specific about an id) and disabled until one - // has been reported. The id is read here at open time, so the row can't - // copy a stale one. if let Some(session_id) = agent_session.map(|(_, s)| s.session_id) { menu = menu.item( PopupMenuItem::new("Copy Session ID") @@ -1153,10 +743,6 @@ impl Tty7App { ) } - /// The horizontal tab strip rendered into the title bar. `show_chips` draws - /// the per-tab chip row; passing `false` (the vertical-sidebar mode, where - /// the sidebar owns the tab list) keeps only the "+" and "⋯" chrome so the - /// title bar isn't left empty. pub(crate) fn tab_strip( &self, show_chips: bool, @@ -1164,62 +750,15 @@ impl Tty7App { cx: &mut Context<Self>, ) -> impl IntoElement + use<> { let active = self.active; - // While the bare ⌘/Ctrl hold is armed (see `ui::hints`), each of the - // first nine chips swaps its close affordance for a ⌘N badge — same - // slot, so nothing shifts when the hints appear. let show_badges = self.mod_hint_badges; - // Explicit viewport-derived strip width, NOT `w_full`: the title bar sizes - // its content by intrinsic width, so `w_full` doesn't track the window and - // the strip's right edge (where the "⋯" is pinned) lags behind the - // shrinking window — the button drifts right into the corner. Deriving the - // width from the live viewport makes the right edge track the window at - // every size. macOS reserves 80px on the *left* for the traffic lights, so - // `viewport - 80` reaches the true right edge and the strip's own `pr` - // sets the "⋯" inset; other platforms put the window controls on the - // *right*, so keep the strip narrower to clear them. - // - // The non-macOS reserve must cover everything the TitleBar lays out - // *beside* the strip, or the strip overruns the bar and shoves the native - // close button off the corner: 12px of `TitleBar` left padding + the three - // 34px window-control tiles (─ ▢ ✕ = 102px) = 114px. Undershooting it (the - // old 100px) left the strip ~14px too wide, clipping the "✕". let strip_w = if cfg!(target_os = "macos") { (window.viewport_size().width - px(80.)).max(px(160.)) } else { (window.viewport_size().width - px(114.)).max(px(140.)) }; - // Off macOS the bar spans the detail panel (see `app::render`). The corner - // chrome then sits *over the panel's surface*, so it stops hugging the - // window controls and aligns with the column it is painted on: a block as - // wide as the slice of the panel the bar can reach — panel width less the - // control group, less the panel's own left border — with the tiles packed - // at its leading edge, on the same inset as everything else in the panel. let chrome_band_w = (!cfg!(target_os = "macos") && self.right_panel_open(cx)).then(|| { (self.right_panel_px(window, cx) - crate::ui::app::WINDOW_CONTROLS_W - 1.).max(0.) }); - // Everything the strip lays out *beside* the clipped chip row lives - // outside that row's budget — reserve the whole footprint here so the - // fixed chrome never overflows the strip box (which would eat the corner - // chrome's right inset and shove it into the window corner) and cap the - // chip row at the remainder. - // - // The corner is either the free-standing chrome group (trailing pad + the - // panel tile + its 2px gap + the app-menu tile: 71px on macOS, 70px - // elsewhere) or, off macOS with the panel open, the wider band the chrome - // is laid out inside; three `CHIP_GAP`s and the "+" tile sit between it and - // the chips — 121px / 120px of fixed chrome all told. - // - // The trailing pad is not `tile_trailing_inset()` on every platform: - // `window_chrome` follows it with `pr_1` off macOS, and gpui's padding - // setters *assign* rather than accumulate, so the later 4px replaces the - // 5px rather than adding to it. - // - // This used to be a flat 100px, a leftover from when the corner held a - // 30px "+" and a 30px "⋯", and it was never revisited as the group's - // contents changed under it — so the reserve and the real footprint had - // simply drifted apart, and the flexible spacer, which is the strip's only - // grab handle (see `GRAB_HANDLE_W`), was the first thing to pay for it. - // Measure the group instead of quoting a number at it. let corner_w = chrome_band_w.unwrap_or_else(|| { let trailing_pad = if cfg!(target_os = "macos") { tile_trailing_inset() @@ -1230,8 +769,6 @@ impl Tty7App { }); let fixed_w = 3. * CHIP_GAP + crate::ui::app::TILE_SIZE + corner_w; let chips_avail = (strip_w - px(fixed_w + GRAB_HANDLE_W)).max(px(80.)); - // Only the chip row clips; a crowded row shrinks its chips (down to their - // `min_w`) and truncates their labels rather than pushing the "+" away. let mut chips = h_flex() .items_center() .gap(px(CHIP_GAP)) @@ -1239,12 +776,6 @@ impl Tty7App { .max_w(chips_avail) .overflow_hidden(); - // ── Live drag-reorder ───────────────────────────────────────────────── - // While a chip is being dragged the strip renders in the order a drop - // would produce (see [`crate::ui::reorder`]): the dragged chip travels - // along the row itself — nothing floats over the window — and every - // chip it passes slides over to meet it. `slots` collects this frame's - // chip geometry: the reference a drag starting on a later frame freezes. let slots: Rc<RefCell<Vec<Bounds<Pixels>>>> = Rc::new(RefCell::new(vec![Bounds::default(); self.tabs.len()])); let preview = reorder::preview( @@ -1253,10 +784,6 @@ impl Tty7App { self.tabs.len(), window.mouse_position(), ); - // Display order: plain tab order, or the previewed one mid-drag. In the - // strip a slot *is* a tab index, so the previewed order doubles as the - // tab permutation a release would commit — recorded every frame so - // letting go applies exactly what's on screen (see `reorder`). let display: Vec<usize> = match &preview { Some(p) => { reorder::set_pending(&self.reorder, &Surface::Strip, p.order.clone()); @@ -1266,42 +793,28 @@ impl Tty7App { }; for i in display { - // In sidebar mode the vertical rail carries the tab list; the strip - // keeps only its "+"/"⋯" chrome, so skip the chip row entirely. if !show_chips { break; } - // The chip you're holding: still a chip in the row, just dimmed so - // it reads as picked up while it slides between slots. let dragged = preview.as_ref().is_some_and(|p| p.from == i); let tab = &self.tabs[i]; let is_active = i == active; let label = self.tab_label(tab, i, Some(window), cx); - // SSH status dot (PRD FR-E2): coloured by the pane's connection phase. let ssh_dot = self.tab_ssh_dot(tab, cx); - // A coding agent running in this tab (Claude Code, Codex, …) fronts - // its chip with the vendor brand mark so it's recognizable at a - // glance across a crowded strip. let agent = tab.agent(cx); let agent_status = tab.agent_status(cx); let agent_unread = tab.agent_unread_count(cx); - // Inline rename input for this tab, if it's the one being renamed. let rename_input = self .renaming .as_ref() .filter(|r| r.index == i) .map(|r| r.input.clone()); - // Either the editable input (while renaming) or the clickable, - // draggable label. let label_region = match rename_input { Some(input) => div() .id(("tab-rename", i)) .flex_1() .min_w_0() - // Swallow mouse-downs (incl. double-click word-select inside - // the input) so they never reach the enclosing TitleBar and - // zoom/maximize the window. .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) .child(Input::new(&input).appearance(false)) .into_any_element(), @@ -1309,30 +822,15 @@ impl Tty7App { .id(("tab-label", i)) .flex_1() .min_w_0() - // Ellipsis-truncate the label so a shrunken chip degrades - // gracefully instead of hard-clipping mid-glyph. .truncate() .text_sm() - // Active tab carries a hair more weight so hierarchy reads - // from the type, not from colour alone. .when(is_active, |d| d.font_weight(FontWeight::MEDIUM)) .child(label) - // No mouse handler of its own: click and drag both live on - // the chip, and a child that swallowed the press would take - // the label — most of the chip — out of both. .into_any_element(), }; let chip = h_flex() .id(("tab-chip", i)) - // Drag anywhere on the chip to reorder it. On the chip, not on - // its label: the drag's frame of reference is where the *chip* - // was grabbed, which is what the frozen geometry below - // measures — hang it off the label and the held chip rides - // offset from the cursor, skewing every crossing by that much. - // The builder runs once, when gpui promotes the press into a - // drag, freezing the strip's geometry as of the last painted - // frame. .on_drag(DragTab, { let state = self.reorder.clone(); let slots = slots.clone(); @@ -1349,45 +847,18 @@ impl Tty7App { cx.new(|_| DragTab) } }) - // The strip lives inside gpui-component's `TitleBar`, which marks - // its whole area as `WindowControlArea::Drag`. On Windows that maps - // to `HTCAPTION`, so unless an element on top registers a - // mouse-blocking hitbox, the OS swallows clicks as window-drags and - // our `on_mouse_down` never fires. `occlude()` makes the chip a - // `BlockMouse` hitbox so hit-testing stops here (its label/close - // children paint above it, so they still click through). No-op on - // macOS, where titlebar dragging doesn't gate child hit-testing. .occlude() - // A group so this chip's close affordance can reveal on hover - // (progressive disclosure) without affecting sibling tabs. .group(SharedString::from(format!("tab-chip-{i}"))) - // Same as the sidebar rows: a chip is a switch target first, so - // hover says "click me" and the drag swaps in the closed hand - // (see `Tty7App::render`). .cursor_pointer() .items_center() .justify_between() .gap_1p5() .h(px(30.)) - // Content-adaptive width with a readable floor. The three inputs - // that should decide a chip's width all flow through flexbox: its - // own label length (the flex basis is the content), the other - // tabs' lengths, and the window. A short label ("~") sits at the - // floor; a longer one grows to fit — with no pixel cap on top, so - // a wide window with few tabs shows labels in full (the only upper - // bound is `short_title`'s 40-char clamp). When the row overflows, - // `flex_shrink` trims every chip in proportion to its basis (the - // longest give up the most) down to `min_w`, which is the shrink - // floor too — kept modest so a crowded strip stays readable rather - // than collapsing to slivers, and so plenty of tabs fit first. .min_w(px(100.)) .flex_shrink(1.) .pl_3() .pr_1p5() .rounded_lg() - // Active tab: a soft lifted fill, no border — reads native - // (Safari/Arc) rather than as a hard-edged box. Inactive: quiet - // muted text with a barely-there fill on hover for feedback. .when(is_active, |s| { s.bg(cx.theme().secondary).text_color(cx.theme().foreground) }) @@ -1395,11 +866,7 @@ impl Tty7App { s.text_color(cx.theme().muted_foreground) .hover(|s| s.bg(cx.theme().muted)) }) - // Held: a light dimming so the chip under your cursor reads as - // picked up. Not a lift — it stays in the row's own plane. .when(dragged, |s| s.opacity(0.75)) - // Measures this chip into the frame's slot table. Absolute and - // empty, so it costs the layout nothing. .child( canvas( { @@ -1412,27 +879,9 @@ impl Tty7App { }, |_, _, _, _| {}, ) - // `inset_0`, not `size_full`: an absolutely-positioned - // child with no insets is laid out at its parent's - // *content* box, so a measuring canvas inside a padded - // element would report an origin shifted right by the - // left padding — and the held chip would ride that far - // off the cursor. Pinning all four insets to 0 anchors it - // to the padding box, which is the chip itself. .absolute() .inset_0(), ) - // A click anywhere on the chip activates the tab; a double click - // zooms the window, as the rest of the title bar does. Both live - // here rather than on the label so the whole chip — label, icon, - // padding — is one switch target and one drag handle. (The close - // button and the rename input stop the press for their own use.) - // - // The event is swallowed: on Windows the chip's `occlude()` means - // it would never reach the TitleBar anyway, so the zoom is - // forwarded explicitly. Caveat: gpui only implements - // `titlebar_double_click` on macOS; elsewhere it's a no-op until - // upstream adds support. .on_mouse_down( MouseButton::Left, cx.listener(move |this, ev: &MouseDownEvent, window, cx| { @@ -1444,7 +893,6 @@ impl Tty7App { } }), ) - // Leading SSH status dot when this tab hosts an SSH session. .when_some(ssh_dot, |c, rgb| { c.child( div() @@ -1454,10 +902,6 @@ impl Tty7App { .bg(gpui::rgb(rgb)), ) }) - // Leading agent brand avatar, when a coding agent runs in this - // tab — the vendor mark on its accent. Only agents get an avatar - // here: ordinary shells stay text-only so the strip reads as - // tabs, not icon-per-chip busy. .when_some(agent, |chip, agent| { chip.child(self.tab_avatar( Some(agent), @@ -1468,18 +912,8 @@ impl Tty7App { cx, )) }) - // Clickable / editable label region. .child(label_region) - // Trailing ⌘N badge: while the shortcut hints are armed the - // badge takes an in-flow 20px slot (the strip reflows once as - // the hints arm/disarm — a deliberate, all-chips-at-once modal - // moment). It can't float like the close button below: badges - // also show on unhovered inactive chips, which are transparent - // over the window background (possibly a gradient or image), so - // there's no solid colour to back an overlay with. .when(show_badges && i < 9, |chip| { - // Bare digit, no keycap box — the hint blends into the chip - // rather than reading as another button. chip.child( div() .flex_shrink_0() @@ -1497,15 +931,6 @@ impl Tty7App { .child(tab_badge_label(i)), ) }) - // Close affordance: out of flow, so the label runs the full - // chip width instead of always reserving a 20px slot for a - // button that's invisible until hover. On hover the ✕ floats - // over the label's right edge (Safari-style) on a solid backing - // in the chip's current fill — `secondary` on the active chip, - // `muted` on an inactive one, whose hover fill is exactly what - // the ✕'s visibility implies — with a short gradient run-in so - // covered text fades out instead of hard-cutting mid-glyph. - // Nothing reflows on hover. .when(!(show_badges && i < 9), |chip| { let backing = if is_active { cx.theme().secondary @@ -1542,23 +967,12 @@ impl Tty7App { ) }); - // Per-tab right-click menu (rename / worktree / split / copy cwd / - // close group) — the same builder the sidebar rows use. let menu_app = cx.entity().downgrade(); let chip = chip.context_menu(move |menu, window, cx| { Self::tab_context_menu(menu, i, false, &menu_app, window, cx) }); chips = chips.child(match &preview { - // The chip in hand: drawn wherever the cursor is holding it, - // pixel for pixel, with no animation in the way. `deferred` - // keeps its slot in the layout but paints it after its - // siblings, so it passes *over* the chips it's crossing - // instead of being clipped behind them. Some(p) if p.from == i => deferred(chip.relative().left(p.held)).into_any_element(), - // A chip the drag just crossed starts the frame where it used - // to be and eases to its new slot. `offset` is zero for every - // chip the last crossing left alone, so this is one moving - // chip at a time, not the whole row re-animating every frame. Some(p) => { let offset = p.offsets[i].as_f32(); chip.with_animation( @@ -1576,62 +990,33 @@ impl Tty7App { }); } - // "+" 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 tile - // rhythm (`TILE_SIZE` box, soft corners) rather than the hand-rolled - // tile the "+" used to be. `TILE_GLYPH_LINE`, not `TILE_GLYPH`: lucide's - // "+" draws inside a smaller share of its viewBox than the framed marks - // beside it, and would otherwise read a fifth small. - let add_button = - // 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( - self.attach_new_tab_menu( - chrome_tile_sized( - Button::new("tab-add").icon(Icon::new(IconName::Plus)), - TILE_SIZE, - TILE_GLYPH_LINE, - false, - cx, - ) - .rounded_lg(), + let add_button = div().occlude().flex_shrink_0().child( + self.attach_new_tab_menu( + chrome_tile_sized( + Button::new("tab-add").icon(Icon::new(IconName::Plus)), + TILE_SIZE, + TILE_GLYPH_LINE, + false, cx, - ), - ); + ) + .rounded_lg(), + cx, + ), + ); - // Sidebar mode with the rail collapsed: the rail's own controls move here - // rather than vanishing with it, so collapsing is never a one-way door. - // They keep the rail's order and spacing and just re-anchor from the rail's - // right edge to the window's left one, landing beside the traffic lights. let rail_collapsed = !show_chips && !self.left_panel_open(cx); let left_group = rail_collapsed.then(|| { h_flex() .flex_shrink_0() .items_center() .gap(px(2.)) - // Negative off macOS only: the bar already inset us past the window - // controls, and there the reserve *is* the clearance. .ml(px(crate::ui::app::title_bar_hug_offset())) - // The brand mark follows the rail's controls into the strip, so - // collapsing the sidebar doesn't strip the window's leading corner - // back to nothing (see `app::window_mark`). The group is anchored by - // its tiles' *hit boxes*, which start `tile_trailing_inset()` from - // the window edge; the mark has no box, so it adds the difference - // back to land its own ink on `CONTENT_INSET` like the rail's did. .when_some(crate::ui::app::window_mark(), |group, mark| { group.child( div() .flex_shrink_0() .pl(px(crate::ui::app::CONTENT_INSET - crate::ui::app::tile_trailing_inset())) - // The mark is solid where the tiles are line work, so it - // needs more air than the 2px that separates two tiles - // before the "+" beside it stops reading as part of it. .pr(px(4.)) .child(mark), ) @@ -1668,53 +1053,22 @@ impl Tty7App { }); let panel_open = self.right_panel_open(cx); - // The window's right-corner chrome. On macOS, when the panel is open it - // lives on the *panel's* top zone (the panel is what reaches the window's - // right edge then) exactly like the rail's controls live on the rail; the - // strip only carries it while the panel is closed. Off macOS the bar spans - // the panel (the window controls are at its right end — see `app::render`), - // so the strip always reaches the right edge and always carries the chrome. let right_chrome = (!panel_open || !cfg!(target_os = "macos")).then(|| self.window_chrome(window, cx)); - // Outer strip: the clipping chip row and the always-visible "+" anchored - // left, the overflow "⋯" pushed to the right edge by a flexible spacer. - // Only `chips` is width-capped and `overflow_hidden`, so neither button is - // pushed off-screen no matter how many tabs are open. h_flex() .id("tab-strip") .items_center() .gap_1p5() - // Chip mode: viewport-derived width (see `strip_w`) so the right edge — - // and the "⋯" pinned to it — tracks the window instead of drifting. - // Sidebar mode: the strip lives in the narrower right column beside the - // rail, so it just fills that column (`w_full`) and the "⋯" pins to its - // right; the viewport width would overrun the column and push it off. .when(show_chips, |this| this.w(strip_w)) .when(!show_chips, |this| this.w_full()) - // Padding, not margin: taffy is border-box, so a horizontal *margin* - // would push the strip past its box and clip the "⋯"; padding stays - // inside the width. `pr_2` (8px) sets the "⋯"'s gap from the right edge - // — the original tight inset, which now holds steady on resize since - // `strip_w` keeps the right edge tracking the window. .pl_0() .min_w_0() .when_some(left_group, |this, g| this.child(g)) .child(chips) - // In sidebar mode the rail owns "New Tab" (a "+" in its own top bar), - // so the title bar drops its "+" to avoid a redundant second one — - // leaving just the "⋯" overflow menu on a thin strip. .when(show_chips, move |this| this.child(add_button)) - // The strip's grab handle. `min_w` is the whole point: `flex_1` alone - // has `flex-basis: 0` and only ever takes leftover space, so it went to - // 0px the moment the chips saturated the line and left nowhere to grab - // the window by (#221). A flex item never shrinks below its `min_w`, so - // the clipping chip row beside it absorbs the pressure instead. .child(div().flex_1().min_w(px(GRAB_HANDLE_W))) .when_some(right_chrome, |this, chrome| match chrome_band_w { - // Over the panel: left-aligned on the panel's content edge, and - // wide enough that its own right edge lands where the window - // controls start. Some(w) => this.child( h_flex() .flex_none() @@ -1723,8 +1077,6 @@ impl Tty7App { .pl(px(tile_trailing_inset())) .child(chrome), ), - // Over the terminal: pinned to the strip's trailing edge, which is - // the window's right edge (or the controls' left edge off macOS). None => this.child(chrome), }) } @@ -1736,7 +1088,6 @@ mod tests { #[test] fn short_title_strips_user_host_and_shows_shallow_path_in_full() { - // Up to KEEP_SEGMENTS deep (home `~` counts as one) shows in full. assert_eq!(short_title("user@host:~/projects/app"), "~/projects/app"); assert_eq!(short_title("/usr/local/bin"), "/usr/local/bin"); assert_eq!(short_title("plain"), "plain"); @@ -1744,7 +1095,6 @@ mod tests { #[test] fn short_title_truncates_deep_paths_to_trailing_segments() { - // Deeper than KEEP_SEGMENTS collapses to `…/` plus the last three. assert_eq!(short_title("user@host:~/repo/025/tty7"), "…/repo/025/tty7"); assert_eq!(short_title("/usr/local/share/man"), "…/local/share/man"); assert_eq!(short_title("a/b/c/d"), "…/b/c/d"); @@ -1754,7 +1104,6 @@ mod tests { fn short_title_keeps_home_tilde_and_normalizes_trailing_slash() { assert_eq!(short_title("user@host:~"), "~"); assert_eq!(short_title("~"), "~"); - // Trailing slash is dropped; the path is shown, not just its basename. assert_eq!(short_title("a/b/c/"), "a/b/c"); } @@ -1763,7 +1112,6 @@ mod tests { assert_eq!(short_title(" "), ""); let long = "a".repeat(50); let out = short_title(&long); - // Clamp is 40 chars plus a single ellipsis. assert_eq!(out.chars().count(), 41); assert!(out.ends_with('…')); } diff --git a/src/ui/theme.rs b/src/ui/theme.rs index 94514586..a3923333 100644 --- a/src/ui/theme.rs +++ b/src/ui/theme.rs @@ -1,8 +1,3 @@ -//! The menu bar and theme application — the window "chrome" that sits outside -//! the tab/pane shell. `set_menus` (re)builds the macOS menu; `apply_theme` -//! paints gpui-component's `Theme` from the active color theme (see -//! `ui::presets`) and publishes the terminal-facing palette. - use gpui::{ App, Background, Hsla, Menu, MenuItem, OsAction, Pixels, Point, SystemMenuType, Window, WindowBackgroundAppearance, linear_color_stop, linear_gradient, point, px, rgb, @@ -19,33 +14,10 @@ use crate::terminal::view::{ use crate::ui::presets; use crate::ui::presets::Fill; -/// The traffic-light origin, nudged down from the macOS default so the buttons -/// stay vertically centred in our taller (40px) title bar. Shared between the -/// window's initial `TitlebarOptions` (see `main.rs`) and `apply_theme`, which -/// re-pins it after each theme change — macOS resets the buttons to their -/// default (higher) position when the app appearance changes, and gpui only -/// repositions them on the next resize/activation, so they'd briefly sit too -/// high until then. pub(crate) fn traffic_light_position() -> Point<Pixels> { point(px(9.), px(13.)) } -/// (Re)build the macOS menu bar. -/// -/// Menu order and contents follow the macOS HIG's standard set — App, File, -/// Edit, View, Window, Help — because that is where a Mac user's hand goes -/// before they read a single label. The app used to ship four menus in the -/// order App / Shell / Window / View with no Edit at all, which put Copy and -/// Paste nowhere but a right-click and made the whole bar read as improvised. -/// -/// Two deliberate departures from a stock bar: -/// -/// * There is no "Shell" menu. Its contents (new/close/split/rename) are File's -/// job everywhere else, and the name collided with Settings → Shell, which -/// configures something entirely different — the program a pane launches. -/// * "Restart Daemon…" lives at the bottom of Help, not near Settings. It is a -/// break-glass repair, it ends every running shell, and it has no business -/// one slot away from ⌘,. pub(crate) fn set_menus(cx: &mut App) { cx.set_menus([ Menu::new("tty7").items([ @@ -81,23 +53,10 @@ pub(crate) fn set_menus(cx: &mut App) { MenuItem::action("Reopen Closed Tab", ReopenClosedTab), MenuItem::separator(), MenuItem::action("Rename Workspace…", RenameWorkspace), - // Separated: the only item above the rule that touches running - // sessions is none of them — closing a window or a tab leaves the - // shells alive in the daemon. Stop ends them but keeps the layout. MenuItem::action("Stop Workspace…", StopWorkspace), - // Alone at the very bottom, behind its own rule: the one - // irreversible item in the entire menu bar. It used to sit directly - // under Stop, distinguishable only by the verb. MenuItem::separator(), MenuItem::action("Delete Workspace…", DeleteWorkspace), ]), - // `os_action` routes these through the standard Cut/Copy/Paste/Select All - // selectors, so they behave like every other Mac app's Edit menu (and stay - // enabled via the app delegate) while still dispatching our own actions. - // They carry no key-equivalent glyph: the chords are handled inline in - // `terminal::view::handle_cmd_shortcut` rather than as registered - // bindings, because ⌃C has to fall through to SIGINT when nothing is - // selected — a registered binding would swallow it. Menu::new("Edit").items([ MenuItem::os_action("Undo", UndoEdit, OsAction::Undo), MenuItem::os_action("Redo", RedoEdit, OsAction::Redo), @@ -118,8 +77,6 @@ pub(crate) fn set_menus(cx: &mut App) { MenuItem::action("Decrease Font Size", DecreaseFontSize), MenuItem::action("Reset Font Size", ResetFontSize), MenuItem::separator(), - // The three docks and the tab rail's placement — the most literally - // "view" things in the app, and until now reachable only by chord. MenuItem::action("Left Sidebar", ToggleLeftPanel), MenuItem::action("Right Panel", ToggleRightPanel), MenuItem::action("Code Panel", ToggleCodePanel), @@ -141,25 +98,11 @@ pub(crate) fn set_menus(cx: &mut App) { MenuItem::action("Join the Discord", OpenDiscord), MenuItem::action("Report an Issue…", ReportIssue), MenuItem::separator(), - // Force a fresh background daemon (so a newly granted macOS permission - // such as Full Disk Access takes effect). The trailing "…" signals the - // confirmation prompt; it ends every running session. MenuItem::action("Restart Daemon…", RestartDaemon), ]), ]); } -/// The Window menu's contents: every workspace tty7 knows about, on screen or -/// not. -/// -/// This is what makes ⌘W honest. Closing a window only *detaches* its -/// workspace — the shells keep running in the daemon — but a detached -/// workspace the user can't see may as well have been deleted. The Window menu -/// is where a Mac user already looks for "what do I have open", so putting the -/// detached ones right below the open ones costs no learning at all. -/// -/// Slot order comes from [`crate::ui::windows::menu_order`], shared with the -/// `SelectWorkspace1..9` handlers so slot *n* means the same thing in both. fn window_menu_items(cx: &App) -> Vec<MenuItem> { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -168,13 +111,8 @@ fn window_menu_items(cx: &App) -> Vec<MenuItem> { let order = crate::ui::windows::menu_order(cx); let store = crate::core::session::WorkspaceStore::all(cx); - // The same slot→action mapping the title-bar chip's menu uses, so slot *n* - // dispatches identically wherever it was clicked. let slot_action = crate::ui::tab_strip::select_workspace_action; - // Minimize / Zoom first: every Mac app's Window menu opens with them, and a - // menu that jumps straight into a bespoke list reads as if the standard ones - // were forgotten. The workspace roster follows behind a rule. let mut items = vec![ MenuItem::action("Minimize", MinimizeWindow), MenuItem::action("Zoom", ZoomWindow), @@ -187,27 +125,17 @@ fn window_menu_items(cx: &App) -> Vec<MenuItem> { continue; }; let Some(action) = slot_action(i) else { break }; - // One rule between the two groups: what's on screen, then what's put - // away. Only drawn once, and never as a leading rule. if !open && !separated { separated = true; - // Compared against the roster's own start, not the whole menu: with - // Minimize/Zoom above, `items` is never empty and the old check - // would have drawn a second rule directly under the first. if items.len() > workspace_start { items.push(MenuItem::Separator); } } - // From the machine's mirror — the tree owns the layout the name is - // derived from. Before the first pull lands the entry reads as the - // shared fallback; the menu is rebuilt on every roster change anyway. let name = crate::ui::machine_mirror::display_name(cx, workspace) .unwrap_or_else(|| "Untitled".to_string()); let label = if *open { name } else { - // The age is the useful discriminator among detached ones — several - // may share a repo name. format!( "{} — {}", name, @@ -223,17 +151,11 @@ fn window_menu_items(cx: &App) -> Vec<MenuItem> { }); } if items.len() == workspace_start { - // Never leave the roster empty — a Window menu that lists no windows - // reads as broken. The one workspace that must exist is the current one. items.push(MenuItem::action("New Workspace", NewWorkspace)); } items } -/// The actual window-background paint for the active theme: a flat color or a -/// real two-stop linear gradient (vertical = CSS `to bottom`, horizontal = -/// `to right`), with the theme's window opacity carried in the stops' alpha so -/// a translucent theme shows through gradients exactly like solids. pub(crate) fn window_background(bg: &presets::ActiveBackground) -> Background { let alpha = bg.opacity.unwrap_or(1.0); let stop = |c: u32| -> Hsla { @@ -256,22 +178,6 @@ pub(crate) fn window_background(bg: &presets::ActiveBackground) -> Background { } } -/// The OS light/dark appearance, cached as a global rather than asked of the -/// platform on demand. -/// -/// gpui's Linux backends dispatch a window's appearance-changed callback while -/// the platform client's `RefCell` is *already* mutably borrowed: both the -/// Wayland and X11 XDP handlers hold `client.borrow_mut()` across -/// `set_appearance`, which invokes the callback synchronously. `window_appearance` -/// re-borrows that same cell, so calling it from inside an appearance observer -/// panics with "RefCell already borrowed". The portal source emits one appearance -/// event during startup, which made `theme_follow_system: true` — the only path -/// that reads the OS appearance from that observer — panic on *every* launch. -/// -/// `Window::appearance()` reads the window's own cell instead, and that borrow is -/// released before the callback runs. So the observer caches what the window -/// reports and every other caller reads the cache. (Zed keeps a `SystemAppearance` -/// global for the same reason.) #[derive(Clone, Copy)] pub(crate) struct SystemAppearance { dark: bool, @@ -286,35 +192,21 @@ fn is_dark(appearance: gpui::WindowAppearance) -> bool { ) } -/// Seed [`SystemAppearance`] from the platform. Only safe *off* the -/// appearance-observer path (see the type's note): at startup, and on macOS right -/// after the native pin is released. pub(crate) fn refresh_system_appearance(cx: &mut App) { let dark = is_dark(cx.window_appearance()); cx.set_global(SystemAppearance { dark }); } -/// Cache the appearance a window just reported. This is the observer-safe update -/// — the one that runs on an actual OS light/dark flip. pub(crate) fn note_system_appearance(window: &Window, cx: &mut App) { let dark = is_dark(window.appearance()); cx.set_global(SystemAppearance { dark }); } -/// Whether the OS is currently in dark mode, per the cached [`SystemAppearance`]. -/// Only meaningful while the native appearance isn't pinned (see -/// [`sync_native_appearance`]) — a pinned appearance reports the pin, not the -/// OS setting, which is why callers gate on `Config::theme_follow_system`. pub(crate) fn system_dark(cx: &App) -> bool { - // Unset only before startup seeds it, i.e. before any theme resolves; light - // is the same default the config ships. cx.try_global::<SystemAppearance>() .is_some_and(|appearance| appearance.dark) } -/// The id of the theme that should be on screen right now: the light/dark -/// slot matching the OS appearance while `Config::theme_follow_system` is on, -/// otherwise the manual `Config::theme_preset`. pub(crate) fn effective_preset_id(cx: &App) -> String { let config = cx.global::<Config>(); if !config.theme_follow_system { @@ -326,9 +218,6 @@ pub(crate) fn effective_preset_id(cx: &App) -> String { } } -/// The background appearance the window should be *created* with: Blurred when -/// the effective theme wants blur, otherwise Transparent — never Opaque, so the -/// opacity slider works live (see the comment in [`apply_theme`]). pub(crate) fn background_appearance(cx: &App) -> WindowBackgroundAppearance { let config = cx.global::<Config>(); let theme = presets::by_id(cx, &effective_preset_id(cx)); @@ -339,25 +228,10 @@ pub(crate) fn background_appearance(cx: &App) -> WindowBackgroundAppearance { } } -/// Paint gpui-component's `Theme` from the active color theme (resolved by -/// [`effective_preset_id`]). The theme's inferred `dark` brightness picks the -/// component `ThemeMode`; every shell surface is then derived from the theme's -/// background/foreground (see `Theme::neutrals`). Also publishes the -/// terminal-facing palette as the `ActivePalette` global so the renderer matches, -/// and applies the theme's window opacity/blur. pub(crate) fn apply_theme(mut window: Option<&mut Window>, cx: &mut App) { let follow = cx.global::<Config>().theme_follow_system; - // While following the OS the native pin must be released *before* the - // theme is resolved: `effective_preset_id` reads the system appearance - // through `effectiveAppearance`, which keeps reporting the pinned value - // until the pin is cleared. if follow { sync_native_appearance(None); - // The cache may still hold the pin that call just released (the observer - // caches whatever the window reports, pin included), so re-read it from - // the platform. macOS-only on both counts: nothing pins the appearance - // elsewhere, and this is exactly the platform read that would re-enter - // gpui's borrowed Linux client — see [`SystemAppearance`]. #[cfg(target_os = "macos")] refresh_system_appearance(cx); } @@ -368,15 +242,8 @@ pub(crate) fn apply_theme(mut window: Option<&mut Window>, cx: &mut App) { } else { ThemeMode::Light }; - // Window opacity / blur: the global config override wins when set (so a - // chosen translucency survives theme switches); otherwise the theme's own - // values apply. Only an opacity below 1.0 makes the window translucent. let opacity = config.window_opacity.or(theme.opacity).filter(|o| *o < 1.0); let blur = config.window_blur.unwrap_or(theme.blur); - // Force the native macOS chrome (traffic lights, system menus, scrollbars) - // into the theme's own light/dark mode regardless of the OS setting — - // only while *not* following the OS, where the chrome should track the - // system (and pinning would blind `system_dark` to OS flips). if !follow { sync_native_appearance(Some(theme.dark)); } @@ -384,20 +251,8 @@ pub(crate) fn apply_theme(mut window: Option<&mut Window>, cx: &mut App) { let surfaces = theme.surfaces(); let sem = theme.semantics(); let active = theme.active_palette(); - // Read before `Theme::global_mut` borrows `cx`. macOS reports the overlay / - // legacy scroller preference here; Windows reports the accessibility - // "always show scrollbars" setting (false by default) and Linux always - // false — which is exactly the platform split we want below. let auto_hide_scrollbars = cx.should_auto_hide_scrollbars(); - // Never `Opaque`: on macOS 26 (Tahoe) flipping a window's opacity after - // creation doesn't reach the compositor — the window keeps compositing - // against black (verified empirically; the framebuffer alpha was correct - // but a red window behind never bled through). So the window is created - // non-opaque (see `background_appearance`, used by `main.rs`) and stays - // that way; a fully opaque theme simply paints alpha-1.0 content, which is - // visually identical. Only the Transparent↔Blurred flip happens here at - // runtime (it adds/removes an NSVisualEffectView, which does work live). if let Some(window) = window.as_deref_mut() { let bg_appearance = if blur { WindowBackgroundAppearance::Blurred @@ -408,70 +263,30 @@ pub(crate) fn apply_theme(mut window: Option<&mut Window>, cx: &mut App) { } Theme::change(mode, window.as_deref_mut(), cx); - // Publish the terminal palette before borrowing the theme mutably. cx.set_global(active); - // Publish the render-facing background (fill/opacity/image) for the root - // view, which paints gradients and the background image for real — - // gpui-component's `Theme.background` below only carries the representative - // solid. cx.set_global(presets::ActiveBackground { fill: theme.background.clone(), opacity, image: theme.image.clone(), }); - // Publish the interaction-state ladders. Every hand-rolled control in the - // shell reads its resting/hover/selected fills and label colors from here - // rather than picking a `Theme` colour field that looks about right — which - // is how the same state ended up wearing four different greys (and one - // invisible one) across the app. See `presets::Surface`. cx.set_global(surfaces.clone()); cx.set_global(presets::ActiveAccent(m.accent)); let t = Theme::global_mut(cx); - // The window base carries the theme's opacity so a translucent/blurred theme - // actually shows through; opaque themes (opacity None) stay fully opaque. let mut base: Hsla = rgb(m.background).into(); if let Some(o) = opacity { base.a = o; } - t.background = base; // terminal / window base - t.foreground = rgb(m.foreground).into(); // default text + t.background = base; + t.foreground = rgb(m.foreground).into(); t.border = rgb(m.border).into(); - t.secondary = rgb(m.secondary).into(); // hover chips (+ / tab) + t.secondary = rgb(m.secondary).into(); t.muted = rgb(m.muted).into(); - t.muted_foreground = rgb(m.muted_foreground).into(); // inactive tab text - t.popover = rgb(m.popover).into(); // elevated surfaces - // gpui-component paints popovers/menus (context menu, dropdowns) from - // `tokens.popover` / `tokens.popover_foreground`, NOT the `popover*` fields — - // so the menu background ignored our theme and fell back to the stock surface - // (looking off-theme). Mirror the theme onto the tokens, same gotcha as the - // sidebar below. + t.muted_foreground = rgb(m.muted_foreground).into(); + t.popover = rgb(m.popover).into(); t.tokens.popover = Hsla::from(rgb(m.popover)).into(); t.tokens.popover_foreground = Hsla::from(rgb(m.foreground)).into(); - // Context menus and dropdowns highlight the hovered/selected row from - // `tokens.accent` (fill) + `accent_foreground` (text) — see gpui-component's - // `MenuItemElement`. Left unset, that highlight falls back to the stock - // saturated accent, which snaps hard against this app's soft palette (the - // "生硬" hover). Point it at the popover ladder's selected rung so context - // menu, dropdown and palette share one hover language; keep the text at - // `foreground` so it stays legible on the low-contrast fill instead of the - // stock inverted accent text. The plain `accent`/`accent_foreground` fields - // feed the same highlight in the input completion / code-action popovers, so - // mirror both the fields and the tokens to keep every menu surface in step. - // - // NOTE the surface: menu rows paint on `popover`, not on the window - // background. This used to read the window ladder, which is why the menu - // highlight measured as little as 1.20:1 against the panel it actually sat on - // while nominally being the same fill that reads fine on the terminal ground. - // - // This does *not* mean "accent" — the field is gpui-component's name for a - // row highlight, and pointing the theme's real accent at it is what would - // give the saturated snap. `surfaces.popover.cursor` says what it is. - // - // The `cursor` rung, not `selected`: a menu row is lit for as long as the - // pointer is on it and nothing else on that surface is competing, which is - // exactly the case the loud rung exists for (see `presets::state`). let accent_fill = rgb(surfaces.popover.cursor); let accent_text: Hsla = rgb(m.foreground).into(); t.accent = accent_fill.into(); @@ -479,15 +294,6 @@ pub(crate) fn apply_theme(mut window: Option<&mut Window>, cx: &mut App) { t.tokens.accent = Hsla::from(accent_fill).into(); t.tokens.accent_foreground = accent_text.into(); - // Primary buttons (Connect, Reconnect, Trust, Save…) fill from - // `tokens.button_primary`; the raw default is the foreground — a pure - // near-black in a light theme, which reads harsh. Nudge it toward the - // background so it lands on a softer dark charcoal (and, symmetrically, a - // slightly dimmed near-white in dark themes). We set the whole primary token - // family (both the plain `primary*` fields — used for the border and outline - // text — and the `button_primary*` tokens the fill actually reads) so every - // primary button shifts together, hover/pressed included. The stock - // `button_primary_foreground` stays legible on top either way. let primary_base: Hsla = rgb(presets::mix(m.foreground, m.background, 0.20)).into(); let primary_hover: Hsla = rgb(presets::mix(m.foreground, m.background, 0.30)).into(); let primary_active: Hsla = rgb(presets::mix(m.foreground, m.background, 0.10)).into(); @@ -501,23 +307,6 @@ pub(crate) fn apply_theme(mut window: Option<&mut Window>, cx: &mut App) { t.tokens.button_primary_hover = primary_hover.into(); t.tokens.button_primary_active = primary_active.into(); - // Status colours. The last family that was still gpui-component's stock - // Tailwind (`red-400`, `yellow-400`, `green-400`) — a palette with no - // relationship to the active theme, used at 33 sites. On Dracula that put a - // `#f87171` delete button beside `#ff5555` terminal output: two reds, one - // window. On the light themes it was worse than inconsistent, at 2.45:1 — - // under even the non-text floor. - // - // They now come from each theme's *own* ANSI-16 (see `Theme::semantics`), so - // a danger marker and an error line of shell output are the same red. - // - // Each family gets three roles because the plain field and the tokens are - // read for different jobs: tty7's own sites use `Theme::danger` as a text / - // small-mark colour (a 7px status dot in the run list, a label), while - // gpui-component's buttons fill from `tokens.button_danger` and put - // `*_foreground` on top of that fill. One value cannot serve both. - // `ink` and `fill` each step one notch either side of themselves for - // hover/active — the same shape the primary family above uses. let steps = |c: u32| { ( Hsla::from(rgb(c)), @@ -526,7 +315,6 @@ pub(crate) fn apply_theme(mut window: Option<&mut Window>, cx: &mut App) { ) }; - // ── Danger ── let (ink, ink_hover, ink_active) = steps(sem.danger.ink); let (fill, fill_hover, fill_active) = steps(sem.danger.fill); let on_fill = Hsla::from(rgb(sem.danger.on_fill)); @@ -543,7 +331,6 @@ pub(crate) fn apply_theme(mut window: Option<&mut Window>, cx: &mut App) { t.tokens.button_danger_active = fill_active.into(); t.tokens.button_danger_foreground = on_fill.into(); - // ── Warning ── let (ink, ink_hover, ink_active) = steps(sem.warning.ink); let (fill, fill_hover, fill_active) = steps(sem.warning.fill); let on_fill = Hsla::from(rgb(sem.warning.on_fill)); @@ -560,7 +347,6 @@ pub(crate) fn apply_theme(mut window: Option<&mut Window>, cx: &mut App) { t.tokens.button_warning_active = fill_active.into(); t.tokens.button_warning_foreground = on_fill.into(); - // ── Success ── let (ink, ink_hover, ink_active) = steps(sem.success.ink); let (fill, fill_hover, fill_active) = steps(sem.success.fill); let on_fill = Hsla::from(rgb(sem.success.on_fill)); @@ -577,7 +363,6 @@ pub(crate) fn apply_theme(mut window: Option<&mut Window>, cx: &mut App) { t.tokens.button_success_active = fill_active.into(); t.tokens.button_success_foreground = on_fill.into(); - // ── Info ── let (ink, ink_hover, ink_active) = steps(sem.info.ink); let (fill, fill_hover, fill_active) = steps(sem.info.fill); let on_fill = Hsla::from(rgb(sem.info.on_fill)); @@ -594,11 +379,6 @@ pub(crate) fn apply_theme(mut window: Option<&mut Window>, cx: &mut App) { t.tokens.button_info_active = fill_active.into(); t.tokens.button_info_foreground = on_fill.into(); - // Links. Unset, these resolve to near-white on dark themes and near-black on - // light ones — i.e. the body text colour, so a link in the Markdown preview - // (`ui::code_editor`, which renders through gpui-component's `TextView`) - // looked exactly like prose. The theme's own cyan is what a terminal user - // already reads as "this is a link". t.link = rgb(sem.link.ink).into(); t.link_hover = rgb(presets::mix(sem.link.ink, m.foreground, 0.25)).into(); t.link_active = rgb(presets::mix(sem.link.ink, m.background, 0.20)).into(); @@ -606,21 +386,6 @@ pub(crate) fn apply_theme(mut window: Option<&mut Window>, cx: &mut App) { t.tokens.link_hover = Hsla::from(rgb(presets::mix(sem.link.ink, m.foreground, 0.25))).into(); t.tokens.link_active = Hsla::from(rgb(presets::mix(sem.link.ink, m.background, 0.20))).into(); - // Switches. Three more fields nobody had set, and the reason the toggles read - // inverted on every dark theme: - // - // * `switch_thumb` falls back to `tokens.background` — also unset — so the - // knob was gpui-component's stock near-black. On the (near-white) checked - // track that is a dark knob on a light track, the opposite of every system - // switch; on the dark unchecked track it disappeared entirely. - // * `switch` (the unchecked track) was the stock `#404040`, unrelated to the - // theme. - // - // The knob takes the light end of the theme's own axis — it is a raised - // physical object, and both macOS modes render it near-white — and the - // component already draws it with `shadow_md`, which is what separates it from - // a light track rather than raw contrast. The unchecked track is the window - // ladder's `selected` rung: the same "this is filled" grey as everything else. let knob = if presets::is_lighter(m.background, m.foreground) { m.background } else { @@ -631,19 +396,8 @@ pub(crate) fn apply_theme(mut window: Option<&mut Window>, cx: &mut App) { t.tokens.switch = Hsla::from(rgb(surfaces.window.selected)).into(); t.caret = rgb(m.caret).into(); - t.selection = rgb(m.selection).into(); // text selection highlight + t.selection = rgb(m.selection).into(); - // Overlay scrollbars (right panel, file tree, tab rail — see - // `ui::scrollbar`). gpui-component's `Scrollbar` paints the thumb from - // `tokens.scrollbar_thumb{,_hover}` and the track from the plain - // `scrollbar` field; the stock values are fixed neutral greys per light/dark - // mode, so on a tinted theme the thumb reads as a foreign grey. Derive both - // from this theme's own foreground instead — the same background→foreground - // mix ladder the borders and chips use. - // - // The track stays fully transparent: the thumb floats over the content the - // way macOS overlay scrollbars do, and a filled channel would put a vertical - // slab down the edge of every panel. let scrollbar_thumb: Hsla = rgb(presets::mix(m.background, m.foreground, 0.26)).into(); let scrollbar_thumb_hover: Hsla = rgb(presets::mix(m.background, m.foreground, 0.42)).into(); t.scrollbar = gpui::transparent_black(); @@ -653,32 +407,14 @@ pub(crate) fn apply_theme(mut window: Option<&mut Window>, cx: &mut App) { t.tokens.scrollbar_thumb = scrollbar_thumb.into(); t.tokens.scrollbar_thumb_hover = scrollbar_thumb_hover.into(); - // Follow the platform: auto-hide (fade in on scroll, out when idle) where - // the OS uses overlay scrollbars — the macOS default — and stay permanently - // visible where it doesn't, which is Windows and Linux. gpui-component's own - // `sync_scrollbar_appearance` picks `Hover` for that second case, meaning the - // bar only appears once the pointer is within 16px of the panel edge; that's - // the "no scrollbar at all" report from issue #185 on Windows. t.scrollbar_show = if auto_hide_scrollbars { ScrollbarShow::Scrolling } else { ScrollbarShow::Always }; - // Round every gpui-component widget (buttons, inputs, selects, switches, - // segmented controls, menus) to match the shell's own hand-rolled chrome, - // which uses `rounded_lg` (8px) for tab chips, title-bar tiles and the - // settings steppers. gpui-component defaults to 6px, so stock controls read a - // hair boxier than everything around them; pinning `radius` to 8 makes the - // widgets and the chrome share one corner language instead of two. The - // hand-rolled chrome sets explicit radii, so it's unaffected — this only - // pulls the stock widgets into line. t.radius = px(8.); - // Settings sidebar. NOTE: gpui-component's Sidebar paints its column from - // `tokens.sidebar` (and the active chip from `tokens.sidebar_accent`), NOT - // the `sidebar*` color fields — so those must be set on `tokens` or the - // override is a no-op and the column falls back to the stock surface. let sidebar_bg = rgb(m.sidebar); let sidebar_sel = rgb(surfaces.sidebar.selected); t.sidebar = sidebar_bg.into(); @@ -689,51 +425,14 @@ pub(crate) fn apply_theme(mut window: Option<&mut Window>, cx: &mut App) { t.tokens.sidebar_accent = Hsla::from(sidebar_sel).into(); t.sidebar_accent_foreground = rgb(surfaces.sidebar.text_selected).into(); - // Flatten gpui-component's list selection highlight (used by the command - // palette) into a single soft fill — no blue ring, no accent tint — so it - // matches this app's minimal aesthetic instead of the stock look. Keep - // `active_highlight` on (the alternative path tints with the shared - // `accent`), but make the ring colour equal the fill so the box disappears. - // - // The palette and its list paint on an elevated panel, so this is the popover - // ladder — and its `cursor` rung, same reasoning as `accent` above: one row - // moves with the arrow keys, and the eye is already tracking it. t.list.active_highlight = true; t.list_active = rgb(surfaces.popover.cursor).into(); t.list_active_border = rgb(surfaces.popover.cursor).into(); t.list_hover = rgb(surfaces.popover.hover).into(); - // ── Stock widgets that were still wearing gpui-component's defaults ────── - // - // Everything above overrides a field because someone noticed it looking - // off-theme. The fields *nobody noticed* are the actual hazard: they silently - // keep the stock value, which is a fixed grey with no relationship to the - // active theme, so whether a control reads is down to where that theme's - // background happens to land relative to a hardcoded `#2f2f2f`. - // - // `input` is how issue #197 happened. Outline buttons (and inputs, selects, - // switches) derive their resting *and* selected fills from it, so an unset - // `input` put the selected segment of every segmented control at 1.03:1 - // against its neighbours on Dracula — and inverted the direction of the - // change between light and dark themes. Pointing it at the window ladder ties - // it to the theme; `Tty7App::segmented` no longer depends on this path at all - // (it paints the ladder itself), but every other stock control still does. t.input = rgb(surfaces.window.selected).into(); t.tokens.input = Hsla::from(rgb(surfaces.window.selected)).into(); - // …but `input` only reaches the *outline* path, which reads the field live. - // The plain (non-outline) button family is derived from `input` **once**, - // inside the `apply_config` that `Theme::change` ran above — i.e. from the - // stock `#2f2f2f`, before any of this function's overrides exist — and a - // snapshot never sees the fix. So a plain `Button` still hovered and pressed - // in that grey, and `Button::selected` (the terminal search bar's `Aa` / `.*` - // toggles, the last two in the app) filled from `tokens.secondary_active` the - // same way: on Dracula, ~1.03:1 against the surface behind it. That is issue - // #197 again, one snapshot removed from the field that fixed it. - // - // Only the *state* rungs move — `tokens.button` (the resting fill) is left - // alone, so a plain button keeps the flat look it has today and only its - // hover/pressed/selected join the ladder. let button_hover: Hsla = rgb(surfaces.window.hover).into(); let button_active: Hsla = rgb(surfaces.window.selected).into(); t.tokens.button_hover = button_hover.into(); @@ -743,48 +442,19 @@ pub(crate) fn apply_theme(mut window: Option<&mut Window>, cx: &mut App) { t.tokens.button_secondary_hover = button_hover.into(); t.tokens.button_secondary_active = button_active.into(); - // Focus rings: the one place the theme's *real* accent belongs. A ring is ink - // on the background at 1–2px, which is exactly the job `legible_accent` - // conditions the seed for; the stock `neutral-300` was both off-theme and - // indistinguishable from a border. t.ring = rgb(m.accent).into(); - // `sync_native_appearance` above may have flipped the macOS app appearance, - // which resets the traffic-light buttons to their default (higher) position. - // gpui doesn't reposition them on an appearance change (only on - // resize/activation/title changes), so re-pin our centred position now — - // otherwise the buttons briefly sit too high until the next such event. Same - // immediate-re-move pattern gpui itself uses after `setRepresentedFilename`. #[cfg(target_os = "macos")] if let Some(window) = window.as_deref_mut() { window.set_traffic_light_position(traffic_light_position()); } } -/// A `Switch` wearing the theme's accent on its checked track. -/// -/// Every switch in the app goes through here rather than through `Switch::new` -/// directly. gpui-component defaults the checked track to `tokens.primary`, which -/// tty7 tunes for *primary buttons* — a near-white on dark themes, deliberately, -/// because a primary button is a light slab with dark text. As a switch track -/// that same colour swallows the (near-white) knob, so the two uses genuinely -/// need two colours and the component only offers a per-instance override. -/// -/// The accent is the right one, and this is the one control in the app that gets -/// it. On/off differ by *hue* here because they cannot differ by lightness: the -/// knob has already claimed the light end of the axis, so a checked track that -/// reads as "brighter" is a track the knob vanishes into. It is the same reason -/// every system switch is coloured. `Neutrals::accent` is contrast-conditioned -/// (see `presets::legible_accent`), so a seed as pale as the Light theme's -/// `#00c2ff` still lands on a track a white knob can sit on. pub(crate) fn switch(id: impl Into<gpui::ElementId>, cx: &App) -> gpui_component::switch::Switch { let accent = cx.global::<presets::ActiveAccent>().0; gpui_component::switch::Switch::new(id).color(Hsla::from(rgb(accent))) } -/// Apply `Config::mouse_hide_while_typing` to GPUI's cursor-hide policy: hide the -/// pointer while typing when on, never when off. Called at startup and whenever -/// the config changes (setter + hot-reload) so the switch takes effect live. pub(crate) fn apply_cursor_hide_mode(cx: &mut App) { let mode = if cx.global::<Config>().mouse_hide_while_typing { gpui::CursorHideMode::OnTypingAndAction @@ -794,17 +464,6 @@ pub(crate) fn apply_cursor_hide_mode(cx: &mut App) { cx.set_cursor_hide_mode(mode); } -/// Pin the macOS app appearance to the active theme's light/dark mode -/// (`Some(dark)`), or release the pin so it follows the OS `Appearance` -/// setting again (`None`, used while `Config::theme_follow_system` is on). -/// -/// macOS draws the native traffic-light buttons according to the window's -/// effective appearance. With a dark tty7 theme on a light-mode macOS, the -/// system paints the *light-style* inactive (unfocused) traffic lights — heavy -/// mid-grey circles that look filthy on the dark titlebar. gpui only ever -/// *reads* `effectiveAppearance` (`WindowAppearance::from_native`); it exposes -/// no setter, so we pin `NSApplication.appearance` ourselves via AppKit. This -/// also keeps system menus, context menus and scrollbars in the right mode. #[cfg(target_os = "macos")] fn sync_native_appearance(dark: Option<bool>) { use objc2::MainThreadMarker; @@ -812,13 +471,10 @@ fn sync_native_appearance(dark: Option<bool>) { NSAppearance, NSAppearanceNameAqua, NSAppearanceNameDarkAqua, NSApplication, }; - // `apply_theme` is always invoked on the gpui app (main) thread; bail - // defensively rather than panic if that ever stops holding. let Some(mtm) = MainThreadMarker::new() else { return; }; let appearance = dark.and_then(|dark| { - // SAFETY: reading the framework-provided appearance-name statics. let name = unsafe { if dark { NSAppearanceNameDarkAqua @@ -828,7 +484,6 @@ fn sync_native_appearance(dark: Option<bool>) { }; NSAppearance::appearanceNamed(name) }); - // `None` here means "inherit from the system" — the AppKit way to unpin. NSApplication::sharedApplication(mtm).setAppearance(appearance.as_deref()); } @@ -840,11 +495,6 @@ mod tests { use super::*; use gpui::TestAppContext; - /// The follow-system slot has to come from the cached [`SystemAppearance`], - /// not from a fresh `cx.window_appearance()` — that read is what re-enters - /// gpui's borrowed Linux client and panics inside the appearance observer. - /// The test platform always reports `Light`, so the dark half only passes - /// while the cache is what's consulted. #[gpui::test] fn effective_preset_follows_the_cached_system_appearance(cx: &mut TestAppContext) { cx.update(|cx| { @@ -863,7 +513,6 @@ mod tests { assert!(system_dark(cx)); assert_eq!(effective_preset_id(cx), "dark-slot"); - // Following off, the manual preset wins whatever the OS is doing. cx.global_mut::<Config>().theme_follow_system = false; assert_eq!(effective_preset_id(cx), Config::default().theme_preset); }); diff --git a/src/ui/tray/icon.rs b/src/ui/tray/icon.rs index a32ea73b..58faa05a 100644 --- a/src/ui/tray/icon.rs +++ b/src/ui/tray/icon.rs @@ -1,21 +1,6 @@ -//! Tray bitmap rendering: the bundled SVGs rasterized with `resvg` (gpui's -//! own SVG path only yields a tinted alpha mask, so the tray draws its own). -//! -//! Per platform: -//! - macOS: the outline terminal glyph (`tray.svg`) as a *template* image — -//! the system recolors its alpha for light/dark menu bars, permanently. -//! Attention never touches the bitmap (template images can't carry color, -//! and leaving template mode made the glyph illegible); the tooltip and -//! menu carry agent status instead (see `native.rs`). -//! - Windows / Linux: the colored app icon (`app-icon.svg`); attention -//! punches a transparent ring into the corner and fills an amber badge, so -//! the badge separates from the orange tile behind it. - use resvg::tiny_skia; use resvg::usvg; -/// Straight (unpremultiplied) RGBA, the format both `tray_icon::Icon` and -/// (after a byte shuffle) `ksni::Icon` want. pub(super) struct RgbaImage { pub data: Vec<u8>, pub width: u32, @@ -27,23 +12,14 @@ const GLYPH_SVG: &[u8] = include_bytes!("../../../assets/tray.svg"); #[cfg(not(target_os = "macos"))] const GLYPH_SVG: &[u8] = include_bytes!("../../../assets/app-icon.svg"); -/// Physical pixel size. On macOS the bitmap is 36 px (retina-crisp at 18 pt); -/// native.rs then overrides the NSImage to 22 pt so the glyph fills the menu -/// bar. Windows tray slots are 16–32 px; 32 downsamples cleanly. #[cfg(target_os = "macos")] const SIZE: u32 = 36; #[cfg(not(target_os = "macos"))] const SIZE: u32 = 32; -/// The `Waiting` amber, same hue as the in-window status dot -/// (`AgentStatus::dot_rgb`). #[cfg(not(target_os = "macos"))] const AMBER: (u8, u8, u8) = (0xF5, 0x9E, 0x0B); -/// Render the tray icon: the template outline glyph, always — attention -/// never touches the bitmap, so the icon stays a template image the system -/// keeps legible on any bar. `None` only on a malformed bundled SVG, i.e. -/// never in practice — callers treat it as "no icon change". #[cfg(target_os = "macos")] pub(super) fn render() -> Option<RgbaImage> { let tree = usvg::Tree::from_data(GLYPH_SVG, &usvg::Options::default()).ok()?; @@ -52,10 +28,6 @@ pub(super) fn render() -> Option<RgbaImage> { Some(to_rgba(&pixmap)) } -/// Render the tray icon. `attention` = some agent is blocked on the user — -/// stamps the amber badge into the colored app icon. `None` only on a -/// malformed bundled SVG, i.e. never in practice — callers treat it as "no -/// icon change". #[cfg(not(target_os = "macos"))] pub(super) fn render(attention: bool) -> Option<RgbaImage> { let tree = usvg::Tree::from_data(GLYPH_SVG, &usvg::Options::default()).ok()?; @@ -69,23 +41,16 @@ pub(super) fn render(attention: bool) -> Option<RgbaImage> { Some(to_rgba(&pixmap)) } -/// The tray-menu row avatar for an agent pane: the tab avatar's visual -/// language translated to a menu icon — brand-colored disc, the brand mark as -/// a white silhouette (geometry only, same as the tab renders it), and the -/// status dot in the bottom-right corner. `None` only if the brand SVG fails -/// to resolve/parse, which the caller treats as "text-only row". pub(super) fn agent_avatar( agent: crate::core::cli_agent::CLIAgent, status: crate::core::cli_agent::AgentStatus, ) -> Option<tiny_skia::Pixmap> { use gpui::AssetSource as _; - // 16 pt at 2× — the size native menus render item icons at. const SIZE: u32 = 32; let s = SIZE as f32; let mut pixmap = tiny_skia::Pixmap::new(SIZE, SIZE)?; - // Brand-colored disc. let accent = agent.accent_rgb(); let mut paint = tiny_skia::Paint { anti_alias: true, @@ -108,10 +73,6 @@ pub(super) fn agent_avatar( None, ); - // Brand mark as a white silhouette, centered at ~60% of the disc — the - // same "tinted alpha mask" treatment the tab avatar gets from gpui. The - // SVG resolves through the app's asset source, so the generic `bot` - // fallback for unbranded agents comes along for free. let svg = crate::ui::assets::Assets .load(agent.icon_path()) .ok() @@ -131,8 +92,6 @@ pub(super) fn agent_avatar( None, ); - // Status dot, bottom-right, ringed by transparency so it reads against - // the disc — the same composition as the tab avatar's dot. Idle has none. if let Some(rgb) = status.dot_rgb() { let (cx, cy, r) = (s * 0.80, s * 0.80, s * 0.17); let circle = |radius: f32| { @@ -166,10 +125,6 @@ pub(super) fn agent_avatar( Some(pixmap) } -/// Scale-to-fit + center transform for rendering an SVG into a square -/// `size`×`size` bitmap — a non-square SVG would otherwise hug the top-left -/// corner. (Both bundled icons are square today; this keeps that an -/// aesthetic fact, not a correctness assumption.) fn fit_center(tree: &usvg::Tree, size: u32) -> tiny_skia::Transform { let (w, h) = (tree.size().width(), tree.size().height()); let scale = size as f32 / w.max(h); @@ -179,8 +134,6 @@ fn fit_center(tree: &usvg::Tree, size: u32) -> tiny_skia::Transform { ) } -/// Un-premultiply a tiny-skia pixmap into straight RGBA (what -/// `tray_icon::Icon`/`muda::Icon` want). pub(super) fn to_rgba(pixmap: &tiny_skia::Pixmap) -> RgbaImage { let mut data = Vec::with_capacity(pixmap.data().len()); for p in pixmap.pixels() { @@ -194,13 +147,10 @@ pub(super) fn to_rgba(pixmap: &tiny_skia::Pixmap) -> RgbaImage { } } -/// Repaint every covered pixel to `rgb`, keeping coverage (alpha) intact — -/// turns the glyph into a flat single-color mark. fn recolor(pixmap: &mut tiny_skia::Pixmap, rgb: (u8, u8, u8)) { for p in pixmap.pixels_mut() { let a = p.alpha(); let mul = |c: u8| ((c as u16 * a as u16) / 255) as u8; - // from_rgba only rejects components > alpha; mul() guarantees not. if let Some(np) = tiny_skia::PremultipliedColorU8::from_rgba(mul(rgb.0), mul(rgb.1), mul(rgb.2), a) { @@ -209,9 +159,6 @@ fn recolor(pixmap: &mut tiny_skia::Pixmap, rgb: (u8, u8, u8)) { } } -/// Stamp the amber attention badge in the top-right corner: first clear a -/// slightly larger disc so the badge is ringed by transparency (separating it -/// from whatever the glyph or a colored tile puts behind it), then fill. #[cfg(not(target_os = "macos"))] fn badge(pixmap: &mut tiny_skia::Pixmap) { let s = SIZE as f32; @@ -250,7 +197,6 @@ fn badge(pixmap: &mut tiny_skia::Pixmap) { } } -/// The same bitmap in `ksni::Icon`'s wire format: ARGB32, network byte order. #[cfg(target_os = "linux")] pub(super) fn render_argb(attention: bool) -> Option<(Vec<u8>, u32)> { let img = render(attention)?; @@ -266,9 +212,6 @@ mod tests { use super::*; use crate::core::cli_agent::{AgentStatus, CLIAgent}; - /// `recolor` must keep coverage (alpha) and produce premultiplied - /// components that `PremultipliedColorU8` accepts (component ≤ alpha) — - /// the invariant the `from_rgba` in its body relies on. #[test] fn recolor_keeps_alpha_and_flattens_color() { let mut pm = tiny_skia::Pixmap::new(2, 2).unwrap(); @@ -284,15 +227,12 @@ mod tests { recolor(&mut pm, (0xFF, 0xFF, 0xFF)); for (p, a) in pm.pixels().iter().zip(alphas) { assert_eq!(p.alpha(), a); - // White at coverage a premultiplies to ~a on every channel. assert!(p.red().abs_diff(a) <= 1, "red {} vs alpha {a}", p.red()); assert_eq!(p.red(), p.green()); assert_eq!(p.green(), p.blue()); } } - /// `to_rgba` un-premultiplies: a half-covered red pixel comes back as - /// full red with the original alpha. #[test] fn to_rgba_demultiplies() { let mut pm = tiny_skia::Pixmap::new(1, 1).unwrap(); @@ -312,7 +252,6 @@ mod tests { assert_eq!((px[1], px[2]), (0, 0)); } - /// The template glyph renders at the declared size with visible coverage. #[cfg(target_os = "macos")] #[test] fn render_produces_template_glyph() { @@ -323,8 +262,6 @@ mod tests { assert!(covered > 0, "icon rendered fully transparent"); } - /// Both tray states render at the declared size with visible coverage, - /// and the attention badge actually changes the bitmap. #[cfg(not(target_os = "macos"))] #[test] fn render_produces_both_states() { @@ -339,29 +276,14 @@ mod tests { assert_ne!(normal.data, attention.data); } - /// Every avatar renders, with and without the status dot. Run over the - /// whole roster rather than one branded and one fallback agent, because - /// this is the only test that puts the bundled SVGs through resvg: the - /// asset-source test next door proves the bytes resolve, not that they - /// parse into visible geometry. Vendor marks arrive in whatever shape the - /// vendor publishes — stylesheets, nested groups, features usvg quietly - /// drops — and a mark that parses to nothing shows up as a bare accent - /// disc, which nothing else here would catch. #[test] fn agent_avatar_renders_brand_and_fallback() { for agent in CLIAgent::ALL { let idle = agent_avatar(agent, AgentStatus::Idle).unwrap(); let waiting = agent_avatar(agent, AgentStatus::Waiting).unwrap(); assert_eq!((idle.width(), idle.height()), (32, 32)); - // The disc leaves the very corners transparent… assert_eq!(idle.pixel(0, 0).unwrap().alpha(), 0); - // …and the center is covered (disc + glyph). assert!(idle.pixel(16, 16).unwrap().alpha() > 0); - // The glyph actually drew something. The disc under it is a flat - // accent fill, so every opaque pixel shares one colour unless the - // white mark landed on top — one colour means resvg handed back an - // empty canvas, which is what a silently-unsupported SVG feature - // looks like from here. let shades: std::collections::HashSet<_> = idle .pixels() .iter() @@ -373,13 +295,10 @@ mod tests { "{} rendered as a bare disc — its glyph drew nothing", agent.display_name() ); - // The status dot changes the bottom-right corner. assert_ne!(idle.data(), waiting.data()); } } - /// ksni wants ARGB32 in network byte order — verify the shuffle against - /// the RGBA source. #[cfg(target_os = "linux")] #[test] fn render_argb_reorders_bytes() { diff --git a/src/ui/tray/mod.rs b/src/ui/tray/mod.rs index 69f82321..98f56964 100644 --- a/src/ui/tray/mod.rs +++ b/src/ui/tray/mod.rs @@ -1,28 +1,3 @@ -//! System tray / menu bar status item. -//! -//! The tray is the app's face outside the window: on Windows/Linux the icon -//! flips to an amber-badged attention state the moment any pane's coding -//! agent blocks on the user (`Waiting`); on macOS the icon is a template -//! image that stays calm in every state (legible on any bar) — agent status -//! lives in the tooltip and menu instead. The menu lists every agent pane — -//! click one to reveal it — plus window/notification/quit controls. Menu -//! labels are English, matching the native app menus (`ui::theme::set_menus`). -//! -//! Platform split (see Cargo.toml for the why): -//! - macOS / Windows: tauri's `tray-icon` (NSStatusItem / Shell_NotifyIcon), -//! in [`native`]. Both are driven by the main-thread event loop gpui -//! already pumps, so the backend lives on the foreground executor. -//! - Linux: `ksni` (StatusNotifierItem over DBus, pure Rust), in [`sni`] — -//! `tray-icon`'s Linux backend would drag in GTK + libappindicator, which -//! the AppImage doesn't bundle. On desktops without an SNI host the spawn -//! fails and the app simply runs without a tray. -//! -//! Data flow mirrors the rest of the UI (which polls rather than observes — -//! see `TerminalView::poll_foreground`): a foreground task snapshots the -//! agent panes once a second, diffs against the last snapshot, and only -//! touches the native tray when something changed. Menu clicks come back on -//! a channel and are applied to the app on the foreground executor. - mod icon; #[cfg(any(target_os = "macos", target_os = "windows"))] mod native; @@ -38,38 +13,19 @@ use crate::core::cli_agent::AgentStatus; use crate::core::config::{Config, NotifyMode}; use gpui::App; -/// How often the poll loop re-snapshots the app. Agent status itself is -/// polled into the views on a 300 ms timer; 1 s here keeps the tray a hair -/// behind the in-window dots at negligible cost. const POLL: std::time::Duration = std::time::Duration::from_millis(1000); -/// A menu click, decoded from the platform menu item id and applied to the -/// app by [`Tty7App::handle_tray_action`] on the foreground executor. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum TrayAction { - /// Bring the window to the front. ShowWindow, - /// Reveal the pane hosting this agent: switch to its tab, focus the - /// leaf, and activate the window. The id is the leaf's gpui entity id — - /// resolved against the live tab tree at click time, so a row that - /// outlived its pane (menu open across a close) degrades to a no-op. - RevealPane { - leaf_id: u64, - }, - /// Set the notification policy (the same knob as Settings → Terminal). + RevealPane { leaf_id: u64 }, SetNotifyMode(NotifyMode), OpenSettings, - /// Force an update check (even with the startup check disabled) and open - /// Settings → About, where the result lands. CheckForUpdates, - /// Plain quit — identical to ⌘Q: the daemon and every session survive. Quit, - /// Quit *and* shut the daemon down, ending every running session. - /// Confirmed with a prompt before anything happens. QuitStopSessions, } -/// Sort key for the agent list: the pane that needs the user tops the menu. pub(crate) fn urgency(status: AgentStatus) -> u8 { match status { AgentStatus::Waiting => 3, @@ -79,38 +35,26 @@ pub(crate) fn urgency(status: AgentStatus) -> u8 { } } -/// One agent pane, as shown in the tray menu. #[derive(Clone, PartialEq, Eq)] pub(crate) struct AgentRow { - /// The hosting leaf's entity id (`EntityId::as_u64`), the reveal key. pub leaf_id: u64, - /// Which agent — names the row and picks the brand avatar. pub agent: crate::core::cli_agent::CLIAgent, pub status: AgentStatus, - /// Where it's working: the cwd's directory name, plus the git branch - /// when known — e.g. "tty7 @ main". pub detail: String, } -/// Everything the tray renders, diffed once a second against the app. #[derive(Clone, Default, PartialEq, Eq)] pub(crate) struct TraySnapshot { - /// Agent panes, most urgent first (waiting > working > done > idle). pub agents: Vec<AgentRow>, pub notify_mode: NotifyMode, } impl TraySnapshot { - /// Whether any agent is blocked on the user — drives the attention badge - /// on Windows/Linux (the macOS icon stays calm; the tooltip and menu - /// carry agent status there). #[cfg_attr(target_os = "macos", allow(dead_code))] pub(crate) fn attention(&self) -> bool { self.agents.iter().any(|a| a.status == AgentStatus::Waiting) } - /// Hover text: a one-line census of the agent panes ("tty7 — 1 waiting, - /// 2 working"), or just "tty7" when none are running. pub(crate) fn tooltip(&self) -> String { let count = |s: AgentStatus| self.agents.iter().filter(|a| a.status == s).count(); let mut parts = Vec::new(); @@ -131,18 +75,11 @@ impl TraySnapshot { } } -/// The platform-independent menu shape; each backend translates it 1:1 into -/// its native menu type, so the layout and labels live in exactly one place -/// ([`menu_spec`]). pub(crate) enum SpecItem { Item { id: String, label: String, - /// `Some(_)` renders a checkable item (the notification radio). checked: Option<bool>, - /// `Some(_)` renders the agent's brand avatar (colored disc + white - /// mark + status dot — the tab avatar's menu translation) next to the - /// label. The backends rasterize it via [`icon::agent_avatar`]. avatar: Option<(crate::core::cli_agent::CLIAgent, AgentStatus)>, }, Separator, @@ -152,9 +89,6 @@ pub(crate) enum SpecItem { }, } -/// Build the menu for a snapshot. Layout: reveal/window on top, then the -/// live agent panes, then notification policy + settings, then the two quit -/// flavors — plain quit keeps sessions (like ⌘Q), the second one stops them. pub(crate) fn menu_spec(snap: &TraySnapshot) -> Vec<SpecItem> { let item = |id: &str, label: String| SpecItem::Item { id: id.to_string(), @@ -164,9 +98,6 @@ pub(crate) fn menu_spec(snap: &TraySnapshot) -> Vec<SpecItem> { }; let mut items = vec![item("show", "Show tty7".into()), SpecItem::Separator]; for a in &snap.agents { - // The avatar (brand disc + status dot) carries the who/state visually, - // exactly like the tab chip; the textual suffix repeats the state for - // scanability in a text-first menu. let state = match a.status { AgentStatus::Waiting => " — needs input", AgentStatus::Working => " — working", @@ -191,9 +122,6 @@ pub(crate) fn menu_spec(snap: &TraySnapshot) -> Vec<SpecItem> { }; items.push(SpecItem::Submenu { label: "Notifications".into(), - // Weakest to strongest, matching Settings → Window & Tabs → Notify on - // command finish, which writes the same setting. The two used to run in - // opposite directions with different capitalisation. items: vec![ notify("notify:never", "Never", NotifyMode::Never), notify("notify:unfocused", "When Unfocused", NotifyMode::Unfocused), @@ -204,17 +132,10 @@ pub(crate) fn menu_spec(snap: &TraySnapshot) -> Vec<SpecItem> { items.push(item("updates", "Check for Updates…".into())); items.push(SpecItem::Separator); items.push(item("quit", "Quit tty7".into())); - // Plain quit leaves the daemon (and every session) running; this one - // stops the daemon too. "Daemon" is already in the product vocabulary — - // the Help menu ships "Restart Daemon…" — and the confirm prompt spells - // out the consequences. items.push(item("quit-stop", "Quit and Stop Daemon…".into())); items } -/// Decode a clicked menu item id back into an action. Ids are assigned in -/// [`menu_spec`]; unknown ids (never expected) decode to `None` and the -/// click is dropped. pub(crate) fn action_from_id(id: &str) -> Option<TrayAction> { match id { "show" => Some(TrayAction::ShowWindow), @@ -248,8 +169,6 @@ mod tests { } } - /// Every id the menu builder mints must decode back to an action — - /// otherwise a click on that item silently does nothing. #[test] fn every_menu_id_decodes_to_an_action() { fn check(items: &[SpecItem]) { @@ -274,7 +193,6 @@ mod tests { action_from_id("agent:42"), Some(TrayAction::RevealPane { leaf_id: 42 }) ); - // Garbage after the prefix is dropped, not a panic or a mis-decode. assert_eq!(action_from_id("agent:nope"), None); assert_eq!(action_from_id("bogus"), None); } @@ -291,8 +209,6 @@ mod tests { assert_eq!(TraySnapshot::default().tooltip(), "tty7"); } - /// The empty snapshot renders no agent section (no dangling separator), - /// and the notification radio reflects the snapshot's mode. #[test] fn menu_spec_shape() { let empty = menu_spec(&TraySnapshot::default()); @@ -315,7 +231,6 @@ mod tests { "Quit and Stop Daemon…" ] ); - // No two separators in a row when the agent section is absent. assert!( !empty .windows(2) @@ -330,10 +245,6 @@ mod tests { } } -/// Everything the tray renders, gathered across *every* open window. One icon -/// represents the whole app, so an agent waiting in a background window has to -/// show up here — otherwise the tray would silently only ever describe -/// whichever window happened to open first. fn app_snapshot(cx: &mut App) -> TraySnapshot { let windows = crate::ui::windows::WindowRegistry::open_windows(cx); let mut agents = Vec::new(); @@ -341,8 +252,6 @@ fn app_snapshot(cx: &mut App) -> TraySnapshot { let Some(app) = weak.upgrade() else { continue }; agents.extend(app.read(cx).agent_rows(cx)); } - // Sorted once over the merged list, so the most urgent pane tops the menu - // regardless of which window it lives in. agents.sort_by_key(|a| std::cmp::Reverse(urgency(a.status))); TraySnapshot { agents, @@ -350,12 +259,6 @@ fn app_snapshot(cx: &mut App) -> TraySnapshot { } } -/// Route a menu click to the window that should handle it. -/// -/// `RevealPane` carries a leaf's entity id, which belongs to exactly one -/// window — sending it anywhere else would silently do nothing. Everything -/// else (Settings, quit, the notify toggle) acts on the app or just needs -/// *some* window, so it goes to the most recently focused one. fn dispatch(action: TrayAction, cx: &mut App) { use crate::ui::windows::WindowRegistry; @@ -371,8 +274,6 @@ fn dispatch(action: TrayAction, cx: &mut App) { } .or_else(|| WindowRegistry::most_recent(cx)); - // No window at all (every one closed while the menu was open): nothing to - // act on. Quit is the exception — it must work even then. let Some(workspace) = target else { if matches!(action, TrayAction::Quit) { cx.quit(); @@ -392,25 +293,9 @@ fn dispatch(action: TrayAction, cx: &mut App) { }); } -/// Wire the tray up: one task pumps menu clicks into the app, another polls -/// the app into the tray. Called once, for the first window (`ui::app`); both -/// tasks live for the app's lifetime, not any one window's. -/// -/// `show_tray_icon` is re-read every tick, so the Settings toggle and a -/// `config.json` hot-reload both take effect within a second — the backend -/// is dropped (icon removed) when off and re-created when back on. pub(crate) fn init(cx: &mut App) { let (tx, rx) = smol::channel::unbounded::<TrayAction>(); - // Menu clicks → the app. The platform handler feeds `tx` from wherever - // the OS delivers menu events; this task is the only place they touch - // gpui state, with a real window + context in hand. - // - // App-scoped rather than tied to one window's entity: the tray is a single - // icon for the whole app and has to outlive any individual window. Each - // click picks its own target window (see [`dispatch`]). - // The loop ends when every `TrayAction` sender is dropped — i.e. when the - // backend goes away. On app shutdown the detached task itself is dropped. cx.spawn(async move |cx| { while let Ok(action) = rx.recv().await { cx.update(|cx| dispatch(action, cx)); @@ -418,22 +303,11 @@ pub(crate) fn init(cx: &mut App) { }) .detach(); - // App → tray poll loop. Owns the backend; dropping it removes the icon. - // The backend types are !Send on macOS (NSStatusItem), which is fine on - // the foreground executor — exactly where tray-icon requires them. cx.spawn(async move |cx| { let mut backend: Option<Backend> = None; - // Last snapshot actually pushed; `None` forces a push after - // (re)creation so a fresh icon never shows a stale menu. let mut shown: Option<TraySnapshot> = None; - // Creation can fail transiently — on Linux the SNI host may simply - // not be on the bus *yet* (tty7 autostarting at login races the - // desktop shell / AppIndicator extension) — so a failed create is - // retried on a slow backoff before giving up for this enable-cycle: - // one attempt every RETRY_EVERY ticks, MAX_ATTEMPTS total. Toggling - // the setting off and on re-arms. const MAX_ATTEMPTS: u32 = 10; - const RETRY_EVERY: u32 = 30; // ticks ≈ seconds + const RETRY_EVERY: u32 = 30; let mut attempts = 0u32; let mut cooldown = 0u32; loop { diff --git a/src/ui/tray/native.rs b/src/ui/tray/native.rs index 4eb8b762..f3ced337 100644 --- a/src/ui/tray/native.rs +++ b/src/ui/tray/native.rs @@ -1,10 +1,3 @@ -//! macOS / Windows tray backend on tauri's `tray-icon`. -//! -//! Lifecycle rules the poll loop in `mod.rs` already honors: the tray must be -//! created on a thread that pumps native events — gpui's main thread — and -//! `TrayIcon` is `!Send` there, so the backend lives inside the foreground -//! poll task and dropping it removes the status item. - use super::{SpecItem, TrayAction, TraySnapshot, action_from_id, icon}; use gpui::AsyncApp; use tray_icon::menu::{ @@ -14,25 +7,15 @@ use tray_icon::{Icon, TrayIcon, TrayIconBuilder}; pub(super) struct Backend { tray: TrayIcon, - /// Whether the amber badge is currently stamped, so a snapshot diff that - /// doesn't flip attention skips the bitmap rebuild. macOS tracks nothing: - /// its template glyph never changes (see `icon.rs`). #[cfg(not(target_os = "macos"))] attention: bool, } impl Backend { - /// Build the status item with the calm icon and an initial (empty-state) - /// menu; the first `update` follows immediately. `None` (creation - /// failure) is retried by the poll loop on a slow backoff (see - /// `mod.rs`). pub(super) async fn create( tx: smol::channel::Sender<TrayAction>, _cx: &AsyncApp, ) -> Option<Self> { - // (Re-)install the process-global menu-event hook. Menu events are - // delivered by the native event loop the app already pumps; decoding - // and the actual work happen on the channel's gpui side. MenuEvent::set_event_handler(Some(move |event: MenuEvent| { if let Some(action) = action_from_id(&event.id().0) { let _ = tx.try_send(action); @@ -46,13 +29,9 @@ impl Backend { let icon = Icon::from_rgba(img.data, img.width, img.height).ok()?; let tray = TrayIconBuilder::new() .with_icon(icon) - // The glyph is a template on macOS (system recolors it for the - // bar, in both states); a no-op on Windows. .with_icon_as_template(true) .with_tooltip("tty7") .with_menu(Box::new(build_menu(&TraySnapshot::default()))) - // Windows defaults to menu-on-right-click only; a status item - // whose left click does nothing reads as broken. .with_menu_on_left_click(true) .build(); let tray = match tray { @@ -63,16 +42,11 @@ impl Backend { } }; - // tray-icon hardcodes the NSImage height to 18 pt; override to a - // larger size so the glyph fills more of the menu bar. The bitmap - // itself is already rendered at `icon::SIZE` px (retina-ready). #[cfg(target_os = "macos")] if let Some(status_item) = tray.ns_status_item() { if let Some(mtm) = objc2::MainThreadMarker::new() { if let Some(button) = status_item.button(mtm) { if let Some(nsimage) = button.image() { - // 22 pt matches the macOS menu bar height; the glyph - // scales proportionally from its 96×96 viewBox. let target_h: f64 = 22.0; let aspect = nsimage.size().width / nsimage.size().height; nsimage.setSize(objc2_foundation::NSSize::new(target_h * aspect, target_h)); @@ -87,15 +61,9 @@ impl Backend { }) } - /// Push a changed snapshot into the native item: menu always (it's what - /// changed); on Windows, the badge only across an attention flip. The - /// macOS icon is a template image in every state — the system recolors it - /// for the bar, and it never carries an attention mark (status lives in - /// the tooltip and menu). pub(super) fn update(&mut self, snap: &TraySnapshot) { self.tray.set_menu(Some(Box::new(build_menu(snap)))); let _ = self.tray.set_tooltip(Some(snap.tooltip())); - // Windows: flip the amber corner badge on the colored icon. #[cfg(not(target_os = "macos"))] { let attention = snap.attention(); @@ -111,7 +79,6 @@ impl Backend { } } -/// Translate the shared menu spec into a muda menu. fn build_menu(snap: &TraySnapshot) -> Menu { let menu = Menu::new(); for item in super::menu_spec(snap) { @@ -120,8 +87,6 @@ fn build_menu(snap: &TraySnapshot) -> Menu { menu } -/// Append one spec item to a muda container (top-level menu or submenu — -/// both expose `append(&dyn IsMenuItem)` behind small wrappers). fn append(menu: &Menu, item: &SpecItem) { let appended = match item { SpecItem::Item { .. } => menu.append(leaf_item(item).as_ref()), @@ -145,9 +110,6 @@ fn append(menu: &Menu, item: &SpecItem) { } } -/// Build the muda item for a `SpecItem::Item`: checkable → `CheckMenuItem`, -/// avatar-bearing (agent rows) → `IconMenuItem` with the rasterized brand -/// avatar, plain → `MenuItem`. A failed avatar render degrades to text-only. fn leaf_item(item: &SpecItem) -> Box<dyn IsMenuItem> { let SpecItem::Item { id, diff --git a/src/ui/tray/sni.rs b/src/ui/tray/sni.rs index 17611cb9..71c0a072 100644 --- a/src/ui/tray/sni.rs +++ b/src/ui/tray/sni.rs @@ -1,31 +1,11 @@ -//! Linux tray backend: `ksni`, a pure-Rust StatusNotifierItem over DBus. -//! -//! ksni owns a service thread and re-queries the [`ksni::Tray`] impl for -//! icon/menu/status whenever we call `Handle::update`, so the backend just -//! swaps the stored snapshot in. That call is a blocking round-trip to the -//! service thread, so updates flow through a background task rather than the -//! foreground poll loop. Menu item activation runs on ksni's thread; actions -//! cross back to gpui over the same channel the other platforms use. -//! -//! On desktops without an SNI host (bare GNOME without the AppIndicator -//! extension) the spawn fails; the poll loop logs once and the app runs -//! without a tray. - use super::{SpecItem, TrayAction, TraySnapshot, action_from_id, icon}; use gpui::{AppContext as _, AsyncApp}; pub(super) struct Backend { - /// Feeds the updater task spawned in [`Backend::create`]. Dropping the - /// Backend closes the channel, which makes that task shut the SNI - /// service down — removing the icon. updates: smol::channel::Sender<TraySnapshot>, } impl Backend { - /// Spawn the SNI service plus its updater task. Registration and every - /// later `Handle::update` are blocking round-trips to ksni's service - /// thread, so both live on the background executor rather than stalling - /// the foreground poll loop. pub(super) async fn create( tx: smol::channel::Sender<TrayAction>, cx: &AsyncApp, @@ -49,23 +29,11 @@ impl Backend { let (updates, update_rx) = smol::channel::unbounded::<TraySnapshot>(); cx.background_spawn(async move { while let Ok(mut snap) = update_rx.recv().await { - // Coalesce a queued burst down to the newest snapshot — - // intermediate states would each cost a DBus push. while let Ok(later) = update_rx.try_recv() { snap = later; } - // `update` re-reads menu/icon/status from the Tray impl and - // pushes the changed properties over DBus. `None` (service - // gone) can only follow the `shutdown` below; a vanished SNI - // *host* is ksni's problem — it re-registers by itself when - // a watcher returns to the bus. handle.update(move |tray| tray.snap = snap); } - // Channel closed: the Backend was dropped (tray toggled off or - // app exit). Dropping the handle alone would leave the service - // thread (and the icon) alive; ask it to unregister. The awaiter - // is intentionally not waited on — teardown can finish on ksni's - // thread. handle.shutdown(); }) .detach(); @@ -73,8 +41,6 @@ impl Backend { } pub(super) fn update(&mut self, snap: &TraySnapshot) { - // Unbounded channel: the only send failure is "closed", impossible - // while the Backend (whose drop is what closes it) is alive. let _ = self.updates.try_send(snap.clone()); } } @@ -85,8 +51,6 @@ struct SniTray { } impl SniTray { - /// A menu item that sends the action decoded from `id` — the same id - /// space `action_from_id` serves on the other platforms. fn send(&self, id: &str) { if let Some(action) = action_from_id(id) { let _ = self.tx.try_send(action); @@ -105,8 +69,6 @@ impl ksni::Tray for SniTray { fn status(&self) -> ksni::Status { if self.snap.attention() { - // Hosts surface this — e.g. KDE moves the item out of the - // overflow and may highlight it. ksni::Status::NeedsAttention } else { ksni::Status::Active @@ -131,7 +93,6 @@ impl ksni::Tray for SniTray { } } - /// Plain left click on the item (not the menu): reveal the window. fn activate(&mut self, _x: i32, _y: i32) { let _ = self.tx.try_send(TrayAction::ShowWindow); } @@ -144,7 +105,6 @@ impl ksni::Tray for SniTray { } } -/// Translate one shared spec item into ksni's menu tree. fn translate(item: SpecItem) -> ksni::MenuItem<SniTray> { match item { SpecItem::Item { @@ -154,9 +114,6 @@ fn translate(item: SpecItem) -> ksni::MenuItem<SniTray> { avatar, } => ksni::menu::StandardItem { label, - // Agent rows carry the brand avatar (disc + white mark + status - // dot) as PNG bytes — dbusmenu's icon-data. A failed render just - // leaves the row text-only. icon_data: avatar .and_then(|(agent, status)| icon::agent_avatar(agent, status)) .and_then(|pm| pm.encode_png().ok()) diff --git a/src/ui/tree_sync.rs b/src/ui/tree_sync.rs index 284d5f5c..52f165dc 100644 --- a/src/ui/tree_sync.rs +++ b/src/ui/tree_sync.rs @@ -1,51 +1,3 @@ -//! The write half of the client-side tree migration: every structural change a -//! window makes becomes **semantic operations** on the daemon-owned machine -//! tree, instead of a whole-layout write to a file. -//! -//! # Why a mirror-and-diff rather than ops at every call site -//! -//! `Tty7App::save_session` is already the single point every structural change -//! funnels through — twenty-odd call sites, each of which knows *that* -//! something changed but expresses it by handing over the whole tab list. This -//! module keeps that funnel: it holds, per window, a **mirror** of what the -//! daemon's tree looked like after the last acknowledged operation, and each -//! sync diffs the window's current state against it. Because consecutive syncs -//! differ by exactly one user action, the diff *recovers* that action — a -//! split diffs to one `PaneSplit`, a closed tab to one `TabClose` — without -//! twenty call sites each hand-rolling its own op sequence (and each being a -//! chance to get one wrong). Multi-step changes ("close other tabs") fall out -//! as the op sequence they are. -//! -//! The mirror is updated by running **the server's own tree surgery** -//! ([`PaneNode::split_leaf`] and friends are public for exactly this), so the -//! predicted post-state cannot drift from what the daemon will hold. -//! -//! # What happens when prediction and reality disagree -//! -//! Any failed operation — a refused edit, a dropped link — invalidates the -//! mirror instead of trying to patch around it: the queue is dropped, the tree -//! is re-pulled (`WorkspaceTree`), and the next diff against the *authoritative* -//! state re-emits exactly the edits that still matter. Reconciliation by -//! re-pull is the one recovery path, shared by every failure mode, which is -//! why none of them needs code of its own. -//! -//! # Panes that do not exist yet -//! -//! A leaf whose pane is still connecting (a fresh spawn with no daemon id) is -//! **invisible** to the tree until it lands: the daemon's leaves hold pane ids -//! and nothing else, so there is nothing to say yet. `land_pane`'s save is the -//! moment the id exists, and the diff then emits the `TabCreate` / `PaneSplit` -//! the earlier saves could not. A connecting leaf that is *re-attaching* to a -//! known pane id is representable all along. -//! -//! # One id space oddity -//! -//! Operations name the workspace by the **machine's** id. For a local window -//! that is the client's own [`WorkspaceId`]; for a remote one it is -//! `RemoteRef::workspace` — the id minted on that machine — while the client's -//! entry keeps its own id for the window registry. [`tree_workspace_id`] is the -//! one translation point. - use std::collections::{HashMap, VecDeque}; use std::io; use std::sync::Arc; @@ -62,15 +14,6 @@ use crate::core::session::{Session, SessionPane, SessionTab, WorkspaceId, Worksp use crate::ui::app::Tty7App; use crate::ui::pane::{Pane, PaneSlot}; -/// The control link to `host`'s daemon, if one is up right now. -/// -/// The unification the whole design leans on: the local machine's link lives in -/// [`LocalLink`](crate::ui::local_link::LocalLink), a remote machine's in -/// [`HostLinks`](crate::ui::remote_connect::HostLinks), and -/// everything above this function stops caring which. `None` is always -/// transient (both holders have supervisors reconnecting), so callers treat it -/// as "not now": mark dirty and let the re-pull that follows reconnection -/// resend what still matters. pub(crate) fn control_for(cx: &mut App, host: HostId) -> Option<Arc<ControlClient>> { if host.is_local() { crate::ui::local_link::LocalLink::client(cx) @@ -81,16 +24,6 @@ pub(crate) fn control_for(cx: &mut App, host: HostId) -> Option<Arc<ControlClien } } -/// The control link to `host`, seen by a caller about to speak the tree verbs. -/// -/// [`TreeLink::Unserved`] is the difference from [`control_for`]'s plain -/// `None`: the peer is connected but does not advertise -/// [`feature::MACHINE_TREE`](tty7_core::daemon::control::feature::MACHINE_TREE) -/// — a server with no home directory to keep a tree in, or one predating the -/// verbs. "Down" is transient and retried; "unserved" is a fact about the -/// peer, and sending it tree verbs anyway would only trade this one clear -/// state for a refusal (or, on an old enough peer, a decode failure) per -/// operation. pub(crate) enum TreeLink { Ready(Arc<ControlClient>), Unserved, @@ -101,8 +34,6 @@ pub(crate) fn tree_control_for(cx: &mut App, host: HostId) -> TreeLink { classify_tree_link(control_for(cx, host)) } -/// The judgement half of [`tree_control_for`]: what the handshake's -/// capability bits say this link is good for. fn classify_tree_link(client: Option<Arc<ControlClient>>) -> TreeLink { match client { Some(client) @@ -117,7 +48,6 @@ fn classify_tree_link(client: Option<Arc<ControlClient>>) -> TreeLink { } } -/// The machine-side id operations about this window's workspace must carry. fn tree_workspace_id(cx: &App, client_ws: WorkspaceId) -> WorkspaceId { WorkspaceStore::all(cx) .get(client_ws) @@ -126,11 +56,6 @@ fn tree_workspace_id(cx: &App, client_ws: WorkspaceId) -> WorkspaceId { .unwrap_or(client_ws) } -// --------------------------------------------------------------------------- -// The desired tree: what the window currently shows, in the daemon's shape -// --------------------------------------------------------------------------- - -/// One tab as the window wants the daemon to hold it. #[derive(Debug, Clone)] pub(crate) struct DesiredTab { pub id: TabId, @@ -139,8 +64,6 @@ pub(crate) struct DesiredTab { pub root: DesiredNode, } -/// A pane tree whose leaves carry the [`PaneSeed`] that introduces them, so an -/// op that first mentions a pane has its birth certificate in hand. #[derive(Debug, Clone)] pub(crate) enum DesiredNode { Leaf { @@ -156,8 +79,6 @@ pub(crate) enum DesiredNode { } impl DesiredNode { - /// The first (top/left-most) leaf — the anchor every split materializes - /// around. fn first_leaf(&self) -> (&u64, &PaneSeed) { match self { DesiredNode::Leaf { pane, seed } => (pane, seed), @@ -165,7 +86,6 @@ impl DesiredNode { } } - /// The plain tree shape, for comparing against a mirror tab's root. fn to_pane_node(&self) -> PaneNode { match self { DesiredNode::Leaf { pane, .. } => PaneNode::Leaf { pane: *pane }, @@ -178,7 +98,6 @@ impl DesiredNode { } } - /// The seed of the leaf holding `pane`. fn seed_of(&self, pane: u64) -> Option<&PaneSeed> { match self { DesiredNode::Leaf { pane: p, seed } => (*p == pane).then_some(seed), @@ -187,19 +106,6 @@ impl DesiredNode { } } -/// Read the window's tabs into the daemon's shape. Tabs with nothing -/// representable yet (every pane still spawning) are omitted from the desired -/// list — but their identities are answered separately as *held*: the tab is -/// occupied, its panes just have no ids yet, and a diff that read its absence -/// as "closed" would delete the daemon tab (and spend the very records) a -/// revival in flight is about to replace. -/// -/// Held is strictly for the *transient* case. A remote window's tab that is -/// native-SSH through and through is unrepresentable **forever** — its panes -/// live in this client's daemon — and is neither desired nor held: as far as -/// this machine's tree is concerned, it does not exist. Holding it instead -/// would freeze the whole window's ordering and active-tab sync permanently, -/// because [`diff`] waits out held tabs before touching either. pub(crate) fn desired_tabs( app: &Tty7App, cx: &App, @@ -212,13 +118,6 @@ pub(crate) fn desired_tabs( let mut held = Vec::new(); for (index, tab) in app.tabs.iter().enumerate() { let Some(root) = desired_node(&tab.pane, remote, cx) else { - // No root means every leaf is individually unrepresentable. If - // even one of them is merely *pending* (a spawn or an empty slot - // still to fill), the tab is held; a pure native-SSH tab is - // permanently invisible instead. The distinction also lets a - // mixed tab whose last tree-visible pane was closed fall out of - // `desired` entirely, so a Full diff closes its daemon tab - // rather than leaving a dead leaf on the machine for ever. if !(remote && every_leaf_is_native_ssh(&tab.pane, cx)) { held.push(tab.tree_id.get()); } @@ -242,12 +141,6 @@ pub(crate) fn desired_tabs( (out, active, held) } -/// Whether every leaf of `pane` is a *ready* native-SSH view — the one kind -/// of leaf a remote window can never name in its machine's tree, because the -/// pane lives in this client's own daemon. Only meaningful for a tab whose -/// desired root came out `None`: it decides permanently-invisible versus -/// held (see [`desired_tabs`]). A connecting or empty leaf answers `false` — -/// those are pending, not foreign. fn every_leaf_is_native_ssh(pane: &Pane, cx: &App) -> bool { match pane { Pane::Leaf(PaneSlot::Ready(view)) => view.read(cx).ssh_spec().is_some(), @@ -258,10 +151,6 @@ fn every_leaf_is_native_ssh(pane: &Pane, cx: &App) -> bool { } } -/// One GUI pane node, in tree shape. `None` for the unrepresentable: a fresh -/// spawn with no pane id yet, and — in a remote window — a native-SSH leaf, -/// whose pane lives in *this* client's daemon and so cannot be named in the -/// remote machine's tree (its id would collide with an unrelated pane there). fn desired_node(pane: &Pane, remote_window: bool, cx: &App) -> Option<DesiredNode> { match pane { Pane::Leaf(PaneSlot::Ready(view)) => { @@ -328,8 +217,6 @@ fn desired_node(pane: &Pane, remote_window: bool, cx: &App) -> Option<DesiredNod a: Box::new(a), b: Box::new(b), }), - // One side has nothing to say yet: the other stands where the - // split will be, exactly as the daemon would collapse it. (one, other) => one.or(other), } } @@ -337,34 +224,15 @@ fn desired_node(pane: &Pane, remote_window: bool, cx: &App) -> Option<DesiredNod } } -// --------------------------------------------------------------------------- -// The mirror, and the diff that recovers operations from it -// --------------------------------------------------------------------------- - -/// What the daemon's copy of this workspace looked like after the last -/// operation this window sent (or the last pull). #[derive(Debug, Clone, Default, PartialEq)] pub(crate) struct WsMirror { pub tabs: Vec<TreeTab>, pub active: Option<TabId>, } -/// Diff the window's desired state against the mirror, answering the operation -/// sequence that turns one into the other — and advancing the mirror to the -/// predicted post-state as it goes. -/// -/// `workspace` is the machine-side id the ops carry. -/// How much of the tree a window's diff may claim to speak for. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub(crate) enum SyncScope { - /// The window has seen the tree (it was hydrated from it, or the tree was - /// empty when it primed): its state is the whole story, and tabs it does - /// not show are tabs to close. Full, - /// The window has **not** seen the tree — it opened empty ahead of a pull - /// that has not landed (or was skipped). Its tabs are additions and edits, - /// never evidence of absence: a diff that closed tree tabs such a window - /// simply never displayed would eat another session's layout. Additive, } @@ -378,11 +246,6 @@ pub(crate) fn diff( ) -> Vec<ControlRequest> { let mut ops = Vec::new(); - // Tabs that are gone. Position by position so the active-tab heal below - // sees the same intermediate states the server will. Only a window that - // has seen the tree may prune — see [`SyncScope`] — and a *held* tab (its - // panes are mid-spawn, so it is invisible in `desired` without being - // absent) is never pruned. if scope == SyncScope::Full { let mut index = 0; while index < mirror.tabs.len() { @@ -400,9 +263,6 @@ pub(crate) fn diff( } } - // New tabs and per-tab reconciliation, in the window's order. An additive - // window appends its new tabs rather than claiming positions among tabs it - // has never seen. for (index, want) in desired.iter().enumerate() { match mirror.tabs.iter().position(|t| t.id == want.id) { None => { @@ -416,15 +276,10 @@ pub(crate) fn diff( } } - // With any tab held, positions are ambiguous (a held tab occupies a slot - // the desired list cannot see), so ordering and activation wait for the - // save that follows the spawns landing. if scope == SyncScope::Additive || !held.is_empty() { return ops; } - // Order: fix each position left to right. The tab moved is always to the - // right of the slot it moves into, so earlier fixes stay fixed. for (index, want) in desired.iter().enumerate() { let at = mirror .tabs @@ -442,7 +297,6 @@ pub(crate) fn diff( } } - // Which tab is active. if let Some(active) = desired_active && mirror.active != Some(active) && mirror.tabs.iter().any(|t| t.id == active) @@ -457,9 +311,6 @@ pub(crate) fn diff( ops } -/// The server's active-tab heal, replayed on the mirror: after the tab at -/// `removed` left, a dangling active id re-points to the neighbour that slid -/// into its place (or the new last tab). fn heal_active(mirror: &mut WsMirror, removed: usize) { let named = mirror .active @@ -474,8 +325,6 @@ fn heal_active(mirror: &mut WsMirror, removed: usize) { mirror.active = Some(mirror.tabs[removed.min(mirror.tabs.len() - 1)].id); } -/// Emit the ops that create `want` whole: `TabCreate` anchored on its first -/// leaf, then one `PaneSplit` per split, then the labels. fn create_tab( workspace: WorkspaceId, mirror: &mut WsMirror, @@ -515,14 +364,9 @@ fn create_tab( root, }, ); - // A created tab is active on the server; the final active pass corrects - // this when the window says otherwise. mirror.active = Some(want.id); } -/// Turn the single leaf standing where `want` goes into `want`'s whole split -/// structure, top split first — each split replaces the leaf that anchors its -/// left side, exactly as the server's `split_leaf` will. fn materialize_splits( workspace: WorkspaceId, want: &DesiredNode, @@ -547,9 +391,6 @@ fn materialize_splits( materialize_splits(workspace, b, root, ops); } -/// Bring one existing tab in line: labels field by field, then the pane tree — -/// by the smallest op that explains the change, or by rebuilding the tab when -/// no single op does (a swap, a multi-pane rearrangement). fn reconcile_tab( workspace: WorkspaceId, mirror: &mut WsMirror, @@ -606,9 +447,7 @@ fn reconcile_tab( .collect(); let done = match (added.as_slice(), removed.as_slice()) { - // One pane appeared: a split, if it reads as one. ([new], []) => try_single_split(workspace, mirror, at, want, &desired_root, *new, ops), - // Panes left: close each, then check the shape agrees. ([], gone) if !gone.is_empty() => { for pane in gone { mirror.tabs[at].root.remove_leaf(*pane); @@ -619,7 +458,6 @@ fn reconcile_tab( } same_shape_and_panes(&mirror.tabs[at].root, &desired_root) } - // One pane became another in place: the revival's rebind. ([new], [old]) => { let elsewhere = mirror .tabs @@ -659,10 +497,6 @@ fn reconcile_tab( return; } - // Nothing smaller explains it (a swap, several panes moved at once): - // rebuild the tab whole. The server broadcasts the same class of change as - // one `TabClosed` + `TabCreated`+splits, which mirroring clients apply by - // replacement — the granularity the delta contract already promises. let closed = mirror.tabs.remove(at); ops.push(ControlRequest::TabClose { workspace, @@ -672,10 +506,6 @@ fn reconcile_tab( create_tab(workspace, mirror, at, want, ops); } -/// One added leaf, read as the split it was: find it in the desired tree, -/// check its sibling side is a leaf the mirror already holds, and check that -/// the tree minus the new leaf is the tree the mirror has. Emits the -/// `PaneSplit` and answers whether it took. fn try_single_split( workspace: WorkspaceId, mirror: &mut WsMirror, @@ -712,10 +542,6 @@ fn try_single_split( true } -/// Where `new` sits in `node`: the sibling **leaf** it split off from, with the -/// split's parameters. `None` when the sibling side is itself a split — the -/// server's `split_leaf` can only split a leaf, so that shape did not come from -/// one split and the caller falls back to a rebuild. fn split_site(node: &PaneNode, new: u64) -> Option<(u64, TreeAxis, f32, bool)> { let PaneNode::Split { axis, ratio, a, b } = node else { return None; @@ -744,7 +570,6 @@ fn split_site(node: &PaneNode, new: u64) -> Option<(u64, TreeAxis, f32, bool)> { } } -/// Same structure and the same pane at every position, ratios ignored. fn same_shape_and_panes(a: &PaneNode, b: &PaneNode) -> bool { match (a, b) { (PaneNode::Leaf { pane: pa }, PaneNode::Leaf { pane: pb }) => pa == pb, @@ -766,8 +591,6 @@ fn same_shape_and_panes(a: &PaneNode, b: &PaneNode) -> bool { } } -/// Walk two same-shaped trees and emit a `PaneSetRatio` per split whose -/// divider moved, updating the mirror side in place. fn fix_ratios( workspace: WorkspaceId, tab: TabId, @@ -820,39 +643,16 @@ fn fix_ratios( walk(workspace, tab, mirror, desired, &mut path, ops); } -// --------------------------------------------------------------------------- -// Per-window state, priming, and the op queue -// --------------------------------------------------------------------------- - -/// Where one window's sync stands. enum SyncPhase { - /// No trustworthy mirror. `dirty` records that the window has state worth - /// pushing once one arrives; `priming` that a pull is in flight. - Unprimed { - dirty: bool, - priming: bool, - }, + Unprimed { dirty: bool, priming: bool }, Primed(WsMirror), } struct WsState { sync: SyncPhase, - /// Operations accepted but not yet sent. Drained strictly in order by one - /// in-flight sender at a time — the ops are a serial narrative, and two - /// senders would let a later op overtake the edit it builds on. queue: VecDeque<ControlRequest>, inflight: bool, - /// Whether this window has *seen* the tree — hydrated from it, primed - /// against an empty one, or deliberately declared authoritative (a - /// restore-off open). Until then its diffs run [`SyncScope::Additive`]: - /// a window that opened empty ahead of its pull must not read its own - /// emptiness as "close everything". informed: bool, - /// Which prime/hydrate cycle the pulls in flight belong to. Bumped by - /// every path that invalidates the mirror (a hydration start, a desync, a - /// preemption); a pull landing under a different number is a pull whose - /// question is obsolete, and its answer is dropped rather than allowed to - /// roll a mirror that has since advanced back to older state. epoch: u64, } @@ -871,7 +671,6 @@ impl Default for WsState { } } -/// Every window's sync state, by the *client's* workspace id. #[derive(Default)] pub(crate) struct TreeSync { windows: HashMap<WorkspaceId, WsState>, @@ -879,21 +678,11 @@ pub(crate) struct TreeSync { impl Global for TreeSync {} -/// Push this window's current structure to its machine's tree. The single -/// entry point, called from `save_session` — i.e. from every structural change. pub(crate) fn sync_window(app: &Tty7App, cx: &mut App) { let client_ws = app.workspace; - // A window built outside the store (headless tests) has no machine to talk - // to; skipping keeps those windows byte-for-byte what they were. if !cx.has_global::<crate::core::session::WorkspaceStore>() { return; } - // A preempted window is read-only, and that has to hold on the write path - // too: a click on its tab strip would flip the usurper's active tab, and — - // worse — its next save would Full-diff the pre-takeover layout against - // the mirror and roll the usurper's edits back wholesale. Its sync state - // was dropped at preemption ([`on_preempted`]); taking the workspace back - // re-pulls the tree whole. if crate::ui::remote_workspace::workspace_is_preempted(cx, client_ws) { return; } @@ -924,8 +713,6 @@ pub(crate) fn sync_window(app: &Tty7App, cx: &mut App) { if !ops.is_empty() { let (tabs, active) = (mirror.tabs.clone(), mirror.active); state.queue.extend(ops); - // Origin exclusion means this client never hears these ops - // back, so the machine-wide mirror learns them here. let host = WorkspaceStore::host_of(cx, client_ws); crate::ui::machine_mirror::MachineMirrors::note_synced_workspace( cx, host, machine_ws, tabs, active, @@ -936,17 +723,6 @@ pub(crate) fn sync_window(app: &Tty7App, cx: &mut App) { } } -/// A control link to `host` just came up (or came back): re-run the sync for -/// every window bound to that machine. -/// -/// This is the retry [`start_prime`]'s unreachable arm leaves behind. A window -/// built while the link was still dialing parks as `Unprimed { dirty }`, and -/// the only other thing that re-enters [`sync_window`] is the *next* -/// structural change — on a first launch that may never come, and a quit -/// before it comes loses the window's layout (the machine never heard of it). -/// The link supervisor calling this on connect is what turns "the reconnect -/// gets there first" from a hope into a mechanism. Harmless for windows that -/// are already synced: their diff is empty and queues nothing. pub(crate) fn on_link_up(cx: &mut App, host: HostId) { for (workspace, app) in crate::ui::windows::WindowRegistry::open_windows(cx) { if WorkspaceStore::host_of(cx, workspace) != host { @@ -958,20 +734,12 @@ pub(crate) fn on_link_up(cx: &mut App, host: HostId) { } } -/// Whether `client_ws`'s window has seen its machine's tree (or was declared -/// authoritative). The gate for destructive acts an *empty* window licenses — -/// a window whose hydration has not answered is empty because it is waiting, -/// not because the workspace is, and deleting the workspace on the strength of -/// that emptiness would take a populated tree with it. pub(crate) fn window_is_informed(cx: &App, client_ws: WorkspaceId) -> bool { cx.try_global::<TreeSync>() .and_then(|t| t.windows.get(&client_ws)) .is_some_and(|s| s.informed) } -/// Declare that `client_ws`'s window speaks for the whole tree from here on — -/// the deliberate cases (a restore-off open, a window rebuilt from a source -/// the user chose) where the window's state *is* the intended layout. pub(crate) fn mark_window_informed(cx: &mut App, client_ws: WorkspaceId) { cx.default_global::<TreeSync>() .windows @@ -980,10 +748,6 @@ pub(crate) fn mark_window_informed(cx: &mut App, client_ws: WorkspaceId) { .informed = true; } -/// Give GUI tabs that don't yet know their tree identity the mirror's, matched -/// by the panes they hold. This is what keeps a window whose tabs were built -/// before the tree was pulled (any full rebuild) from closing and recreating -/// every daemon tab it already matches. fn adopt_tab_ids(app: &Tty7App, cx: &App) { let Some(TreeSync { windows }) = cx.try_global::<TreeSync>() else { return; @@ -1021,15 +785,6 @@ fn adopt_tab_ids(app: &Tty7App, cx: &App) { } } -/// The workspace was just taken over by another client: drop everything this -/// window's sync believed. -/// -/// The queue and mirror go because they describe edits the usurper is about -/// to invalidate; `informed` goes because it is the licence to prune, and a -/// preempted window's next diff (after take-back re-primes it) must start -/// additive — its stale layout is *not* the whole story any more. Leaving -/// `informed` set was how a taken-back window's first save could still roll -/// the other client's work away. pub(crate) fn on_preempted(cx: &mut App, client_ws: WorkspaceId) { let Some(state) = cx.default_global::<TreeSync>().windows.get_mut(&client_ws) else { return; @@ -1040,12 +795,9 @@ pub(crate) fn on_preempted(cx: &mut App, client_ws: WorkspaceId) { }; state.queue.clear(); state.informed = false; - // …and any pull in flight was asked on the lost session's behalf. state.epoch += 1; } -/// Drop a window's sync state — its window is closing or rebinding. The -/// machine's tree keeps the workspace; only this client's bookkeeping goes. pub(crate) fn forget(cx: &mut App, client_ws: WorkspaceId) { if let Some(state) = cx.try_global::<TreeSync>() { let _ = state; @@ -1053,16 +805,6 @@ pub(crate) fn forget(cx: &mut App, client_ws: WorkspaceId) { } } -/// Fire one workspace-level operation (rename, touch, remove) at the machine -/// that owns `client_ws`'s tree. Fire-and-forget: these ops are idempotent -/// label writes with no ordering relationship to the structural queue. -/// -/// Unsent is not the same for all of them, which is what -/// [`unsendable`] is about: a rename or a touch that misses -/// its machine is a cosmetic loss the next one supersedes, while a -/// `WorkspaceRemove` that misses it leaves the workspace — and, after the -/// caller's kills, a set of dead leaves — on a machine no picker here lists any -/// more. That one gets said out loud. pub(crate) fn fire_workspace_op( cx: &mut App, client_ws: WorkspaceId, @@ -1074,8 +816,6 @@ pub(crate) fn fire_workspace_op( let host = WorkspaceStore::host_of(cx, client_ws); let machine_ws = tree_workspace_id(cx, client_ws); let request = op(machine_ws); - // The op will not echo back to this client (origin exclusion), so the - // machine-wide mirror folds it in here. crate::ui::machine_mirror::MachineMirrors::note_workspace_op(cx, host, &request); let client = match tree_control_for(cx, host) { TreeLink::Ready(client) => client, @@ -1100,12 +840,6 @@ pub(crate) fn fire_workspace_op( .detach(); } -/// Report a workspace operation that did not reach its machine, at the volume -/// its consequences deserve. -/// -/// A dropped `WorkspaceRemove` is the one with a lasting cost: this client has -/// already forgotten the workspace, so nothing here will ever name it again, and -/// the machine keeps it. Everything else is a label that the next edit resends. fn unsendable(request: &ControlRequest, why: &str) { match request { ControlRequest::WorkspaceRemove { workspace } => log::warn!( @@ -1117,10 +851,6 @@ fn unsendable(request: &ControlRequest, why: &str) { } } -/// Set (or clear, with `None`) a workspace's user-chosen name. The name is -/// purely the machine's fact now — its tree is what every picker lists this -/// workspace from — so a rename is one fire-and-forget operation, and the -/// machine-wide mirror picks it up on the way out. pub(crate) fn rename_workspace(cx: &mut App, client_ws: WorkspaceId, name: Option<String>) { fire_workspace_op(cx, client_ws, move |ws| ControlRequest::WorkspaceRename { workspace: ws, @@ -1128,17 +858,11 @@ pub(crate) fn rename_workspace(cx: &mut App, client_ws: WorkspaceId, name: Optio }); } -/// Pull the authoritative tree for this workspace (creating it on the machine -/// when it has none), then land it as the mirror. fn start_prime(cx: &mut App, client_ws: WorkspaceId) { let host = WorkspaceStore::host_of(cx, client_ws); let machine_ws = tree_workspace_id(cx, client_ws); let client = match tree_control_for(cx, host) { TreeLink::Ready(client) => client, - // Not reachable right now (or reachable but tree-less). Stay dirty; - // the next save retries, and a reconnect-triggered save is what - // usually gets there first. An unserved peer just keeps answering - // this way — the window works locally and nothing round-trips. unavailable => { if matches!(unavailable, TreeLink::Unserved) { log::warn!( @@ -1170,10 +894,6 @@ fn start_prime(cx: &mut App, client_ws: WorkspaceId) { .detach(); } -/// The blocking half of priming: the workspace's tree, or — when the machine -/// has never heard of it — the freshly created empty workspace. Created -/// nameless: the client keeps no name of its own any more, and the machine -/// derives a display name from the tabs the sync is about to send. fn pull_or_create(client: &ControlClient, machine_ws: WorkspaceId) -> io::Result<WsMirror> { match client.call(ControlRequest::WorkspaceTree { workspace: machine_ws, @@ -1204,16 +924,9 @@ fn pull_or_create(client: &ControlClient, machine_ws: WorkspaceId) -> io::Result } fn finish_prime(cx: &mut App, client_ws: WorkspaceId, epoch: u64, outcome: io::Result<WsMirror>) { - // `get_mut`, never `entry`: a window forgotten while the pull was in - // flight must not be resurrected as orphaned bookkeeping. let Some(state) = cx.default_global::<TreeSync>().windows.get_mut(&client_ws) else { return; }; - // Land only into the cycle that asked. A pull outlived by a hydration, a - // desync or a preemption (different epoch) — or by anything that already - // primed the mirror and let it advance — must be dropped, not installed: - // installing would roll the mirror back to the older tree and the next - // diff would faithfully re-emit the rollback as operations. if state.epoch != epoch || !matches!(state.sync, SyncPhase::Unprimed { priming: true, .. }) { log::debug!("workspace {client_ws}: dropping a superseded tree pull"); return; @@ -1221,8 +934,6 @@ fn finish_prime(cx: &mut App, client_ws: WorkspaceId, epoch: u64, outcome: io::R let was_dirty = matches!(state.sync, SyncPhase::Unprimed { dirty: true, .. }); let landed = match outcome { Ok(mirror) => { - // An empty tree has nothing an uninformed window could wrongly - // prune, so priming against one is as good as having seen it. state.informed |= mirror.tabs.is_empty(); let landed = (mirror.tabs.clone(), mirror.active); state.sync = SyncPhase::Primed(mirror); @@ -1237,8 +948,6 @@ fn finish_prime(cx: &mut App, client_ws: WorkspaceId, epoch: u64, outcome: io::R return; } }; - // The pull may have created the workspace on the machine, which this - // client (the writer) hears no delta for. let host = WorkspaceStore::host_of(cx, client_ws); let machine_ws = tree_workspace_id(cx, client_ws); crate::ui::machine_mirror::MachineMirrors::note_synced_workspace( @@ -1247,7 +956,6 @@ fn finish_prime(cx: &mut App, client_ws: WorkspaceId, epoch: u64, outcome: io::R if !was_dirty { return; } - // The window changed while the pull was in flight; diff it now. let Some(app) = crate::ui::windows::WindowRegistry::app_for(cx, client_ws).and_then(|app| app.upgrade()) else { @@ -1256,7 +964,6 @@ fn finish_prime(cx: &mut App, client_ws: WorkspaceId, epoch: u64, outcome: io::R app.update(cx, |app, cx| sync_window(app, cx)); } -/// Send everything queued, in order, one batch in flight at a time. fn pump(cx: &mut App, client_ws: WorkspaceId) { let host = WorkspaceStore::host_of(cx, client_ws); let client = tree_control_for(cx, host); @@ -1298,7 +1005,6 @@ fn pump(cx: &mut App, client_ws: WorkspaceId) { state.inflight = false; } match result { - // More may have queued behind this batch. Ok(()) => pump(cx, client_ws), Err((op, e)) => { log::warn!("tree operation {op:?} failed: {e}; re-pulling the tree"); @@ -1310,10 +1016,6 @@ fn pump(cx: &mut App, client_ws: WorkspaceId) { .detach(); } -/// Prediction and reality disagreed (or the link went): drop what was queued, -/// forget the mirror, and re-pull. The next diff against the fresh pull -/// re-emits exactly the edits that still matter — one recovery path for every -/// failure mode. fn desync(cx: &mut App, client_ws: WorkspaceId, why: &str) { log::info!("resynchronizing workspace {client_ws} with its machine ({why})"); let Some(state) = cx.default_global::<TreeSync>().windows.get_mut(&client_ws) else { @@ -1325,27 +1027,10 @@ fn desync(cx: &mut App, client_ws: WorkspaceId, why: &str) { dirty: true, priming: true, }; - // Older pulls in flight were asked against the mirror just discarded; - // bumping the epoch is what keeps their answers from landing over the - // re-pull this desync is about to start. state.epoch += 1; start_prime(cx, client_ws); } -// --------------------------------------------------------------------------- -// The read path: a window rebuilt from the machine's tree -// --------------------------------------------------------------------------- - -/// One workspace of a pulled [`Machine`], lowered into the `Session` shape the -/// window builder already consumes — the tree's leaves joined with their pane -/// registry records. -/// -/// The lowering *is* the revival decision, made per leaf by the daemon's own -/// liveness fact: a `live` pane keeps its id (the builder re-attaches), a dead -/// one lowers to an id-less leaf carrying the record's cwd, SSH spec and agent -/// resume — exactly the leaf shape that makes the builder spawn a successor. -/// The save that follows then diffs the successor's id against the mirror and -/// sends the `PaneReplace` that spends the old record. pub(crate) fn session_from_tree( ws: &tty7_core::core::machine::Workspace, panes: &[PaneRecord], @@ -1382,9 +1067,6 @@ fn session_pane_from_node(node: &PaneNode, panes: &[PaneRecord]) -> SessionPane }; SessionPane::Leaf { cwd, - // The daemon's liveness fact is the whole of the revival - // decision: an id is only worth keeping if the daemon holds a - // PTY for it *right now*. pane_id: live.then_some(*pane), ssh_spec, agent: agent.as_ref().map(|a| a.agent), @@ -1404,35 +1086,16 @@ fn session_pane_from_node(node: &PaneNode, panes: &[PaneRecord]) -> SessionPane } } -/// How long an opening window waits for its machine's link before giving up on -/// the pull and staying empty. Generous against a slow daemon start; the local -/// link is normally up within one supervision tick. const HYDRATE_LINK_DEADLINE: std::time::Duration = std::time::Duration::from_secs(15); const HYDRATE_LINK_POLL: std::time::Duration = std::time::Duration::from_millis(200); -/// Fill an (empty) window from the machine's tree: pull `MachineGet`, prime -/// the mirror with the workspace's tabs, and rebuild the window from them — -/// re-attaching live panes, spawning successors for dead ones. -/// -/// The window opens first and this runs behind it, because the pull is a round -/// trip that may have to wait out the link coming up; against the local daemon -/// it lands within milliseconds, so in practice the empty state is one frame. -/// -/// A workspace the machine has never heard of is created, empty. There is no -/// fallback source any more: the client keeps no layout of its own, so what -/// the machine answers is the layout. pub(crate) fn hydrate_window_from_tree(cx: &mut App, client_ws: WorkspaceId) { hydrate(cx, client_ws, Adopt::IfEmpty); } -/// What a finished pull may do to the window. #[derive(Clone, Copy, PartialEq)] enum Adopt { - /// Fill an empty window; a window with tabs wins over the pull (the user - /// got there first). The open/restore path. IfEmpty, - /// Replace the window's tabs with the pulled tree. The delta-fallback - /// resync, where the window is known to have drifted. Replace, } @@ -1449,21 +1112,11 @@ fn hydrate(cx: &mut App, client_ws: WorkspaceId, adopt: Adopt) { dirty: false, priming: true, }; - // Same contract as `desync`: anything queued was computed against a - // mirror this pull is about to replace, and letting it drain after - // the snapshot would silently diverge the server from it. state.queue.clear(); - // This hydration owns the cycle from here; older pulls still in - // flight land under the previous number and are dropped. state.epoch += 1; state.epoch }; cx.spawn(async move |cx| { - // At launch the link is usually still dialing; wait it out briefly - // rather than failing an open the supervisor will fix in a second. A - // peer that is up but does not serve the tree is not waited on at all - // — that answer will not change, and fifteen silent seconds would - // read as a hang rather than as the fact it is. let deadline = std::time::Instant::now() + HYDRATE_LINK_DEADLINE; let client = loop { match cx.update(|cx| tree_control_for(cx, host)) { @@ -1501,12 +1154,6 @@ fn hydrate(cx: &mut App, client_ws: WorkspaceId, adopt: Adopt) { .detach(); } -/// The blocking half: the whole machine (the tree plus the pane registry — -/// `WorkspaceTree` alone answers structure without the pane facts revival -/// needs), reduced to this workspace's mirror and session. A machine that has -/// no such workspace gets it created, empty. The machine rides along whole so -/// the caller can refresh the machine-wide mirror off a pull it already paid -/// for. fn pull_workspace( client: &ControlClient, machine_ws: WorkspaceId, @@ -1541,9 +1188,6 @@ fn finish_hydration( adopt: Adopt, outcome: io::Result<(Machine, WsMirror, Session)>, ) { - // A hydration superseded by a newer cycle (another hydration, a desync, a - // preemption) must land nothing — not the mirror, not the window, and not - // the failure bookkeeping, all of which belong to the newer cycle now. let current = cx .default_global::<TreeSync>() .windows @@ -1565,19 +1209,13 @@ fn finish_hydration( return; } }; - // The pull is a whole `MachineGet`; the machine-wide mirror gets it free. let host = WorkspaceStore::host_of(cx, client_ws); crate::ui::machine_mirror::MachineMirrors::install(cx, host, machine); let was_dirty = { - // `get_mut`, never `entry` — same reason as `finish_prime`. let Some(state) = cx.default_global::<TreeSync>().windows.get_mut(&client_ws) else { return; }; let dirty = matches!(state.sync, SyncPhase::Unprimed { dirty: true, .. }); - // An empty tree has nothing to adopt and nothing a window could - // wrongly prune, so the window is as informed as it will ever be. A - // non-empty tree informs the window only if the adopt below actually - // runs — see the IfEmpty return. state.informed |= mirror.tabs.is_empty(); state.sync = SyncPhase::Primed(mirror); dirty @@ -1588,17 +1226,11 @@ fn finish_hydration( return; }; if adopt == Adopt::IfEmpty && !app.read(cx).tabs.is_empty() { - // The user got there first (opened a tab into the empty window). Their - // tabs win — but they have never seen the tree's, so the window stays - // additive: its edits go up, tabs it never showed stay untouched. if was_dirty { app.update(cx, |app, cx| sync_window(app, cx)); } return; } - // An empty pull leaves an empty window empty — with the client's layout - // cache retired there is nothing to import, and the machine answering - // "no tabs" *is* the layout. if session.tabs.is_empty() && adopt == Adopt::IfEmpty { if was_dirty && let Some(app) = @@ -1615,8 +1247,6 @@ fn finish_hydration( "rebuilding {} tab(s) of workspace {client_ws} from its machine's tree", session.tabs.len() ); - // The window is about to display the tree (or the import that stands in - // for it); from here its diffs speak for the whole workspace. mark_window_informed(cx, client_ws); let _ = handle.update(cx, move |_, window, cx| { app.update(cx, |app, cx| { @@ -1625,27 +1255,8 @@ fn finish_hydration( }); } -// --------------------------------------------------------------------------- -// Incremental deltas: another writer edited a workspace this client shows -// --------------------------------------------------------------------------- - -/// Land one [`LayoutDelta`] pushed by a machine: advance this client's mirror, -/// then the live window showing the workspace, if any. -/// -/// The writer never hears its own operation back (origin exclusion), so every -/// delta arriving here is *another* client's edit — and because application -/// updates the window and the mirror in the same step, the next local diff -/// sees no difference and produces no echo. -/// -/// Anything that will not apply cleanly — a tab the mirror does not know, a -/// window whose state has drifted — falls back to a full re-pull of the -/// workspace and a rebuild, the same recovery every other failure uses. pub(crate) fn on_layout_delta(cx: &mut App, host: HostId, key: &str, delta: LayoutDelta) { - // The machine-wide mirror hears every delta, windowed workspace or not — - // it is what the picker and the menus read about workspaces no window - // shows. crate::ui::machine_mirror::MachineMirrors::apply_delta(cx, host, key, &delta); - // The event names the machine's workspace id; translate to the client's. let client_ws = if host.is_local() { key.parse::<WorkspaceId>().ok() } else { @@ -1663,11 +1274,6 @@ pub(crate) fn on_layout_delta(cx: &mut App, host: HostId, key: &str, delta: Layo return; }; - // A preempted window is read-only *and must stay passive*: applying a - // structural delta would attach to panes the usurping client just created - // — and one pane has one subscriber, so that steals the active client's - // streams as they work. The mirror goes stale instead, and taking the - // workspace back re-pulls it whole. if crate::ui::remote_workspace::workspace_is_preempted(cx, client_ws) { on_preempted(cx, client_ws); return; @@ -1680,13 +1286,6 @@ pub(crate) fn on_layout_delta(cx: &mut App, host: HostId, key: &str, delta: Layo .map(|s| &mut s.sync) { Some(SyncPhase::Primed(mirror)) => apply_to_mirror(mirror, &delta), - // No mirror yet: whatever pull is (or will be) in flight already - // answers with a state that includes this delta — so the *window* - // must not apply it either. A `TabCreated` landing in a window whose - // hydration is mid-flight would both duplicate the tab when the - // snapshot arrives and, worse, make `finish_hydration` read the - // no-longer-empty window as "the user got here first" and skip - // adopting the tree at all. _ => return, }; @@ -1710,22 +1309,11 @@ pub(crate) fn on_layout_delta(cx: &mut App, host: HostId, key: &str, delta: Layo resync_window_from_tree(cx, client_ws); return; } - // A clean apply may still have left the window ahead of the tree: adopting - // a tab whose pane was dead on arrival attaches nothing and spawns a fresh - // pane under a *new* id, and nothing else on this path saves — the tree - // would keep the dead leaf until the user's next structural change (and a - // relaunch would spawn a second successor beside the leaked first). One - // sync here is free when window and mirror agree (the diff is empty) and - // is exactly the `PaneReplace` that spends the dead record when they - // don't. app.update(cx, |app, cx| sync_window(app, cx)); } -/// Advance the mirror by one delta. `false` means the delta names state the -/// mirror does not have — the caller re-pulls. fn apply_to_mirror(mirror: &mut WsMirror, delta: &LayoutDelta) -> bool { match delta { - // Workspace-level facts carry no tab structure. LayoutDelta::WorkspaceCreated { .. } | LayoutDelta::WorkspaceRenamed { .. } | LayoutDelta::WorkspaceTouched { .. } @@ -1736,9 +1324,6 @@ fn apply_to_mirror(mirror: &mut WsMirror, delta: &LayoutDelta) -> bool { true } LayoutDelta::TabCreated { at, tab } => { - // A create that straddled a re-pull arrives after the snapshot - // that already carries its tab; replace-by-id, never insert a - // second copy (same rule as the machine-wide mirror's). mirror.tabs.retain(|t| t.id != tab.id); let at = (*at).min(mirror.tabs.len()); mirror.tabs.insert(at, tab.clone()); @@ -1750,8 +1335,6 @@ fn apply_to_mirror(mirror: &mut WsMirror, delta: &LayoutDelta) -> bool { if mirror.tabs.is_empty() { mirror.active = None; } - // The heal, when one happened, arrives as its own - // ActiveTabChanged — the server promises that. mirror.tabs.len() != before } LayoutDelta::TabRenamed { tab, name } => { @@ -1798,15 +1381,11 @@ fn apply_to_mirror(mirror: &mut WsMirror, delta: &LayoutDelta) -> bool { } } -/// Re-pull the workspace and rebuild its window from the result, replacing -/// whatever the window holds — the delta fallback. pub(crate) fn resync_window_from_tree(cx: &mut App, client_ws: WorkspaceId) { hydrate(cx, client_ws, Adopt::Replace); } impl Tty7App { - /// Apply one delta to this window. `false` when it cannot be applied - /// cleanly, in which case the caller re-pulls and rebuilds. pub(crate) fn apply_layout_delta( &mut self, delta: &LayoutDelta, @@ -1817,16 +1396,10 @@ impl Tty7App { tabs.iter().position(|t| t.tree_id.get() == id) }; let applied = match delta { - // Another client naming the workspace needs nothing from the - // window: the chip and the picker read the machine mirror, which - // already applied the delta. LayoutDelta::WorkspaceCreated { .. } | LayoutDelta::WorkspaceTouched { .. } | LayoutDelta::WorkspaceRenamed { .. } | LayoutDelta::PaneFacts { .. } => true, - // Deleting a workspace someone is looking at does not close their - // window — a window is never closed by remote control. The next - // structural edit here recreates the workspace on the machine. LayoutDelta::WorkspaceDeleted => { log::info!( "workspace {} was deleted on its machine; keeping the window", @@ -1838,8 +1411,6 @@ impl Tty7App { if let Some(index) = index_of(&self.tabs, *tab) { self.activate_from_delta(index, window, cx); } - // A tab this window doesn't hold yet: its TabCreated may be a - // spawn still in flight. Not worth a rebuild. true } LayoutDelta::TabCreated { at, tab } => { @@ -1847,10 +1418,6 @@ impl Tty7App { } LayoutDelta::TabClosed { tab } => { if let Some(index) = index_of(&self.tabs, *tab) { - // The panes' views go; the panes themselves were the - // closing client's to kill. The active tab is tracked by - // identity, or closing a tab to its left would silently - // shift focus one tab over. let active_id = self.tabs.get(self.active).map(|t| t.tree_id.get()); self.tabs.remove(index); self.active = active_id @@ -1887,13 +1454,10 @@ impl Tty7App { } true } - LayoutDelta::TabRestructured { tab, .. } => { - match index_of(&self.tabs, tab.id) { - Some(index) => self.rebuild_tab_from_tree(index, tab, window, cx), - // Restructure of a tab we never built — out of step. - None => false, - } - } + LayoutDelta::TabRestructured { tab, .. } => match index_of(&self.tabs, tab.id) { + Some(index) => self.rebuild_tab_from_tree(index, tab, window, cx), + None => false, + }, LayoutDelta::RatioChanged { tab, path, ratio } => { if let Some(index) = index_of(&self.tabs, *tab) { set_gui_ratio(&mut self.tabs[index].pane, path, *ratio) @@ -1906,8 +1470,6 @@ impl Tty7App { applied } - /// Activate a tab because a delta said so — the parts of `activate` that - /// move state, without the save that would echo the change back. fn activate_from_delta( &mut self, index: usize, @@ -1922,8 +1484,6 @@ impl Tty7App { self.focus_active(window, cx); } - /// Build one GUI tab from a tree tab whose panes are all live (they were - /// just created by the writer), attaching each by id. fn insert_tab_from_tree( &mut self, at: usize, @@ -1931,11 +1491,6 @@ impl Tty7App { window: &mut gpui::Window, cx: &mut gpui::Context<Self>, ) -> bool { - // Already shown: the delta straddled a pull whose snapshot carried - // this tab, and the rebuild path already displayed it. Building it - // again would not just duplicate the tab — attaching to panes this - // window already streams would steal their single subscription from - // ourselves. if self.tabs.iter().any(|t| t.tree_id.get() == tab.id) { return true; } @@ -1951,9 +1506,6 @@ impl Tty7App { true } - /// Rebuild one tab's pane tree to match the machine's, **reusing** the - /// views of panes the window already shows — re-attaching a pane this - /// window holds would steal its own stream (one pane, one subscriber). fn rebuild_tab_from_tree( &mut self, index: usize, @@ -1965,17 +1517,9 @@ impl Tty7App { .get(self.workspace) .is_some_and(|w| w.is_remote()); let mut existing: HashMap<u64, PaneSlot> = HashMap::new(); - // Native-SSH leaves in a remote window hold panes in *this* client's - // daemon: they are deliberately absent from the remote machine's tree - // (their ids would collide with unrelated panes there), so the tree - // this tab is rebuilt from cannot mention them. They are kept aside - // and appended back as splits below — dropping their views would - // orphan running local sessions the writer never touched. let mut ssh_slots: Vec<PaneSlot> = Vec::new(); for slot in self.tabs[index].pane.leaves() { let id = match &slot { - // Matching a native-SSH leaf's *local* id against remote ids - // would rebind the SSH view onto an unrelated remote pane. PaneSlot::Ready(view) if remote && view.read(cx).ssh_spec().is_some() => { ssh_slots.push(slot); continue; @@ -1990,9 +1534,6 @@ impl Tty7App { let Some(pane) = self.build_pane_from_tree(&tab.root, &mut existing, window, cx) else { return false; }; - // The ssh leaves' places in the old split geometry are unknowable from - // the delta (the tree never held them), so each comes back as a fresh - // half-and-half split on the right — the shape a split created it in. let pane = ssh_slots.into_iter().fold(pane, |tree, slot| { Pane::split_node(gpui::Axis::Horizontal, 0.5, tree, Pane::Leaf(slot)) }); @@ -2001,14 +1542,9 @@ impl Tty7App { gui.name = tab.name.clone(); *gui.sidebar_group.borrow_mut() = tab.sidebar_group.clone().map(std::path::PathBuf::from); self.maximized = None; - // Slots left in `existing` belonged to panes the writer removed; their - // views drop with the old tree, and killing the panes was the writer's - // act, not ours. true } - /// Lower a tree node into a GUI pane tree, taking views for known panes - /// from `existing` and attaching to unknown (writer-created) ones by id. fn build_pane_from_tree( &self, node: &PaneNode, @@ -2058,15 +1594,10 @@ impl Tty7App { } } -/// Follow `path` through the GUI tree and move that split's divider. fn set_gui_ratio(pane: &mut Pane, path: &[Side], ratio: f32) -> bool { match path.split_first() { None => match pane { Pane::Split { ratio: cell, .. } => { - // The same band the server accepts (`machine::clamp_ratio`). - // Clamping narrower here (0.1–0.9, as this once did) silently - // rewrote another client's 0.07 to 0.1 — and the next save's - // ratio diff then pushed that rewrite back at the machine. cell.set(ratio.clamp(0.05, 0.95)); true } @@ -2086,11 +1617,6 @@ fn set_gui_ratio(pane: &mut Pane, path: &[Side], ratio: f32) -> bool { mod tests { use super::*; - /// The tree verbs are gated on the handshake's `machine-tree` bit: a - /// connected peer that does not advertise it must classify as - /// [`TreeLink::Unserved`] — the callers' cue to say "this server does not - /// serve the tree" once, instead of paying a refused round trip per - /// operation against a server that will never answer differently. #[cfg(unix)] #[test] fn a_peer_without_the_machine_tree_bit_classifies_as_unserved() { @@ -2135,10 +1661,6 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } - /// Preemption must leave the window's sync with nothing to say: the - /// queued ops and the mirror describe a session that just lost the - /// workspace, and `informed` is the licence to prune — kept, it would let - /// the taken-back window's first Full diff roll the usurper's edits away. #[gpui::test] fn preemption_drops_the_mirror_the_queue_and_the_informed_licence( cx: &mut gpui::TestAppContext, @@ -2175,10 +1697,6 @@ mod tests { }); } - /// The GUI applies a `RatioChanged` delta in the same band the server - /// accepts (0.05–0.95). A narrower client-side clamp is not cosmetic: it - /// rewrites another client's ratio, and the next save's diff pushes the - /// rewrite back at the machine as an operation. #[test] fn a_ratio_delta_is_clamped_to_the_servers_band_not_a_narrower_one() { let mut pane = Pane::split_node(gpui::Axis::Horizontal, 0.5, Pane::Empty, Pane::Empty); @@ -2187,7 +1705,6 @@ mod tests { Pane::Split { ratio, .. } => assert_eq!(ratio.get(), 0.07), _ => unreachable!("built as a split"), } - // Out-of-band values still land clamped, exactly as the server would. assert!(set_gui_ratio(&mut pane, &[], 0.01)); match &pane { Pane::Split { ratio, .. } => assert_eq!(ratio.get(), 0.05), @@ -2195,9 +1712,6 @@ mod tests { } } - /// Same overlap as the machine-wide mirror's: a `TabCreated` that - /// straddled a re-pull arrives after the snapshot that already carries - /// its tab, and must land once. #[test] fn a_tab_created_delta_that_straddled_a_repull_lands_once_in_the_window_mirror() { let mut mirror = WsMirror::default(); @@ -2210,10 +1724,6 @@ mod tests { assert_eq!(mirror.tabs.len(), 1); } - /// A prime whose pull was outlived by a newer cycle (a hydration, a - /// desync, a preemption) must drop its answer: installing it would roll - /// the mirror back to older state, and the next diff would faithfully - /// re-emit the rollback as operations against the machine. #[gpui::test] fn a_superseded_prime_result_does_not_roll_the_mirror_back(cx: &mut gpui::TestAppContext) { cx.update(|cx| { @@ -2230,8 +1740,6 @@ mod tests { }; state.epoch }; - // A hydration supersedes the prime and lands a mirror that has - // since advanced by an op. let advanced = WsMirror { tabs: vec![TreeTab::leaf(7)], active: None, @@ -2292,9 +1800,6 @@ mod tests { } } - /// Apply `ops`' effect is already folded into the mirror by `diff`; this - /// asserts the mirror agrees with what the window wanted — the property - /// the whole scheme rests on. fn assert_converged(mirror: &WsMirror, desired: &[DesiredTab]) { assert_eq!(mirror.tabs.len(), desired.len()); for (m, d) in mirror.tabs.iter().zip(desired) { @@ -2589,7 +2094,6 @@ mod tests { &[], ); - // The two panes trade places: same panes, same shape, different order. let want = vec![tab(id, split(TreeAxis::Vertical, 0.5, leaf(2), leaf(1)))]; let ops = diff(ws, &mut mirror, &want, Some(id), SyncScope::Full, &[]); assert_eq!( @@ -2623,7 +2127,6 @@ mod tests { let ws = WorkspaceId::new(); let id = TabId::new(); let mut mirror = WsMirror::default(); - // ((1 | 2) over (3 | 4)) let want = vec![tab( id, split( @@ -2699,10 +2202,6 @@ mod tests { &[], ); - // The window's copy of the tab is mid-revival: every leaf is a spawn - // with no pane id yet, so the tab is invisible in `desired` — but it - // is *held*, not gone, and closing it would spend the very record the - // landing spawn's PaneReplace needs. let ops = diff(ws, &mut mirror, &[], None, SyncScope::Full, &[id]); assert_eq!(ops, Vec::new()); assert_eq!(mirror.tabs.len(), 1, "the daemon tab survives the wait"); @@ -2722,9 +2221,6 @@ mod tests { &[], ); - // A window that opened empty ahead of its pull and grew one fresh tab: - // its diff may add that tab, and must touch nothing else — reading its - // ignorance as "close everything" would eat another session's layout. let fresh = TabId::new(); let ops = diff( ws, @@ -2749,9 +2245,6 @@ mod tests { #[test] fn deltas_advance_the_mirror_exactly_as_the_writers_operations_did() { - // Writer A's mirror advances through `diff`; watcher B's advances by - // applying the equivalent deltas. Both must land on the same tree — - // that equality is what lets B mirror A without re-implementing A. let ws = WorkspaceId::new(); let id = TabId::new(); let mut watcher = WsMirror::default(); @@ -2799,7 +2292,6 @@ mod tests { }, )); - // The writer's own mirror, advanced by the diff for the same edits. let mut writer = WsMirror::default(); diff( ws, @@ -2951,11 +2443,6 @@ mod tests { &[], ); - // Tab a now claims pane 2 (which tab b still holds) instead of pane 1 - // — a corrupt window state. `PaneReplace` would be refused by the - // server (pane 2 is elsewhere in the tree), so the diff must not - // choose it; the rebuild path handles it, and the server refusing - // *that* too (duplicate pane) desyncs into a fresh pull. let want = vec![tab(a, leaf(2)), tab(b, leaf(2))]; let ops = diff(ws, &mut mirror, &want, Some(b), SyncScope::Full, &[]); assert!( diff --git a/src/ui/windows.rs b/src/ui/windows.rs index 38dfdae3..fe79b5e9 100644 --- a/src/ui/windows.rs +++ b/src/ui/windows.rs @@ -1,20 +1,3 @@ -//! The app-level window registry, and the single place that opens a window. -//! -//! tty7 used to have exactly one window, so `main` opened it inline and every -//! app-wide duty (tray, menus, the quit hook) could live in `Tty7App`'s -//! constructor. With several windows those duties have to belong to the *app*, -//! and anything that acts on "a window" — a tray click, `New Workspace`, the quit -//! hook walking every open workspace — needs a way to find them. That is this -//! module. -//! -//! The registry maps each live window to the [`WorkspaceId`] it displays. -//! Windows are transient views; workspaces are the persistent identity -//! (`core::session`). Exactly one window shows a given workspace at a time — -//! the daemon gives each pane a single subscriber, so two windows attached to -//! one workspace would have the second silently steal the first's output. -//! [`open`] enforces that by focusing an already-open workspace instead of -//! opening a second window onto it. - use gpui::{ AnyWindowHandle, App, AppContext as _, BorrowAppContext as _, Bounds, Global, Styled as _, TitlebarOptions, WeakEntity, Window, WindowBounds, WindowOptions, point, px, size, @@ -26,23 +9,16 @@ use crate::core::session::{WorkspaceId, WorkspaceStore}; use crate::core::window_state::{WindowGeometry as _, WindowState}; use crate::ui::app::Tty7App; -/// How far each additional window is offset from the one before it, so a new -/// window never lands exactly on top of an existing one (logical px). const CASCADE_STEP: f32 = 28.0; -/// Default size for a window with nothing remembered. const DEFAULT_SIZE: (f32, f32) = (1440.0, 900.0); -/// One live window and what it is showing. struct WindowEntry { workspace: WorkspaceId, handle: AnyWindowHandle, - /// Weak so a closed window's entity can drop normally; a dead handle is - /// pruned on the next sweep rather than keeping the app alive. app: WeakEntity<Tty7App>, } -/// Every window tty7 currently has open. #[derive(Default)] pub struct WindowRegistry { windows: Vec<WindowEntry>, @@ -51,20 +27,15 @@ pub struct WindowRegistry { impl Global for WindowRegistry {} impl WindowRegistry { - /// Install the empty registry. Call once, before the first window opens. pub fn init(cx: &mut App) { cx.set_global(Self::default()); } - /// Number of live windows. Drives "is this the last window?" — the check - /// that decides whether closing one quits the app. pub fn count(cx: &mut App) -> usize { Self::sweep(cx); cx.global::<Self>().windows.len() } - /// The workspaces currently on screen, with the entity to read their tabs - /// from. Used by the quit hook to record every window's final state. pub fn open_windows(cx: &mut App) -> Vec<(WorkspaceId, WeakEntity<Tty7App>)> { Self::sweep(cx); cx.global::<Self>() @@ -74,7 +45,6 @@ impl WindowRegistry { .collect() } - /// The window showing `workspace`, if one is open. pub fn window_for(cx: &mut App, workspace: WorkspaceId) -> Option<AnyWindowHandle> { Self::sweep(cx); cx.global::<Self>() @@ -84,10 +54,6 @@ impl WindowRegistry { .map(|w| w.handle) } - /// The workspace of the most recently focused window — the sensible target - /// for an app-wide action (a tray click, "open Settings") that needs *a* - /// window but doesn't care which. Falls back to the first live window when - /// the store has no opinion. pub fn most_recent(cx: &mut App) -> Option<WorkspaceId> { Self::sweep(cx); let active = WorkspaceStore::all(cx).active; @@ -97,12 +63,6 @@ impl WindowRegistry { .or_else(|| registry.windows.first().map(|w| w.workspace)) } - /// The `Tty7App` rendered in `window`, if it is one of ours. - /// - /// For code that runs *inside* a window (an element's event handler) but - /// has no line to the app entity — the inverse lookup of - /// [`window_for`](Self::window_for), keyed by the handle instead of the - /// workspace. pub fn app_in(cx: &mut App, window: &Window) -> Option<gpui::Entity<Tty7App>> { Self::sweep(cx); let handle = window.window_handle(); @@ -113,7 +73,6 @@ impl WindowRegistry { .and_then(|w| w.app.upgrade()) } - /// The `Tty7App` showing `workspace`, if one is open. pub fn app_for(cx: &mut App, workspace: WorkspaceId) -> Option<WeakEntity<Tty7App>> { Self::sweep(cx); cx.global::<Self>() @@ -136,17 +95,12 @@ impl WindowRegistry { }); } - /// Forget a window. Idempotent — a window can be dropped by its own close - /// path and then swept again when its entity finally releases. pub fn unregister(cx: &mut App, workspace: WorkspaceId) { cx.global_mut::<Self>() .windows .retain(|w| w.workspace != workspace); } - /// Point an existing window at a different workspace, keeping its handle - /// and entity. Used when the picker swaps a window's contents in place - /// rather than opening a second window (see `Tty7App::switch_workspace`). pub fn rebind(cx: &mut App, from: WorkspaceId, to: WorkspaceId) { if let Some(entry) = cx .global_mut::<Self>() @@ -158,9 +112,6 @@ impl WindowRegistry { } } - /// Drop entries whose `Tty7App` entity is gone. Windows can close through - /// paths that never reach our own teardown (an OS-level close, a panic in a - /// sibling view), so every read prunes first rather than trusting the list. fn sweep(cx: &mut App) { let dead: Vec<WorkspaceId> = cx .global::<Self>() @@ -178,14 +129,6 @@ impl WindowRegistry { } } -/// Open a window on `workspace` — or on a brand-new workspace when `None`, -/// which starts with a single terminal. A known workspace opens empty and is -/// filled from its machine's tree. -/// -/// When that workspace already has a window, this focuses it instead of -/// opening a second one: two windows on one workspace would both attach the -/// same daemon panes, and the daemon's single-subscriber model means the -/// second attach silently kills the first window's terminal. pub fn open(cx: &mut App, workspace: Option<WorkspaceId>) { if let Some(id) = workspace && let Some(handle) = WindowRegistry::window_for(cx, id) @@ -195,16 +138,10 @@ pub fn open(cx: &mut App, workspace: Option<WorkspaceId>) { } let options = window_options(cx, workspace); - // The registry needs the window's `Tty7App`, but `open_window` hands back - // only the root view — so capture it on the way past. let mut created: Option<gpui::Entity<Tty7App>> = None; let opened = cx.open_window(options, |window, cx| { let app = cx.new(|cx| Tty7App::for_workspace(workspace, window, cx)); created = Some(app.clone()); - // Root's own background is fully transparent: `Tty7App`'s root div is - // the single owner of the window background (solid / gradient / image, - // with the theme's alpha). A second paint here would compound the alpha - // and read darker than the configured opacity. cx.new(|cx| Root::new(app, window, cx).bg(gpui::transparent_black())) }); @@ -220,36 +157,17 @@ pub fn open(cx: &mut App, workspace: Option<WorkspaceId>) { return; }; - // Read back the workspace the window actually claimed — passing `None` - // mints a fresh one, so the caller's id isn't authoritative. let id = app.read(cx).workspace; WindowRegistry::register(cx, id, handle.into(), app.downgrade()); refresh_menu(cx); } -/// Rebuild the menu bar so the Window menu reflects the current workspace set. -/// -/// macOS menus are static snapshots — nothing re-reads them when they open — -/// so every change to *which* workspaces exist has to push a new one. Called -/// on open / detach / switch / end, but deliberately not on ordinary tab edits: -/// a workspace's name comes from its repo and effectively never changes, so -/// rebuilding the whole menu bar per tab would be churn for nothing. pub fn refresh_menu(cx: &mut App) { crate::ui::theme::set_menus(cx); } -/// Most workspaces listed in the Window menu. Nine because that is how many -/// `SelectWorkspace1..9` actions exist — the same ceiling the tab shortcuts -/// use, and past which a flat menu stops being scannable anyway. pub const MENU_SLOTS: usize = 9; -/// The Window menu's ordering, shared by the menu builder and the actions that -/// index into it so slot *n* always means the same workspace in both. -/// -/// Open windows first (this is the macOS Window menu — its primary job is -/// listing what is on screen), then detached workspaces most-recent-first. That -/// second group is the whole point: a workspace closed with ⌘W has to be -/// visible *somewhere* or it may as well have been deleted. pub fn menu_order(cx: &App) -> Vec<(WorkspaceId, bool)> { let all = WorkspaceStore::all(cx); let mut open: Vec<_> = all.views.iter().filter(|w| w.open).collect(); @@ -263,59 +181,24 @@ pub fn menu_order(cx: &App) -> Vec<(WorkspaceId, bool)> { .collect() } -/// How many of a workspace's panes are still running on its own machine. -/// `Some(0)` means closing it destroys nothing — every shell already exited — -/// so the caller can skip the confirmation prompt. -/// -/// `None` is "the machine could not be asked", and it exists because the old -/// `0` conflated the two. A remote workspace whose link was down counted zero -/// live panes, and "Stop Workspace" then went through **without a prompt** and -/// killed sessions the user was never told about. Only a machine that answered -/// can license skipping the confirmation. -/// -/// Answered synchronously rather than from -/// [`pane_liveness`](crate::terminal::pane_liveness): the prompt states an exact -/// number about an irreversible action, so it wants a fresh count, not one that -/// may be ten seconds old. This runs on a click, not on a frame. -/// What [`live_pane_count`] needs from the app, gathered on the UI thread so the -/// count itself does not have to run there. pub struct PaneCountQuery { route: crate::terminal::PaneRoute, claimed: Vec<u64>, } -/// Read the inputs for [`live_pane_count`]. Cheap; UI thread only. -/// -/// `None` when the workspace's machine has never been pulled this session — -/// the ids to count live only in its tree, and a prompt about to state "N -/// running sessions will be ended" must say it could not ask rather than -/// count against a guess. pub fn pane_count_query(cx: &App, workspace: WorkspaceId) -> Option<PaneCountQuery> { let ws = WorkspaceStore::all(cx).get(workspace)?; Some(PaneCountQuery { - // Routed to the workspace's own machine: a remote workspace's pane ids - // mean nothing to this computer's daemon, so asking it would count - // whichever *local* panes happen to hold those numbers and put a "3 - // running sessions will be ended" warning on a workspace that has none. route: crate::ui::remote_workspace::pane_route_for(cx, workspace), claimed: crate::ui::machine_mirror::pane_ids(cx, ws)?, }) } -/// **Blocking. Never call this on the UI thread.** -/// -/// For a remote route this dials the workspace's machine — an SSH handshake if -/// nothing is pooled — and a WSL one can go as far as installing the server -/// binary. `guard_off_ui` makes a UI-thread call a debug-build abort rather -/// than a dropped frame, which is what it did when this was reached straight -/// from the Stop/Delete action handler. pub fn live_pane_count(q: &PaneCountQuery) -> Option<usize> { let PaneCountQuery { route, claimed } = q; if claimed.is_empty() { return Some(0); } - // One short-lived connection, only when there is something to ask about — - // the picker renders far more often than a workspace is closed. match crate::terminal::RemoteTerminal::try_list_panes_on(route) { Ok(panes) => { let alive: std::collections::HashSet<u64> = panes @@ -325,42 +208,21 @@ pub fn live_pane_count(q: &PaneCountQuery) -> Option<usize> { .collect(); Some(claimed.iter().filter(|id| alive.contains(id)).count()) } - // This machine is the one case where a refused `List` *is* an answer: - // the daemon lives at a known socket on the same box, and one that - // cannot be reached is one with nothing running in it. Keeping this - // arm is what makes a local workspace behave exactly as it did before - // any of this was routed. Err(_) if matches!(route, crate::terminal::PaneRoute::Local) => Some(0), Err(_) => None, } } -/// Confirm, then stop `workspace`. Skips the prompt when nothing is running — -/// there is nothing to lose and it would be pure friction. pub fn confirm_and_stop(cx: &mut App, window: &mut Window, workspace: WorkspaceId) { confirm_destructive(cx, window, workspace, "Stop", stop_workspace); } -/// Confirm, then delete `workspace`. Always asks: even with every shell -/// already exited, the saved layout is still something to lose. pub fn confirm_and_delete(cx: &mut App, window: &mut Window, workspace: WorkspaceId) { confirm_destructive(cx, window, workspace, "Delete", delete_workspace); } -/// What the confirmation prompt says below its title, given what -/// [`live_pane_count`] found. -/// -/// Split out of [`confirm_destructive`] because it is the one part of a -/// `window.prompt` path that can be tested: the three answers a liveness query -/// can give — a count, zero, and "could not ask" — each have to reach the user -/// as a different sentence, and the third one is new. Getting it wrong is not a -/// wording bug: it is telling somebody nothing will be lost right before ending -/// their sessions. fn destructive_detail(live: Option<usize>, verb: &str) -> String { match (live, verb) { - // The machine could not be asked, so no number can be promised — say - // what is actually known, which is that anything still running there - // is about to end. (None, "Delete") => "Its machine could not be reached. Any sessions still running there \ will be ended, and the layout forgotten." .to_string(), @@ -380,11 +242,6 @@ fn destructive_detail(live: Option<usize>, verb: &str) -> String { } } -/// Shared confirm-then-act path for the two destructive workspace actions. -/// -/// A free function rather than a `Tty7App` method because the title-bar menu's -/// row buttons run inside a menu builder, which has a `Window` and an `App` but -/// no entity to call a method on. fn confirm_destructive( cx: &mut App, window: &mut Window, @@ -398,11 +255,6 @@ fn confirm_destructive( let handle = window.window_handle(); cx.spawn(async move |cx| { - // The count dials the workspace's machine, so it does not belong on the - // UI thread — on a remote route that is an SSH handshake, and on a WSL - // one it can go as far as installing the server. Reached straight from - // the action handler, it was a `guard_off_ui` abort in a debug build - // and a window frozen for the length of a connect in a release one. let live = match query { Some(q) => { cx.background_spawn(async move { live_pane_count(&q) }) @@ -411,17 +263,12 @@ fn confirm_destructive( None => None, }; - // Only a machine that *answered* zero licenses skipping the prompt. An - // unreachable one is the case most likely to still have work in it. if live == Some(0) && verb == "Stop" { let _ = cx.update(|cx| act(cx, workspace)); return; } let detail = destructive_detail(live, verb); - // Title Case, like every other prompt title in the app — this one used - // to lowercase "workspace" while its siblings read "Close Window?" / - // "Quit and Stop Daemon?". let Ok(answer) = handle.update(cx, |_, window, cx| { window.prompt( gpui::PromptLevel::Warning, @@ -431,14 +278,9 @@ fn confirm_destructive( cx, ) }) else { - // The window went away while we were asking its machine. Nothing to - // confirm against, and acting unprompted is exactly what this path - // exists to prevent. return; }; - // Index 1 == the verb button; Cancel and a dismissed prompt both leave - // the workspace alone. if let Ok(1) = answer.await { let _ = cx.update(|cx| act(cx, workspace)); } @@ -446,26 +288,11 @@ fn confirm_destructive( .detach(); } -/// Stop a workspace: kill every pane it owns in the daemon, and close the -/// window showing it. -/// -/// The workspace *record* survives — its tabs, split layout and each pane's cwd -/// stay on file — so reopening it later rebuilds the same arrangement with -/// fresh shells. That is the difference from [`delete_workspace`], which throws -/// the record away too. -/// -/// Callers confirm first unless [`live_pane_count`] answered zero; with nothing -/// running there is nothing to lose. pub fn stop_workspace(cx: &mut App, workspace: WorkspaceId) { let doomed = doomed_pane_ids(cx, workspace); stop_workspace_keeping(cx, workspace, doomed); } -/// The pane ids stopping or deleting `workspace` must kill, per its machine's -/// mirror. Read this **before** any operation that removes the workspace from -/// the mirror — `fire_workspace_op(WorkspaceRemove)` folds the removal in -/// synchronously ([`crate::ui::machine_mirror::MachineMirrors::note_workspace_op`]), -/// and a list read after it is always empty. fn doomed_pane_ids(cx: &App, workspace: WorkspaceId) -> Vec<u64> { WorkspaceStore::all(cx) .get(workspace) @@ -474,21 +301,12 @@ fn doomed_pane_ids(cx: &App, workspace: WorkspaceId) -> Vec<u64> { } fn stop_workspace_keeping(cx: &mut App, workspace: WorkspaceId, ids: Vec<u64>) { - // A remote workspace's panes live on the remote server, and its pane ids are - // *that* daemon's. Sending them here would not fail — it would succeed - // against whatever local panes happen to hold those numbers, killing a - // stranger's shells. The route is what makes "Stop" mean the same thing on - // both kinds of workspace. let route = crate::ui::remote_workspace::pane_route_for(cx, workspace); let host = WorkspaceStore::all(cx) .get(workspace) .map(|w| w.host_id()) .unwrap_or(crate::ui::host_ops::HostId::LOCAL); if !ids.is_empty() { - // Off the UI thread: each of these dials `route`, and on a remote - // workspace that is an SSH channel per pane. Stopping a four-pane - // workspace used to freeze the window for as long as four round trips. - // Fire-and-forget — a missing daemon means there was nothing to kill. let route = route.clone(); cx.background_executor() .spawn(async move { @@ -498,9 +316,6 @@ fn stop_workspace_keeping(cx: &mut App, workspace: WorkspaceId, ids: Vec<u64>) { }) .detach(); } - // The panes this machine just reported as alive are the ones we killed, so - // the cached answer is now wrong by our own hand. Waiting out its TTL would - // leave a green dot on the picker row of a workspace the user just stopped. if cx .try_global::<crate::terminal::pane_liveness::PaneLivenessCache>() .is_some() @@ -509,28 +324,16 @@ fn stop_workspace_keeping(cx: &mut App, workspace: WorkspaceId, ids: Vec<u64>) { cache.invalidate(host) }); } - // A remote workspace's port forwards are owned by the - // *workspace*, not by its panes, so nothing else ends them. Done before the - // window closes, because the route to the daemon is read off a live pane. if let Some(app) = WindowRegistry::app_for(cx, workspace) && let Some(app) = app.upgrade() { app.read(cx).teardown_workspace_forwards(cx); } - // One workspace is shown by exactly one window, so stopping the work means - // the window goes with it — leaving an empty frame behind reads as a - // half-finished action. close_window_for(cx, workspace); WorkspaceStore::close_window(cx, workspace); - // No client-side bookkeeping about the panes remains to correct: the kills - // above end the PTYs, the machine's own pane server observes each death, - // and the tree's records flip to `live: false` — exactly the state the - // next open reads as "revive with a fresh shell". refresh_menu(cx); } -/// Delete a workspace outright: stop it, then forget it entirely. Irreversible -/// — nothing about the layout survives. pub fn delete_workspace(cx: &mut App, workspace: WorkspaceId) { let doomed = delete_from_tree(cx, workspace); stop_workspace_keeping(cx, workspace, doomed); @@ -539,15 +342,6 @@ pub fn delete_workspace(cx: &mut App, workspace: WorkspaceId) { refresh_menu(cx); } -/// The tree half of a delete, in the one order that works: read the kill list -/// off the machine mirror **before** firing `WorkspaceRemove`, because firing -/// folds the removal into that mirror on the way out and the list read -/// afterwards is empty — which is how "N running sessions will be ended" once -/// ended zero. Answers the panes the caller must kill. -/// -/// The op itself still goes before `WorkspaceStore::remove`: the tree is where -/// every other client (and the next launch) lists workspaces from, and firing -/// after the entry is gone would leave it stranded with no way to name it. fn delete_from_tree(cx: &mut App, workspace: WorkspaceId) -> Vec<u64> { let doomed = doomed_pane_ids(cx, workspace); crate::ui::tree_sync::fire_workspace_op(cx, workspace, |ws| { @@ -557,11 +351,6 @@ fn delete_from_tree(cx: &mut App, workspace: WorkspaceId) -> Vec<u64> { doomed } -/// Drop the connection to any machine no workspace points at any more. -/// -/// One connection per machine is shared by every workspace on it, so it is -/// released when the *last* one goes — not when a window closes. Anything less -/// careful would tear down a live sibling window's host mid-call. fn release_unused_hosts(cx: &mut App) { let live: Vec<_> = WorkspaceStore::all(cx) .views @@ -576,11 +365,6 @@ fn release_unused_hosts(cx: &mut App) { } } -/// Close whichever window is showing `workspace`, if any. -/// -/// The last window is the exception: it stays, swapped onto a fresh blank -/// workspace, because a windowless tty7 left in the Dock stops responding to -/// clicks (#147). fn close_window_for(cx: &mut App, workspace: WorkspaceId) { let showing = WindowRegistry::app_for(cx, workspace); let Some(handle) = WindowRegistry::window_for(cx, workspace) else { @@ -605,22 +389,12 @@ fn close_window_for(cx: &mut App, workspace: WorkspaceId) { }); } -/// Where a new window should appear: the workspace's own remembered geometry -/// first (that is where the user left *this* workspace), then the shared -/// `window.json` fallback, then a centred default — each cascaded so it does -/// not land exactly on an existing window. fn window_options(cx: &mut App, workspace: Option<WorkspaceId>) -> WindowOptions { - // X11 needs the icon on the native window itself for taskbars and window - // switchers. Wayland resolves the same application identity through the - // desktop entry when tty7 is packaged. #[cfg(any(target_os = "linux", target_os = "freebsd"))] static APP_ICON: std::sync::LazyLock<Option<std::sync::Arc<image::RgbaImage>>> = std::sync::LazyLock::new(|| { image::load_from_memory(include_bytes!("../../assets/app-icon.png")) .ok() - // The source asset is 1024×1024, but _NET_WM_ICON ships raw - // pixels to the X server per window (~4 MB at full size) and - // taskbars want at most 256px anyway. .map(|image| std::sync::Arc::new(image.thumbnail(256, 256).into_rgba8())) }); @@ -635,8 +409,6 @@ fn window_options(cx: &mut App, workspace: Option<WorkspaceId>) -> WindowOptions let existing = WindowRegistry::count(cx); let bounds = match remembered { - // A remembered window that no longer touches any display (monitor - // unplugged, resolution change) keeps its size but re-centers. Some(state) => { let bounds = state.bounds(); if cx.displays().iter().any(|d| d.bounds().intersects(&bounds)) { @@ -649,11 +421,6 @@ fn window_options(cx: &mut App, workspace: Option<WorkspaceId>) -> WindowOptions }; let bounds = cascade(bounds, existing); - // Launch state from config: a normal window, or maximized / fullscreen. - // Each variant still carries the bounds above as the size to restore to - // when the user un-maximizes / exits fullscreen. Only the *first* window - // honors maximized/fullscreen — a second window forced fullscreen would - // hide the one the user was just in. let window_bounds = match cx.global::<Config>().startup_mode { _ if existing > 0 => WindowBounds::Windowed(bounds), StartupMode::Normal => WindowBounds::Windowed(bounds), @@ -666,30 +433,19 @@ fn window_options(cx: &mut App, workspace: Option<WorkspaceId>) -> WindowOptions app_id: Some("tty7".to_owned()), #[cfg(any(target_os = "linux", target_os = "freebsd"))] icon: APP_ICON.as_ref().cloned(), - // Start from the component defaults but nudge the traffic lights down - // so they stay vertically centred in our taller (40px) title bar — see - // `TitleBar::new().h(..)` in `app.rs`. `apply_theme` re-pins the same - // position after appearance changes. titlebar: Some(TitlebarOptions { traffic_light_position: Some(crate::ui::theme::traffic_light_position()), ..TitleBar::title_bar_options() }), - // Non-opaque from creation: macOS 26 ignores a runtime flip to - // transparent, so the opacity slider only works on a window born this - // way (see `theme::background_appearance`). window_background: crate::ui::theme::background_appearance(cx), ..Default::default() } } -/// Offset `bounds` by one cascade step per existing window, so opening several -/// windows in a row doesn't stack them invisibly on top of each other. fn cascade(bounds: Bounds<gpui::Pixels>, existing: usize) -> Bounds<gpui::Pixels> { if existing == 0 { return bounds; } - // Wrap after a few steps so a long-lived session doesn't march windows off - // the bottom-right of the display. let step = (existing % 5) as f32 * CASCADE_STEP; Bounds { origin: bounds.origin + point(px(step), px(step)), @@ -725,25 +481,18 @@ mod tests { cascade(b, 2).origin, point(px(100. + 2. * CASCADE_STEP), px(100. + 2. * CASCADE_STEP)) ); - // Size is never touched — only the origin moves. assert_eq!(cascade(b, 3).size, b.size); } #[test] fn cascade_wraps_so_windows_never_march_off_screen() { let b = bounds_at(100., 100.); - // The 5th extra window is back at the un-offset origin rather than - // 5 steps further down-right. assert_eq!(cascade(b, 5).origin, b.origin); assert_eq!(cascade(b, 6).origin, cascade(b, 1).origin); } - /// The three answers a liveness query can give each reach the user as a - /// different sentence — and the counted ones read exactly as they did - /// before "could not ask" became expressible. #[test] fn the_confirmation_says_which_of_the_three_answers_it_got() { - // Counted: unchanged wording, singular and plural, stop and delete. assert_eq!( destructive_detail(Some(1), "Stop"), "1 running session will be ended." @@ -760,14 +509,11 @@ mod tests { destructive_detail(Some(3), "Delete"), "3 running sessions will be ended and the layout forgotten." ); - // Counted zero: nothing is running, so only the layout is at stake. assert_eq!( destructive_detail(Some(0), "Delete"), "Its layout and working directories will be forgotten." ); - // Could not ask. It must not claim a number, must not claim nothing - // will be lost, and must name the reason. for verb in ["Stop", "Delete"] { let detail = destructive_detail(None, verb); assert!( @@ -785,12 +531,6 @@ mod tests { } } - /// The regression the delete order guards against: `WorkspaceRemove` is - /// folded into the machine mirror synchronously on its way out, so a kill - /// list read *after* firing it is always empty — the confirm prompt said - /// "3 running sessions will be ended" and the delete then ended none. - /// `delete_from_tree` must hand back the panes the mirror listed before - /// the removal blanked it. #[gpui::test] fn a_delete_reads_its_kill_list_before_the_removal_blanks_the_mirror( cx: &mut gpui::TestAppContext, diff --git a/src/ui/worktree_prompt.rs b/src/ui/worktree_prompt.rs index 0d5b9d07..1171364a 100644 --- a/src/ui/worktree_prompt.rs +++ b/src/ui/worktree_prompt.rs @@ -1,8 +1,3 @@ -//! The "New Worktree Tab" sheet: confirms (or edits) the generated worktree -//! name, the new branch, and the branch it starts from before anything touches -//! git. Opened from the tab context menu (`tab_strip::tab_context_menu`); the -//! defaults are probed off the UI thread in `Tty7App::new_worktree_tab`. - use gpui::{AnyElement, Context, Entity, Subscription, Window, div, prelude::*, px}; use gpui_component::button::{Button, ButtonVariants as _}; use gpui_component::input::{Input, InputEvent, InputState}; @@ -13,33 +8,18 @@ use gpui_component::{ use crate::core::worktree::{WorktreeDefaults, WorktreeRequest}; use crate::ui::app::Tty7App; -/// State for the open sheet. Held on [`Tty7App`] so it survives re-renders and -/// tab switches; there is at most one, app-wide. pub(crate) struct WorktreePrompt { - /// The machine the repository is on — the one the eventual - /// `git worktree add` runs on. Held for the whole life of the sheet so the - /// create cannot end up asking a different host than the defaults were - /// probed from. host: crate::ui::host_ops::SharedHost, - /// The directory the repo was derived from (the right-clicked tab's cwd) — - /// what the eventual `git worktree add` resolves the repository through. cwd: std::path::PathBuf, - /// Where the checkout will land (`<root>/<repo-name>`), for the live path - /// preview under the name field. dir: std::path::PathBuf, name: Entity<InputState>, branch: Entity<InputState>, base: Entity<InputState>, - /// True while `git worktree add` runs, so a second Enter can't double-create. busy: bool, _subs: Vec<Subscription>, } impl Tty7App { - /// Open the sheet with probed defaults: the generated candidate fills both - /// the name and the branch (edit either independently), the current branch - /// fills the start point. Focus lands on the name field; Enter anywhere - /// submits, Esc cancels. pub(crate) fn open_worktree_prompt( &mut self, host: crate::ui::host_ops::SharedHost, @@ -55,14 +35,15 @@ impl Tty7App { let subs = [&name, &branch, &base] .into_iter() .map(|input| { - cx.subscribe_in(input, window, |this, _, ev: &InputEvent, window, cx| { - match ev { + cx.subscribe_in( + input, + window, + |this, _, ev: &InputEvent, window, cx| match ev { InputEvent::PressEnter { .. } => this.submit_worktree_prompt(window, cx), - // Keep the path preview tracking the name field. InputEvent::Change => cx.notify(), _ => {} - } - }) + }, + ) }) .collect(); self.worktree_prompt = Some(WorktreePrompt { @@ -85,10 +66,6 @@ impl Tty7App { } } - /// Validate the fields and run the creation off the UI thread. Blanking - /// one of name/branch falls back to the other (one name is enough); a - /// blank start point means the repo's HEAD. On failure the sheet stays up - /// with the values intact, so a typo'd branch is a fix away. fn submit_worktree_prompt(&mut self, window: &mut Window, cx: &mut Context<Self>) { let Some(p) = self.worktree_prompt.as_ref() else { return; @@ -143,8 +120,6 @@ impl Tty7App { ); } - /// The sheet itself, floated near the top of the terminal area like the - /// SSH auth sheet. `None` while no prompt is open. pub(crate) fn render_worktree_prompt_overlay( &self, cx: &mut Context<Self>, @@ -157,7 +132,6 @@ impl Tty7App { .child(div().text_xs().text_color(muted).child(label)) .child(Input::new(input).small()) }; - // Live preview of where the checkout will land, following the name field. let name_now = p.name.read(cx).value().trim().to_string(); let preview = p .dir @@ -179,8 +153,6 @@ impl Tty7App { .border_color(cx.theme().border) .rounded_lg() .shadow_lg() - // Esc cancels from anywhere in the sheet (Enter submits via the - // inputs' PressEnter events). .on_key_down(cx.listener(|this, ev: &gpui::KeyDownEvent, window, cx| { if ev.keystroke.key == "escape" { this.cancel_worktree_prompt(window, cx);