diff --git a/CHANGELOG.md b/CHANGELOG.md index f4ecbff0..ae30ab96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,12 +9,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Multiple windows, one workspace each** — `New Workspace` (⌘⇧N) opens a + second window with its own tabs, splits, and chrome state. A workspace is + the persistent thing: closing its window puts it away with its panes still + running in the daemon, and the title bar's workspace menu, the command + palette, and the home page's picker all bring it back. `Stop Workspace` ends one for real + (no default chord — it kills sessions), `Delete Workspace` also forgets the + layout, and a workspace can be renamed from the title-bar chip. +- **Workspace switcher, in the two places you'd look** — a title-bar chip + (monogram + chevron) whose menu lists every workspace with a monogram badge + and a corner dot for the ones whose shells are still running, and the macOS + **Window** menu listing the same set: on screen first, then the detached ones + with how long ago you left them. Both show the first nine. - **The ⌃R history menu can be switched off** — Settings → Terminal → Keyboard, or `history_search` in `config.json`. With it off, the prompt line is handed to the shell and the raw `^R` follows it, so a binding of your own (fzf, percol, plain reverse-i-search) answers instead of tty7's menu. (#163) +### Changed + +- **Sidebar and right-panel visibility are per-window** — toggling one + window's chrome leaves the others alone. The config value is now what a + *newly opened* window starts with; panel width stays shared. +- **Chrome icons draw at the size they were asked to** — every tile glyph in + the window (title bar, rail, panel tabs, overlay headers) had been rendering + at 12px regardless of the size its call site set, because the button widget + overwrites its icon's size from its own. The tile rhythm is now stated once + and applied from one helper, so the marks read at their intended weight and + the hover capsule keeps a consistent gap from the window edge. +- **Daemon wire protocol is now v2** — WSL panes carry a remote-context kind + that a v1 client can't decode, so it would drop the pane's connection + instead of ignoring the unknown value. The version handshake now sees that + skew and offers to restart the daemon, rather than letting a downgraded + build lose panes silently. + ### Fixed - **⌃J and ⌃M submit the line again** — accept-line's control codes were diff --git a/src/core/actions.rs b/src/core/actions.rs index 386ade9d..144e6f10 100644 --- a/src/core/actions.rs +++ b/src/core/actions.rs @@ -9,6 +9,34 @@ 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, + // 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, + SelectWorkspace4, + SelectWorkspace5, + SelectWorkspace6, + SelectWorkspace7, + SelectWorkspace8, + SelectWorkspace9, CloseActiveTab, SplitRight, SplitDown, diff --git a/src/core/config.rs b/src/core/config.rs index cbfcc432..c78bc58c 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -117,23 +117,32 @@ pub struct Config { /// 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 is collapsed out of the layout (only + /// 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) is - /// docked open. Toggled by `ToggleRightPanel`. + /// 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. + /// 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 last had selected, so reopening it lands - /// where it was left. + /// 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 @@ -168,6 +177,13 @@ pub struct Config { /// second, so toggling it (Settings or a `config.json` edit) applies live. #[serde(default = "default_true")] pub show_tray_icon: bool, + /// Whether the user has already been told, once, that closing a window puts + /// its workspace away rather than ending it. ⌘W is muscle memory and the + /// result is off-screen, so the first time it happens deserves one line + /// pointing at the title bar's workspace menu — and never again. Set to + /// `true` by that hint; there is no UI to reset it (nor a reason to). + #[serde(default)] + pub workspace_detach_hint_seen: bool, /// How the terminal bell (BEL / `^G`) is signalled. Defaults to a brief /// visual flash (the current behavior). #[serde(default, deserialize_with = "de_lenient")] @@ -536,6 +552,7 @@ impl Default for Config { notify_threshold_secs: default_notify_threshold_secs(), restore_session: true, show_tray_icon: true, + workspace_detach_hint_seen: false, // Visual flash preserves the pre-config behavior (the bell always // flashed); opting into None/Audible is a deliberate change. bell: BellMode::Visual, diff --git a/src/core/session.rs b/src/core/session.rs index 99beaf4e..4e426489 100644 --- a/src/core/session.rs +++ b/src/core/session.rs @@ -91,7 +91,11 @@ pub struct SessionTab { pub sidebar_group: Option, } -/// The full saved session: the open tabs and which one was active. +/// One workspace's contents: the open tabs and which one was active. +/// +/// This is the unit a single window displays. It used to *be* the whole file +/// (tty7 had exactly one window); it is now nested inside a [`Workspace`], and +/// [`Workspaces`] owns the file-level IO. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(default)] pub struct Session { @@ -99,16 +103,188 @@ pub struct Session { pub tabs: Vec, } -impl Session { - /// Load the saved session. Returns `None` when the file is absent or - /// unreadable, and `None` (with a warning) when it fails to parse — never - /// panics. - pub fn load() -> Option { +/// 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); + +impl WorkspaceId { + pub fn new() -> Self { + 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 + } +} + +impl Default for WorkspaceId { + fn default() -> Self { + Self::new() + } +} + +impl std::fmt::Display for WorkspaceId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +/// A persistent workspace: a named group of tabs that a window can open, close, +/// and reopen later. Closing its window is a *detach* — the panes keep running +/// in the daemon and the entry stays here with `open: false`, which is what the +/// home-page picker lists. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Workspace { + #[serde(default)] + pub id: WorkspaceId, + /// User-set name from "Rename Workspace". `None` falls back to + /// [`Workspace::display_name`], derived from the tabs' repo/cwd. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(default)] + pub session: Session, + /// 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, + /// Whether a window was showing this workspace at quit. Launch reopens + /// exactly 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. + #[serde(default)] + pub last_active: u64, +} + +impl Default for Workspace { + fn default() -> Self { + Self { + id: WorkspaceId::new(), + name: None, + session: Session::default(), + window: None, + open: true, + last_active: now_secs(), + } + } +} + +impl Workspace { + /// Wrap a bare session as a brand-new open workspace. + pub fn from_session(session: Session) -> Self { + Self { + session, + ..Self::default() + } + } + + /// What to show in the picker and the window title: the user-set name if + /// any, else the repository most of its tabs live in, else the first tab's + /// directory, else a generic fallback. Derived rather than stored so a + /// workspace that `cd`s into a project stops being "Untitled" on its own. + pub fn display_name(&self) -> String { + if let Some(name) = self + .name + .as_ref() + .map(|n| n.trim()) + .filter(|n| !n.is_empty()) + { + return name.to_string(); + } + if let Some(repo) = self.dominant_repo() { + if let Some(base) = basename(&repo) { + return base; + } + } + if let Some(cwd) = self.first_cwd() { + if let Some(base) = basename(&cwd) { + return base; + } + } + "Untitled".to_string() + } + + /// The repo root the most tabs belong to — the workspace's centre of + /// gravity for naming. Ties break toward the earliest tab, matching the + /// order the user sees in the sidebar. + pub fn dominant_repo(&self) -> Option { + let mut counts: Vec<(PathBuf, usize)> = Vec::new(); + for group in self + .session + .tabs + .iter() + .filter_map(|t| t.sidebar_group.as_ref()) + { + match counts.iter_mut().find(|(path, _)| path == group) { + Some((_, n)) => *n += 1, + None => counts.push((group.clone(), 1)), + } + } + counts + .into_iter() + .max_by_key(|(_, n)| *n) + .map(|(path, _)| path) + } + + /// The first saved cwd anywhere in the tab tree, used for naming and for + /// the picker's dim subtitle line. + pub fn first_cwd(&self) -> Option { + self.session + .tabs + .iter() + .find_map(|tab| first_leaf_cwd(&tab.pane)) + } + + /// Total leaf terminals across every tab — the picker's "3 panes" count. + pub fn pane_count(&self) -> usize { + self.session.tabs.iter().map(|t| leaf_count(&t.pane)).sum() + } + + /// Every daemon pane id this workspace claims, for the cross-window + /// uniqueness check on restore (two windows attaching one pane would let + /// the second silently steal the first's stream). + pub fn pane_ids(&self) -> Vec { + let mut out = Vec::new(); + for tab in &self.session.tabs { + collect_pane_ids(&tab.pane, &mut out); + } + out + } + + /// Stamp this workspace as just-focused. + pub fn touch(&mut self) { + self.last_active = now_secs(); + } +} + +/// The whole `session.json`: every workspace tty7 knows about, plus which one +/// had focus at quit. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Workspaces { + /// Note: deliberately *not* `#[serde(default)]` at the struct level — the + /// presence of this key is what distinguishes a new-format file from the + /// legacy flat `{active, tabs}` one. See [`Workspaces::decode`]. + pub workspaces: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub active: Option, +} + +impl Workspaces { + /// Load every saved workspace. 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 { let path = Self::path()?; - // Absent/unreadable file is the normal first-run case: silently None. let text = std::fs::read_to_string(&path).ok()?; - match serde_json::from_str::(&text) { - Ok(session) => Some(session), + match Self::decode(&text) { + Ok(loaded) => Some(loaded), Err(e) => { log::warn!( "failed to parse session at {}: {e}; ignoring", @@ -119,8 +295,80 @@ impl Session { } } - /// Persist the session as JSON, creating the parent directory if needed. - /// Any IO/serialization error is logged and swallowed. + /// Parse either format. A file written by any build with multi-window + /// support has a `workspaces` array; anything else is a pre-multi-window + /// `{active, tabs}` session, which migrates to a single open workspace so + /// upgrading users keep their tabs (and their attached daemon panes). + pub fn decode(text: &str) -> Result { + let value: serde_json::Value = serde_json::from_str(text)?; + if value.get("workspaces").is_some() { + return serde_json::from_value(value); + } + let legacy: Session = serde_json::from_value(value)?; + Ok(Self::single(Workspace::from_session(legacy))) + } + + /// A one-workspace set, used by the legacy migration and by first run. + pub fn single(workspace: Workspace) -> Self { + Self { + active: Some(workspace.id), + workspaces: vec![workspace], + } + } + + pub fn get(&self, id: WorkspaceId) -> Option<&Workspace> { + self.workspaces.iter().find(|w| w.id == id) + } + + pub fn get_mut(&mut self, id: WorkspaceId) -> Option<&mut Workspace> { + self.workspaces.iter_mut().find(|w| w.id == id) + } + + /// The workspaces to reopen at launch, in their saved order. Empty when + /// the user quit with every window closed — launch then shows one window on + /// the picker rather than guessing. + pub fn open_workspaces(&self) -> impl Iterator { + self.workspaces.iter().filter(|w| w.open) + } + + /// Closed workspaces for the home-page picker, most recently active first. + pub fn closed_workspaces(&self) -> Vec<&Workspace> { + let mut closed: Vec<&Workspace> = self.workspaces.iter().filter(|w| !w.open).collect(); + closed.sort_by(|a, b| b.last_active.cmp(&a.last_active)); + closed + } + + /// Drop pane ids that appear in more than one workspace, keeping the claim + /// of whichever workspace was active most recently. A duplicate would have + /// two windows attach the same daemon pane, and the daemon's single + /// subscriber means the loser's terminal goes silently dead — so this runs + /// on every load, before any window is built. + /// + /// Returns the number of claims dropped (0 in the healthy case). + pub fn dedupe_pane_ids(&mut self) -> usize { + let mut order: Vec<(usize, u64)> = self + .workspaces + .iter() + .enumerate() + .map(|(i, w)| (i, w.last_active)) + .collect(); + // Most recently active first: it keeps its claim, earlier ones yield. + order.sort_by(|a, b| b.1.cmp(&a.1)); + + let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + let mut dropped = 0; + for (index, _) in order { + let workspace = &mut self.workspaces[index]; + for tab in &mut workspace.session.tabs { + dropped += drop_duplicate_pane_ids(&mut tab.pane, &mut seen); + } + } + dropped + } + + /// 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 session bookkeeping. pub fn save(&self) { let Some(path) = Self::path() else { return; @@ -149,6 +397,222 @@ impl Session { } } +/// App-level owner of `session.json`, and the single writer to it. +/// +/// Windows never touch the file themselves. Each one pushes *its* workspace's +/// 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 tabs. 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 { + workspaces: Workspaces, +} + +impl gpui::Global for WorkspaceStore {} + +impl WorkspaceStore { + /// Read `session.json` (migrating a legacy flat session), drop any + /// duplicate pane claims, and install the result as the app global. Call + /// once, before the first window is built. + pub fn init(cx: &mut gpui::App) { + let mut workspaces = Workspaces::load().unwrap_or_default(); + let dropped = workspaces.dedupe_pane_ids(); + if dropped > 0 { + log::warn!( + "session.json claimed {dropped} pane(s) from more than one workspace; \ + the stale claims will spawn fresh shells instead" + ); + } + cx.set_global(Self { workspaces }); + } + + /// 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) -> &Workspaces { + static EMPTY: std::sync::OnceLock = std::sync::OnceLock::new(); + match cx.try_global::() { + Some(store) => &store.workspaces, + None => EMPTY.get_or_init(Workspaces::default), + } + } + + /// 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 `session.json`. + fn try_store(cx: &mut gpui::App) -> Option<&mut Self> { + cx.has_global::().then(|| cx.global_mut::()) + } + + /// 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 plus the tabs the window should rebuild. + pub fn claim(cx: &mut gpui::App, id: Option) -> (WorkspaceId, Session) { + 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(), Session::default()); + }; + let id = id.filter(|id| store.workspaces.get(*id).is_some()); + let workspace = match id { + Some(id) => store.workspaces.get_mut(id).expect("filtered above"), + None => { + store.workspaces.workspaces.push(Workspace::default()); + store.workspaces.workspaces.last_mut().expect("just pushed") + } + }; + workspace.open = true; + workspace.touch(); + let claimed = (workspace.id, workspace.session.clone()); + store.workspaces.active = Some(claimed.0); + store.workspaces.save(); + claimed + } + + /// Record a window's current tabs (and geometry, when known) and persist. + /// Called on every structural change, exactly where `Session::save` used to be. + pub fn record( + cx: &mut gpui::App, + id: WorkspaceId, + session: Session, + window: Option, + ) { + let Some(store) = Self::try_store(cx) else { + return; + }; + let Some(workspace) = store.workspaces.get_mut(id) else { + // The workspace was closed out from under us (its window is + // tearing down); nothing to record. + return; + }; + workspace.session = session; + if let Some(window) = window { + workspace.window = Some(window); + } + store.workspaces.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; + }; + if let Some(workspace) = store.workspaces.get_mut(id) { + workspace.touch(); + } + store.workspaces.active = Some(id); + store.workspaces.save(); + } + + /// Set (or clear, with `None`) a workspace's user-chosen name. Clearing + /// falls back to the derived repo/cwd name — see [`Workspace::display_name`]. + pub fn rename(cx: &mut gpui::App, id: WorkspaceId, name: Option) { + let Some(store) = Self::try_store(cx) else { + return; + }; + if let Some(workspace) = store.workspaces.get_mut(id) { + workspace.name = name; + } + store.workspaces.save(); + } + + /// 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) { + let Some(store) = Self::try_store(cx) else { + return; + }; + if let Some(workspace) = store.workspaces.get_mut(id) { + workspace.open = false; + workspace.touch(); + } + store.workspaces.save(); + } + + /// Forget a workspace entirely — the explicit "Close Workspace" action. + /// The caller is responsible for killing its daemon panes first; this only + /// drops the bookkeeping. + pub fn remove(cx: &mut gpui::App, id: WorkspaceId) { + let Some(store) = Self::try_store(cx) else { + return; + }; + store.workspaces.workspaces.retain(|w| w.id != id); + if store.workspaces.active == Some(id) { + store.workspaces.active = None; + } + store.workspaces.save(); + } +} + +/// 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) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +/// Last path component as a display string, skipping a bare `/` or a path that +/// ends in `..`. +fn basename(path: &std::path::Path) -> Option { + path.file_name() + .and_then(|n| n.to_str()) + .map(|s| s.to_string()) + .filter(|s| !s.is_empty()) +} + +fn first_leaf_cwd(pane: &SessionPane) -> Option { + match pane { + SessionPane::Leaf { cwd, .. } => cwd.clone(), + SessionPane::Split { a, b, .. } => first_leaf_cwd(a).or_else(|| first_leaf_cwd(b)), + } +} + +fn leaf_count(pane: &SessionPane) -> usize { + match pane { + SessionPane::Leaf { .. } => 1, + SessionPane::Split { a, b, .. } => leaf_count(a) + leaf_count(b), + } +} + +fn collect_pane_ids(pane: &SessionPane, out: &mut Vec) { + match pane { + SessionPane::Leaf { pane_id, .. } => out.extend(pane_id), + SessionPane::Split { a, b, .. } => { + collect_pane_ids(a, out); + collect_pane_ids(b, out); + } + } +} + +/// Blank any `pane_id` already claimed by an earlier-visited workspace. A +/// blanked leaf still restores — it just spawns a fresh shell in its saved cwd, +/// the same path a session from before the daemon existed takes. +fn drop_duplicate_pane_ids( + pane: &mut SessionPane, + seen: &mut std::collections::HashSet, +) -> usize { + match pane { + SessionPane::Leaf { pane_id, .. } => match *pane_id { + Some(id) if !seen.insert(id) => { + log::warn!("workspace claims pane {id} twice; dropping the duplicate claim"); + *pane_id = None; + 1 + } + _ => 0, + }, + SessionPane::Split { a, b, .. } => { + drop_duplicate_pane_ids(a, seen) + drop_duplicate_pane_ids(b, seen) + } + } +} + /// Helpers for every test that touches the on-disk `session.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 @@ -333,9 +797,232 @@ mod tests { }, }], }; - session.save(); - let loaded = Session::load().expect("a saved session should load back"); - assert_eq!(loaded.tabs.len(), 1); - assert_eq!(loaded.tabs[0].name.as_deref(), Some("main")); + Workspaces::single(Workspace::from_session(session)).save(); + let loaded = Workspaces::load().expect("a saved session should load back"); + let only = &loaded.workspaces[0]; + assert_eq!(only.session.tabs.len(), 1); + assert_eq!(only.session.tabs[0].name.as_deref(), Some("main")); + assert_eq!(loaded.active, Some(only.id)); + } + + // ── Workspace layer ───────────────────────────────────────────────────── + + /// Build a leaf with the given cwd + pane id; the agent/ssh fields are + /// irrelevant to every workspace-layer test. + fn leaf(cwd: Option<&str>, pane_id: Option) -> SessionPane { + SessionPane::Leaf { + cwd: cwd.map(PathBuf::from), + pane_id, + ssh_spec: None, + agent: None, + agent_session_id: None, + agent_launch_argv: None, + } + } + + fn tab(pane: SessionPane, group: Option<&str>) -> SessionTab { + SessionTab { + name: None, + sidebar_group: group.map(PathBuf::from), + pane, + } + } + + fn workspace(tabs: Vec) -> Workspace { + Workspace::from_session(Session { active: 0, tabs }) + } + + #[test] + fn legacy_flat_session_migrates_to_one_open_workspace() { + // Exactly the shape every pre-multi-window build wrote. + let legacy = r#"{"active":1,"tabs":[ + {"name":"build","pane":{"Leaf":{"cwd":"/work","pane_id":7}}}, + {"name":null,"pane":{"Leaf":{"cwd":"/tmp","pane_id":9}}} + ]}"#; + let loaded = Workspaces::decode(legacy).expect("legacy session should migrate"); + assert_eq!(loaded.workspaces.len(), 1); + let only = &loaded.workspaces[0]; + // The tabs — and crucially the pane ids, which are live daemon panes — + // survive the upgrade, so an updating user doesn't lose their shells. + assert_eq!(only.session.active, 1); + assert_eq!(only.session.tabs.len(), 2); + assert_eq!(only.pane_ids(), vec![7, 9]); + // It reopens on the next launch, matching pre-upgrade behavior. + assert!(only.open); + assert_eq!(loaded.active, Some(only.id)); + } + + #[test] + fn empty_and_absent_shapes_decode_without_losing_data() { + // `{}` is the home-page state an older build wrote: zero tabs, still valid. + let empty = Workspaces::decode("{}").expect("empty object decodes"); + assert_eq!(empty.workspaces.len(), 1); + assert!(empty.workspaces[0].session.tabs.is_empty()); + // A new-format file with no workspaces at all stays empty rather than + // being mistaken for a legacy session and gaining a phantom entry. + let none = Workspaces::decode(r#"{"workspaces":[]}"#).expect("new format decodes"); + assert!(none.workspaces.is_empty()); + } + + #[test] + fn new_format_round_trips_through_json() { + let mut ws = workspace(vec![tab(leaf(Some("/work"), Some(3)), Some("/work"))]); + ws.name = Some("api".into()); + ws.open = false; + ws.last_active = 1_700_000_000; + let id = ws.id; + let all = Workspaces { + active: Some(id), + workspaces: vec![ws], + }; + let back = Workspaces::decode(&serde_json::to_string(&all).unwrap()).unwrap(); + let only = &back.workspaces[0]; + assert_eq!(only.id, id, "workspace identity must survive a restart"); + assert_eq!(only.name.as_deref(), Some("api")); + assert!(!only.open); + assert_eq!(only.last_active, 1_700_000_000); + assert_eq!(back.active, Some(id)); + } + + #[test] + fn display_name_prefers_user_name_then_repo_then_cwd() { + // No name, no repo group: fall back to the first leaf's directory. + let ws = workspace(vec![tab(leaf(Some("/home/u/scratch"), None), None)]); + assert_eq!(ws.display_name(), "scratch"); + + // A repo group wins over the cwd — it's the workspace's real subject. + let ws = workspace(vec![tab( + leaf(Some("/repo/tty7/src"), None), + Some("/repo/tty7"), + )]); + assert_eq!(ws.display_name(), "tty7"); + + // The majority repo wins when tabs straddle two checkouts. + let ws = workspace(vec![ + tab(leaf(None, None), Some("/repo/other")), + tab(leaf(None, None), Some("/repo/tty7")), + tab(leaf(None, None), Some("/repo/tty7")), + ]); + assert_eq!(ws.display_name(), "tty7"); + + // An explicit name beats everything derived. + let mut ws = workspace(vec![tab( + leaf(Some("/repo/tty7"), None), + Some("/repo/tty7"), + )]); + ws.name = Some(" Release prep ".into()); + assert_eq!(ws.display_name(), "Release prep"); + + // Nothing to go on at all. + assert_eq!(workspace(vec![]).display_name(), "Untitled"); + // A whitespace-only name is treated as unset rather than rendering blank. + let mut ws = workspace(vec![tab(leaf(Some("/x/proj"), None), None)]); + ws.name = Some(" ".into()); + assert_eq!(ws.display_name(), "proj"); + } + + #[test] + fn pane_and_tab_counts_walk_the_split_tree() { + let ws = workspace(vec![ + tab(leaf(Some("/a"), Some(1)), None), + tab( + SessionPane::Split { + axis: SessionAxis::Vertical, + ratio: 0.5, + a: Box::new(leaf(Some("/b"), Some(2))), + b: Box::new(leaf(None, Some(3))), + }, + None, + ), + ]); + assert_eq!(ws.pane_count(), 3); + assert_eq!(ws.pane_ids(), vec![1, 2, 3]); + assert_eq!(ws.first_cwd(), Some(PathBuf::from("/a"))); + } + + #[test] + fn dedupe_pane_ids_keeps_the_most_recently_active_claim() { + // Two workspaces both claim pane 5 — the crash/hand-edit case. The + // stale one must yield, or its window silently steals the live one's + // stream when both attach (the daemon has a single subscriber). + let mut stale = workspace(vec![tab(leaf(Some("/old"), Some(5)), None)]); + stale.last_active = 100; + let mut fresh = workspace(vec![tab(leaf(Some("/new"), Some(5)), None)]); + fresh.last_active = 200; + let (stale_id, fresh_id) = (stale.id, fresh.id); + + let mut all = Workspaces { + active: Some(fresh_id), + workspaces: vec![stale, fresh], + }; + assert_eq!(all.dedupe_pane_ids(), 1); + + // The recent one keeps pane 5; the stale one drops to a fresh spawn in + // its saved cwd (cwd is preserved — only the id is cleared). + assert_eq!(all.get(fresh_id).unwrap().pane_ids(), vec![5]); + assert!(all.get(stale_id).unwrap().pane_ids().is_empty()); + assert_eq!( + all.get(stale_id).unwrap().first_cwd(), + Some(PathBuf::from("/old")) + ); + } + + #[test] + fn dedupe_pane_ids_is_a_noop_on_healthy_sessions() { + let mut all = Workspaces { + active: None, + workspaces: vec![ + workspace(vec![tab(leaf(Some("/a"), Some(1)), None)]), + workspace(vec![tab(leaf(Some("/b"), Some(2)), None)]), + ], + }; + assert_eq!(all.dedupe_pane_ids(), 0); + assert_eq!(all.workspaces[0].pane_ids(), vec![1]); + assert_eq!(all.workspaces[1].pane_ids(), vec![2]); + } + + #[test] + fn dedupe_pane_ids_catches_a_duplicate_within_one_workspace() { + // Same guarantee inside a single workspace: a split that somehow ended + // up with the same pane in both halves would deadlock the same way. + let mut all = Workspaces { + active: None, + workspaces: vec![workspace(vec![ + tab(leaf(Some("/a"), Some(1)), None), + tab(leaf(Some("/b"), Some(1)), None), + ])], + }; + assert_eq!(all.dedupe_pane_ids(), 1); + assert_eq!(all.workspaces[0].pane_ids(), vec![1]); + } + + #[test] + fn open_and_closed_partition_by_flag_and_recency() { + let mut open_one = workspace(vec![]); + open_one.open = true; + let mut older = workspace(vec![]); + older.open = false; + older.last_active = 100; + let mut newer = workspace(vec![]); + newer.open = false; + newer.last_active = 300; + let (open_id, older_id, newer_id) = (open_one.id, older.id, newer.id); + + let all = Workspaces { + active: None, + workspaces: vec![open_one, older, newer], + }; + assert_eq!( + all.open_workspaces().map(|w| w.id).collect::>(), + vec![open_id] + ); + // The picker lists most-recently-active first. + assert_eq!( + all.closed_workspaces() + .iter() + .map(|w| w.id) + .collect::>(), + vec![newer_id, older_id] + ); } } diff --git a/src/daemon/protocol.rs b/src/daemon/protocol.rs index 38bfe891..5ac20a86 100644 --- a/src/daemon/protocol.rs +++ b/src/daemon/protocol.rs @@ -43,7 +43,20 @@ pub const MAX_FRAME: usize = 64 * 1024 * 1024; /// 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. -pub const PROTOCOL_VERSION: u32 = 1; +/// +/// 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 +/// +/// - **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 = 2; /// Reply to `ClientMsg::Version`: the protocol dialect the daemon speaks, plus /// its crate version for logs/diagnostics. Only `protocol` drives decisions. diff --git a/src/main.rs b/src/main.rs index efb02d23..40b2f0f7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,11 +11,9 @@ mod terminal; mod ui; use crate::core::config::Config; -use crate::ui::app::Tty7App; use crate::ui::assets::Assets; use crate::ui::keymap; use gpui::*; -use gpui_component::{Root, TitleBar}; /// Register the bundled Hack monospace faces with gpui's text system so the /// default `font_family` ("Hack") renders identically on every machine, with no @@ -360,6 +358,14 @@ fn main() { 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()); + // Read `session.json` (migrating a pre-multi-window file) before any + // window is built: windows claim their workspace from this store + // rather than each parsing the file themselves. It also dedupes + // pane claims here, once, instead of per window. + 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); @@ -383,70 +389,34 @@ fn main() { .detach(); keymap::init(cx); - cx.spawn(async move |cx| { - // Open where the user left off: `window.json` holds the geometry from - // the last quit (written by the quit hook in `ui::app`), applied only - // while `remember_window_size` is on. A remembered window that no - // longer touches any display (monitor unplugged, resolution change) - // keeps its size but re-centers; with nothing remembered, open at a - // roomy default, centred on the primary display (`centered` needs - // `&App`, which the async cx hands out via `update`). - let remembered = cx - .update(|cx| cx.global::().remember_window_size) - .then(crate::core::window_state::WindowState::load) - .flatten(); - let bounds = cx.update(|cx| match remembered { - Some(state) => { - let bounds = state.bounds(); - if cx.displays().iter().any(|d| d.bounds().intersects(&bounds)) { - bounds - } else { - Bounds::centered(None, bounds.size, cx) - } - } - None => Bounds::centered(None, size(px(1440.), px(900.)), cx), - }); - // 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. - let startup_mode = cx.update(|cx| cx.global::().startup_mode); - let window_bounds = match startup_mode { - crate::core::config::StartupMode::Normal => WindowBounds::Windowed(bounds), - crate::core::config::StartupMode::Maximized => WindowBounds::Maximized(bounds), - crate::core::config::StartupMode::Fullscreen => { - WindowBounds::Fullscreen(bounds) - } - }; - let window_background = cx.update(|cx| crate::ui::theme::background_appearance(cx)); - let options = WindowOptions { - window_bounds: Some(window_bounds), - // 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, - ..Default::default() - }; - - cx.open_window(options, |window, cx| { - let app = cx.new(|cx| Tty7App::new(window, cx)); - // 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())) - }) - .expect("failed to open window"); - }) - .detach(); + // Reopen the workspaces that had a window at the last quit, each in + // its own window and at its own remembered geometry (`ui::windows` + // owns that logic now, since "New Workspace" and the workspace picker + // need the identical path). Quitting with every window closed — or a + // first run — opens a single window on a fresh workspace. + let (reopen, any_saved) = { + let store = crate::core::session::WorkspaceStore::all(cx); + let reopen: Vec<_> = store.open_workspaces().map(|w| w.id).collect(); + (reopen, !store.workspaces.is_empty()) + }; + // With nothing to reopen, what that one window should hold depends on + // whether there is anything to come back to: workspaces the user + // detached are listed by the home page's picker, so leave it empty + // for them. A genuine first run has no picker to show and no reason + // to greet the user with a blank page — it opens a terminal, exactly + // as every pre-multi-window build did. + let fresh = if any_saved { + crate::ui::windows::FreshStart::HomePage + } else { + crate::ui::windows::FreshStart::Shell + }; + if reopen.is_empty() { + crate::ui::windows::open_with(cx, None, fresh); + } else { + for id in reopen { + crate::ui::windows::open(cx, Some(id)); + } + } }); } diff --git a/src/ui/app.rs b/src/ui/app.rs index 59368a20..b0d1bd1b 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -11,16 +11,21 @@ use gpui_component::select::{SearchableVec, SelectEvent, SelectState}; use gpui_component::slider::{SliderEvent, SliderState}; use gpui_component::{ActiveTheme as _, IndexPath, TitleBar, WindowExt as _}; use std::cell::{Cell, RefCell}; +use std::collections::HashSet; use std::rc::Rc; use std::sync::Arc; use crate::core::actions::*; use crate::core::config::{ - Config, CursorStyle as ConfigCursorStyle, NewTabPosition, ShellConfig, TabBarPosition, + Config, CursorStyle as ConfigCursorStyle, NewTabPosition, RightPanelTab, ShellConfig, + TabBarPosition, +}; +use crate::core::session::{ + Session, SessionAxis, SessionPane, SessionTab, WorkspaceId, WorkspaceStore, }; -use crate::core::session::{Session, SessionAxis, SessionPane, SessionTab}; use crate::core::shells::DetectedShell; use crate::core::ssh_config; +use crate::core::window_state::WindowState; use crate::daemon::protocol::{RemoteContext, ShellSpec, ssh_option_takes_value}; use crate::terminal::view::{ChildExited, TerminalView}; use crate::ui::palette::{Command, CommandKind, PaletteEvent, PaletteView}; @@ -81,14 +86,42 @@ const RECORD_COMMIT_DELAY_MS: u64 = 650; /// 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 30px hit box around a 15px glyph, so the glyph sits -/// [`TILE_PAD`] inside the box on every edge. Alignment is a property of what you -/// can *see*, so anything lining a tile up with text or with the window edge -/// subtracts `TILE_PAD` from the inset it wants — otherwise the invisible hit box -/// lands on the line and the glyph reads 7.5px short of it. -pub(crate) const TILE_SIZE: f32 = 30.; -pub(crate) const TILE_GLYPH: f32 = 15.; +/// 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.; /// 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 @@ -96,6 +129,28 @@ pub(crate) const TILE_PAD: f32 = (TILE_SIZE - TILE_GLYPH) / 2.; /// 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 @@ -105,8 +160,8 @@ pub(crate) const TITLE_BAR_LEAD: f32 = if cfg!(target_os = "macos") { 80. } else /// 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, 7.5px wider -/// than the glyph on each side — so this aligns the box, not the glyph, and the +/// *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 @@ -116,7 +171,7 @@ pub(crate) fn title_bar_hug_offset() -> f32 { if cfg!(target_os = "macos") { 0. } else { - CONTENT_INSET - TILE_PAD - TITLE_BAR_LEAD + tile_trailing_inset() - TITLE_BAR_LEAD } } @@ -322,6 +377,14 @@ pub(crate) struct Renaming { _subs: Vec, } +/// 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, + _subs: Vec, +} + 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 @@ -451,6 +514,19 @@ pub struct Tty7App { /// exactly the reason `sidebar_width` is — see there. pub(crate) right_panel_width: Rc>, pub(crate) right_panel_dragging: Rc>, + /// 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. pub(crate) sidebar_scroll: gpui::ScrollHandle, @@ -484,6 +560,25 @@ pub struct Tty7App { /// 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, + /// 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, + /// Cached "which daemon panes are alive", with the instant it was taken. + /// The picker needs it per row, but answering costs a control connection to + /// the daemon — far too much to pay on every frame — and the answer only + /// changes when a shell exits. A short TTL keeps it honest without making + /// rendering do IO. + pub(crate) alive_cache: RefCell)>>, + /// `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, + /// 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, } /// Which close action a live-SSH close-confirmation is gating (PRD FR-E3). @@ -496,16 +591,39 @@ pub(crate) enum SshCloseKind { } impl Tty7App { - pub fn new(window: &mut Window, cx: &mut Context) -> Self { - // Restore the previous session (tab/split layout + each pane's cwd), - // unless the user turned restore off — then start fresh. `None` takes the - // first-run path in `with_session`, spawning a single default terminal. - let session = if cx.global::().restore_session { - Session::load() - } else { - None + /// 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, + fresh: crate::ui::windows::FreshStart, + window: &mut Window, + cx: &mut Context, + ) -> Self { + // Claiming marks the workspace open and hands back its saved tabs, so + // the store (not this window) stays the single writer of session.json. + let restore = cx.global::().restore_session; + let known = id.is_some_and(|id| WorkspaceStore::all(cx).get(id).is_some()); + let (workspace, saved) = WorkspaceStore::claim(cx, id); + // A workspace that was already on file restores its tab/split layout and + // each pane's cwd, unless the user turned restore off — then it starts + // fresh. A *brand-new* one has no tabs to restore, so what it comes up + // with is the caller's call: `None` here takes the first-run path in + // `with_session`, spawning a single default terminal, which is what + // `New Workspace` and a first run both want. Handing an empty session + // through instead lands on the home page, for the launch that exists to + // show the workspace picker. + let session = match (known, fresh) { + (true, _) => restore.then_some(saved), + (false, crate::ui::windows::FreshStart::Shell) => None, + (false, crate::ui::windows::FreshStart::HomePage) => Some(Session::default()), }; - let app = Self::with_session(session, window, cx); + let app = Self::with_session(Some(workspace), session, window, cx); + // 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 @@ -568,10 +686,14 @@ impl Tty7App { /// so every subscription and window hook runs exactly as in production /// without touching `~/.config` or a daemon. pub(crate) fn with_session( + workspace: Option, session: Option, window: &mut Window, cx: &mut Context, ) -> 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(); // Font size from config (borrow ends before the mutable theme apply). let ( font_size, @@ -606,6 +728,11 @@ impl Tty7App { let mf_description = cx.new(|cx| InputState::new(window, cx).placeholder("description")); let sidebar_width = cx.global::().sidebar_width; let right_panel_width = cx.global::().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::().right_panel_visible; + let right_panel_tab = cx.global::().right_panel_tab; + let sidebar_collapsed = cx.global::().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 @@ -655,6 +782,10 @@ impl Tty7App { // 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); } }); @@ -757,6 +888,9 @@ impl Tty7App { sidebar_dragging: Rc::new(Cell::new(false)), right_panel_width: Rc::new(Cell::new(right_panel_width)), right_panel_dragging: Rc::new(Cell::new(false)), + right_panel_visible, + right_panel_tab, + sidebar_collapsed, sidebar_scroll: gpui::ScrollHandle::new(), reorder: Rc::new(RefCell::new(None)), sidebar_search, @@ -767,11 +901,21 @@ impl Tty7App { ssh_prompt: crate::ui::ssh_prompt::SshPromptState::new(cx), ssh_close_confirm: None, window_bounds: window.window_bounds().get_bounds(), + workspace, + alive_cache: RefCell::new(None), + workspace_rename: None, + window_title: std::cell::RefCell::new(String::new()), }; - // Bring the system tray up (icon + agent menu + poll loop). 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) { + // 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); } // Discover this machine's shells for the "+" dropdown off the UI thread @@ -818,53 +962,81 @@ impl Tty7App { }) .detach(); - // Confirm before the red traffic light closes the window. Closing quits - // the app, but the panes are *detached, not killed* — they keep running in - // the daemon and re-attach on the next launch — so the prompt reassures - // rather than warns. We veto the immediate close (return `false`), show the - // prompt, and quit only if the user picks "Close". A one-shot flag lets - // that post-confirm quit through should we be asked again, instead of - // looping the prompt. + // 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. 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. 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| { if close_confirmed.get() { return true; } - // From the home page (zero tabs) there are no running sessions to - // reassure about — prompting would be pure friction. Close directly, - // but still quit with the window: closing our only window without - // quitting leaves a windowless process sitting in the Dock that no - // longer responds to being clicked (#147). Deferred onto the next - // tick so the close itself completes first, same as the confirmed - // path below. - if weak_app + let last_window = crate::ui::windows::WindowRegistry::count(cx) <= 1; + let empty = weak_app .upgrade() - .is_some_and(|app| app.read(cx).tabs.is_empty()) - { - cx.spawn(async move |cx| { - let _ = cx.update(|cx| cx.quit()); - }) - .detach(); + .is_some_and(|app| app.read(cx).tabs.is_empty()); + + // Any window but the last, or an empty one with nothing to + // reassure about: detach and go. Prompting here would be friction. + if !last_window || empty { + 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()); + }) + .detach(); + } return true; } + 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 and will be \ - restored the next time you open tty7.", + "Your sessions keep running in the background. This \ + workspace will be waiting on the home page, and in the \ + workspace menu in the title bar, the next time you open \ + tty7.", ), &["Cancel", "Close"], cx, ); 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); - cx.update(|cx| cx.quit()); + let _ = cx.update(|cx| { + if let Some(app) = weak_app.upgrade() { + app.update(cx, |app, cx| app.detach_workspace(cx)); + } + cx.quit(); + }); } }) .detach(); @@ -878,7 +1050,7 @@ impl Tty7App { /// Snapshot the current tabs/active index into a `Session` and persist it. /// Called after every structural change; the write is a small synchronous /// JSON dump and any error is swallowed inside `Session::save`. - pub(crate) fn save_session(&self, cx: &App) { + pub(crate) fn save_session(&self, cx: &mut App) { let tabs: Vec = self .tabs .iter() @@ -886,13 +1058,178 @@ impl Tty7App { .collect(); // Zero tabs is a real state (the home page) and is persisted as such, so // the next launch comes back to it instead of a fresh shell. - if tabs.is_empty() { - Session::default().save(); + let active = if tabs.is_empty() { + 0 + } else { + self.active.min(tabs.len() - 1) + }; + let session = Session { active, tabs }; + // The store merges this into the other windows' workspaces and owns the + // write; the geometry rides along so reopening lands where we are now. + WorkspaceStore::record( + cx, + self.workspace, + session, + Some(WindowState::from_bounds(self.window_bounds)), + ); + } + + /// 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. + let name = if self.tabs.is_empty() { + WorkspaceStore::remove(cx, self.workspace); + String::new() + } else { + WorkspaceStore::close_window(cx, self.workspace); + // Read after the update so the name reflects what was just stored. + WorkspaceStore::all(cx) + .get(self.workspace) + .map(|w| w.display_name()) + .unwrap_or_default() + }; + crate::ui::windows::WindowRegistry::unregister(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); + // ...and the first time that happens, say it out loud once. Only for a + // workspace with something in it: putting away an empty window teaches + // nothing (and it was dropped outright above). + if !name.is_empty() { + crate::ui::windows::hint_detached(cx, &name); + } + } + + /// 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, + window: &mut Window, + cx: &mut Context, + ) { + crate::ui::windows::confirm_and_stop(cx, window, id); + cx.notify(); + } + + /// Delete a workspace: stop it *and* discard the saved layout. + pub(crate) fn delete_workspace( + &mut self, + id: WorkspaceId, + window: &mut Window, + cx: &mut Context, + ) { + crate::ui::windows::confirm_and_delete(cx, window, id); + 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, + window: &mut Window, + cx: &mut Context, + ) { + let Some((id, _open)) = crate::ui::windows::menu_order(cx).get(index).copied() else { + return; + }; + 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, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(handle) = crate::ui::windows::WindowRegistry::window_for(cx, id) { + let _ = handle.update(cx, |_, other, _| other.activate_window()); return; } - let active = self.active.min(tabs.len() - 1); - let session = Session { active, tabs }; - session.save(); + if self.tabs.is_empty() { + self.switch_workspace(id, window, cx); + } else { + crate::ui::windows::open(cx, Some(id)); + } + } + + /// 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, + window: &mut Window, + cx: &mut Context, + ) { + let previous = self.workspace; + if previous == id { + return; + } + if self.tabs.is_empty() { + WorkspaceStore::remove(cx, previous); + } else { + self.save_session(cx); + WorkspaceStore::close_window(cx, previous); + } + + let (claimed, session) = WorkspaceStore::claim(cx, Some(id)); + crate::ui::windows::WindowRegistry::rebind(cx, previous, claimed); + self.adopt_workspace(claimed, session, window, cx); + } + + /// 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, + session: Session, + window: &mut Window, + cx: &mut Context, + ) { + self.workspace = id; + let font_size = self.font_size; + let (tabs, active) = tabs_from_session(Some(session), font_size, window, cx); + 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 @@ -931,18 +1268,25 @@ impl Tty7App { // ── System tray (`ui::tray`) ──────────────────────────────────────────── - /// Snapshot every agent pane for the tray menu: brand name, status, and a - /// "where" line (cwd directory name + git branch). Most urgent first, so - /// the pane that needs the user tops the menu. Called by the tray's poll - /// loop once a second; the walk is a handful of entity reads. - pub(crate) fn tray_snapshot(&self, cx: &App) -> crate::ui::tray::TraySnapshot { + /// 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 + .leaves() + .iter() + .any(|l| l.entity_id().as_u64() == leaf_id) + }) + } + + /// 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 { use crate::core::cli_agent::AgentStatus; - let urgency = |s: AgentStatus| match s { - AgentStatus::Waiting => 3, - AgentStatus::Working => 2, - AgentStatus::Done => 1, - AgentStatus::Idle => 0, - }; let mut agents = Vec::new(); for tab in &self.tabs { for leaf in tab.pane.leaves() { @@ -971,11 +1315,7 @@ impl Tty7App { }); } } - agents.sort_by_key(|a| std::cmp::Reverse(urgency(a.status))); - crate::ui::tray::TraySnapshot { - agents, - notify_mode: cx.global::().notify_on_command_finish, - } + agents } /// Apply a tray menu click. Runs on the foreground executor with the @@ -1161,8 +1501,12 @@ impl Tty7App { match &restarted { Ok(()) => { let font_size = this.font_size; - let (tabs, active) = - tabs_from_session(Session::load(), font_size, window, cx); + // This window's own workspace only — the other windows + // rebuild themselves from theirs. + let saved = WorkspaceStore::all(cx) + .get(this.workspace) + .map(|w| w.session.clone()); + let (tabs, active) = tabs_from_session(saved, font_size, window, cx); this.tabs = tabs; this.active = active; } @@ -2094,15 +2438,18 @@ impl Tty7App { /// 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) { - let cfg = cx.global::(); - let (pos, collapsed) = match cfg.tab_bar_position { + let (pos, collapsed) = match cx.global::().tab_bar_position { TabBarPosition::Top => (TabBarPosition::Left, false), - TabBarPosition::Left => (TabBarPosition::Left, !cfg.sidebar_collapsed), + // 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; self.update_config(cx, |cfg| { cfg.tab_bar_position = pos; cfg.sidebar_collapsed = collapsed; }); + cx.notify(); } /// Whether the left rail is actually on screen: `Left` mode, not collapsed, @@ -2110,7 +2457,7 @@ impl Tty7App { /// 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::().tab_bar_position, TabBarPosition::Left) - && !cx.global::().sidebar_collapsed + && !self.sidebar_collapsed && !self.tabs.is_empty() } @@ -2222,7 +2569,30 @@ 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) + .filter(|w| !w.session.tabs.is_empty()) + .map(|w| w.display_name()) + .unwrap_or_else(|| "tty7".to_string()); + if *self.window_title.borrow() == title { + return; + } + window.set_window_title(&title); + *self.window_title.borrow_mut() = title; + } + 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. @@ -3022,6 +3392,59 @@ impl Tty7App { cx.notify(); } + /// The set of daemon panes currently alive, cached for [`ALIVE_TTL`]. + pub(crate) fn alive_panes_cached(&self) -> HashSet { + const ALIVE_TTL: std::time::Duration = std::time::Duration::from_millis(2000); + let mut slot = self.alive_cache.borrow_mut(); + if let Some((taken, panes)) = slot.as_ref() + && taken.elapsed() < ALIVE_TTL + { + return panes.clone(); + } + let panes = alive_panes(); + *slot = Some((std::time::Instant::now(), panes.clone())); + panes + } + + /// 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) { + let current = WorkspaceStore::all(cx) + .get(self.workspace) + .map(|w| w.display_name()) + .unwrap_or_default(); + let input = cx.new(|cx| InputState::new(window, cx).default_value(current)); + input.update(cx, |state, cx| state.focus(window, cx)); + let subs = vec![cx.subscribe_in( + &input, + window, + |this, _input, ev: &InputEvent, window, cx| match ev { + InputEvent::PressEnter { .. } | InputEvent::Blur => { + this.commit_workspace_rename(window, cx) + } + _ => {} + }, + )]; + self.workspace_rename = Some(WorkspaceRename { input, _subs: subs }); + 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) { + let Some(rename) = self.workspace_rename.take() else { + return; + }; + let value = rename.input.read(cx).value().trim().to_string(); + let id = self.workspace; + WorkspaceStore::rename(cx, id, (!value.is_empty()).then_some(value)); + crate::ui::windows::refresh_menu(cx); + self.sync_window_title(window, cx); + self.focus_active(window, cx); + 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 @@ -3035,6 +3458,7 @@ impl Tty7App { tab.name = if value.is_empty() { None } else { Some(value) }; } self.save_session(cx); + crate::ui::windows::refresh_menu(cx); self.focus_active(window, cx); cx.notify(); } @@ -3149,6 +3573,13 @@ impl Tty7App { use CommandKind::*; match kind { NewTab => self.new_tab(window, cx), + NewWorkspace => crate::ui::windows::open(cx, None), + // The opener never reaches here (the palette swaps its own list), + // but the match must stay exhaustive. + OpenWorkspacePicker => {} + SwitchToWorkspace(id) => self.reveal_workspace(id, window, cx), + StopWorkspace => self.stop_workspace(self.workspace, window, cx), + DeleteWorkspace => self.delete_workspace(self.workspace, window, cx), SplitRight => self.split(Axis::Horizontal, window, cx), SplitDown => self.split(Axis::Vertical, window, cx), ClosePane => self.close_pane(window, cx), @@ -4516,7 +4947,7 @@ impl Render for Tty7App { // 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 && !cx.global::().sidebar_collapsed; + 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). @@ -4726,6 +5157,50 @@ impl Render for Tty7App { .text_color(cx.theme().foreground) .on_modifiers_changed(cx.listener(Self::on_modifiers_changed)) .on_action(cx.listener(|this, _: &NewTab, window, cx| this.new_tab(window, cx))) + .on_action(cx.listener(|this, _: &SelectWorkspace1, window, cx| { + this.select_workspace_slot(0, window, cx) + })) + .on_action(cx.listener(|this, _: &SelectWorkspace2, window, cx| { + this.select_workspace_slot(1, window, cx) + })) + .on_action(cx.listener(|this, _: &SelectWorkspace3, window, cx| { + this.select_workspace_slot(2, window, cx) + })) + .on_action(cx.listener(|this, _: &SelectWorkspace4, window, cx| { + this.select_workspace_slot(3, window, cx) + })) + .on_action(cx.listener(|this, _: &SelectWorkspace5, window, cx| { + this.select_workspace_slot(4, window, cx) + })) + .on_action(cx.listener(|this, _: &SelectWorkspace6, window, cx| { + this.select_workspace_slot(5, window, cx) + })) + .on_action(cx.listener(|this, _: &SelectWorkspace7, window, cx| { + this.select_workspace_slot(6, window, cx) + })) + .on_action(cx.listener(|this, _: &SelectWorkspace8, window, cx| { + this.select_workspace_slot(7, window, cx) + })) + .on_action(cx.listener(|this, _: &SelectWorkspace9, window, cx| { + this.select_workspace_slot(8, window, cx) + })) + .on_action(cx.listener(|this, _: &RenameWorkspace, window, cx| { + this.start_workspace_rename(window, cx) + })) + .on_action(cx.listener(|this, _: &StopWorkspace, window, cx| { + let id = this.workspace; + this.stop_workspace(id, window, cx); + })) + .on_action(cx.listener(|this, _: &DeleteWorkspace, window, cx| { + let id = this.workspace; + 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. @@ -4994,7 +5469,7 @@ fn pane_to_session(pane: &Pane, cx: &App) -> SessionPane { /// Set of daemon pane ids currently alive, used by `session_to_pane` to decide /// per leaf whether to re-`attach` or `spawn`. Computed once per restore from the /// daemon's `List`; empty (→ all-fresh) when the daemon is unreachable. -fn alive_panes() -> std::collections::HashSet { +pub(crate) fn alive_panes() -> std::collections::HashSet { crate::terminal::RemoteTerminal::list_panes() .into_iter() .filter(|p| p.alive) @@ -5005,7 +5480,7 @@ fn alive_panes() -> std::collections::HashSet { /// 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::new`) and the daemon-restart rebuild (`restart_daemon`), so the two +/// (`Tty7App::for_workspace`) and the daemon-restart rebuild (`restart_daemon`), so the two /// stay in lockstep. fn tabs_from_session( session: Option, @@ -5465,7 +5940,8 @@ mod keybinding_gpui_tests { // 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(Some(Session::default()), window, cx)); + let app = + cx.new(|cx| Tty7App::with_session(None, Some(Session::default()), window, cx)); gpui_component::Root::new(app, window, cx) }); window diff --git a/src/ui/code_editor.rs b/src/ui/code_editor.rs index 7e431837..645cf793 100644 --- a/src/ui/code_editor.rs +++ b/src/ui/code_editor.rs @@ -822,7 +822,7 @@ impl Tty7App { .items_center() .gap_1p5() .pl(px(lead)) - .pr(px(crate::ui::app::CONTENT_INSET - crate::ui::app::TILE_PAD)) + .pr(px(crate::ui::app::tile_trailing_inset())) .border_b_1() .border_color(cx.theme().border) .child( @@ -847,15 +847,17 @@ impl Tty7App { ) }) .child( - crate::ui::tab_strip::chrome_tile( - Button::new("editor-panel-close") - .icon(Icon::new(IconName::Close).size(px(15.))), + 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, false, cx, ) - .xsmall() - .w(px(30.)) - .h(px(30.)) .rounded_lg() .tooltip("Back to Terminal (Esc)") .on_click(cx.listener(|this, _, window, cx| { diff --git a/src/ui/diff_overlay.rs b/src/ui/diff_overlay.rs index 2f086ec2..340acd7d 100644 --- a/src/ui/diff_overlay.rs +++ b/src/ui/diff_overlay.rs @@ -331,8 +331,8 @@ impl Tty7App { .flex_shrink_0() .h(px(crate::ui::app::TITLE_BAR_HEIGHT)) .pl(px(lead)) - // Trailing tile aligns on its glyph, like every other corner control. - .pr(px(crate::ui::app::CONTENT_INSET - crate::ui::app::TILE_PAD)) + // Trailing tile aligns on its glyph's ink, like every corner control. + .pr(px(crate::ui::app::tile_trailing_inset())) .gap_2() .items_center() .border_b_1() @@ -438,12 +438,17 @@ impl Tty7App { ) .child(div().flex_1()) .child( - crate::ui::tab_strip::chrome_tile( - Button::new("diff-overlay-close").icon(IconName::Close), + 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, false, cx, ) - .small() + .rounded_lg() .tooltip("Close Diff (Esc)") .on_click(cx.listener(|this, _, window, cx| { this.close_diff_overlay(window, cx); diff --git a/src/ui/hints.rs b/src/ui/hints.rs index 1d3ad080..ca86f68e 100644 --- a/src/ui/hints.rs +++ b/src/ui/hints.rs @@ -6,7 +6,7 @@ //! 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::new` (it fires even for keys the terminal +//! 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). //! @@ -96,7 +96,7 @@ impl Tty7App { } /// Hide the badges and invalidate any pending reveal. Called on every real - /// keypress (the interceptor in `Tty7App::new`) so a chord like ⌘C never + /// 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 @@ -150,8 +150,9 @@ mod gpui_tests { // Inject the zero-tab session (the persisted home-page state) so the // app builds without spawning a terminal — and without reading the // on-disk `session.json`. - let window = - cx.add_window(|window, cx| Tty7App::with_session(Some(Session::default()), window, cx)); + 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. diff --git a/src/ui/home.rs b/src/ui/home.rs index e6c1cef3..64e58a9a 100644 --- a/src/ui/home.rs +++ b/src/ui/home.rs @@ -14,10 +14,12 @@ use gpui::{ Animation, AnimationExt as _, App, Context, KeyDownEvent, Keystroke, MouseButton, MouseDownEvent, div, prelude::*, px, }; +use gpui_component::button::{Button, ButtonVariants as _}; use gpui_component::kbd::Kbd; -use gpui_component::{ActiveTheme as _, h_flex, v_flex}; +use gpui_component::menu::{ContextMenuExt as _, PopupMenuItem}; +use gpui_component::{ActiveTheme as _, IconName, Sizable as _, h_flex, v_flex}; -use crate::core::session::{SessionPane, SessionTab}; +use crate::core::session::{SessionPane, SessionTab, WorkspaceId, WorkspaceStore}; use crate::ui::app::Tty7App; /// The "tty7" logotype in half-block characters. Rendered line-by-line in the @@ -80,6 +82,72 @@ fn clamp_label(s: &str) -> String { } } +/// Most closed workspaces to offer on the home page. The picker is a "get back +/// to what you were doing" affordance, not a session manager — a long tail of +/// months-old workspaces would bury the recent ones and turn the page into a +/// wall. The rest stay in `session.json` and reachable from the command palette. +const MAX_PICKER_ROWS: usize = 6; + +/// Longest workspace path shown before the front is elided. +pub(crate) const PICKER_PATH_MAX: usize = 34; + +/// One closed workspace, flattened for rendering. Owned (not a `&Workspace`) +/// so collecting it releases the borrow on the global store before the row +/// closures capture `cx`. +struct PickerRow { + id: WorkspaceId, + name: String, + path: String, + panes: usize, + when: String, + /// Whether any of its panes are still running in the daemon. A stopped + /// workspace still lists its panes — they are the *saved* layout, not live + /// shells — so the count alone can't say which of the two this is. + live: bool, +} + +/// 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(); + } + let secs = now - then; + match secs { + s if s < 60 => "just now".to_string(), + s if s < 3600 => format!("{} min ago", s / 60), + s if s < 7200 => "1 hour ago".to_string(), + s if s < 86_400 => format!("{} hours ago", s / 3600), + s if s < 172_800 => "yesterday".to_string(), + s if s < 604_800 => format!("{} days ago", s / 86_400), + _ => "over a week ago".to_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") { + Ok(home) if !home.is_empty() && text.starts_with(&home) => { + format!("~{}", &text[home.len()..]) + } + _ => text.to_string(), + }; + if shortened.chars().count() <= PICKER_PATH_MAX { + return shortened; + } + let tail: String = shortened + .chars() + .skip(shortened.chars().count() - PICKER_PATH_MAX) + .collect(); + 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 @@ -145,6 +213,13 @@ impl Tty7App { ); } + // Workspaces the user closed earlier. Closing a window detaches its + // workspace rather than ending it — the panes keep running in the + // daemon — so this list is how they come back. It sits directly under + // the logo, above the shortcut watermark: getting back to real work + // outranks learning a keybinding. + let picker = self.render_workspace_picker(cx); + v_flex() .id("home-page") .track_focus(&self.home_focus) @@ -164,6 +239,7 @@ impl Tty7App { } })) .child(logo) + .children(picker) .child(list) // Ease the page in rather than popping it — closing the last tab // should feel like arriving somewhere, not like a glitch. @@ -173,6 +249,218 @@ impl Tty7App { |page, delta| page.opacity(delta), ) } + + /// The closed-workspace picker, or `None` when there is nothing to reopen + /// (first run, or every workspace is already on screen) — an empty panel + /// would just be clutter on a page whose point is calm. + fn render_workspace_picker(&self, cx: &mut Context) -> Option> { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + // Collect owned rows first: this releases the borrow on the workspace + // store before the per-row click handlers capture `cx`. + let alive = self.alive_panes_cached(); + let rows: Vec = WorkspaceStore::all(cx) + .closed_workspaces() + .into_iter() + .take(MAX_PICKER_ROWS) + .map(|w| PickerRow { + live: w.pane_ids().iter().any(|id| alive.contains(id)), + id: w.id, + name: clamp_label(&w.display_name()), + path: w + .dominant_repo() + .or_else(|| w.first_cwd()) + .map(|p| display_path(&p)) + .unwrap_or_default(), + panes: w.pane_count(), + when: relative_time(now, w.last_active), + }) + .collect(); + if rows.is_empty() { + return None; + } + + // Copied out rather than held as a `&Theme`: the rows below hand `cx` + // straight to the shared avatar builder, and a live borrow of the theme + // would be in its way. + let (muted, foreground, popover, border) = { + let theme = cx.theme(); + ( + theme.muted_foreground, + theme.foreground, + theme.popover, + theme.border, + ) + }; + // The established popup language: a solid 10px-radius panel with inset + // soft-grey pill highlights — no translucency, no saturated accent. + let hover_fill = cx.theme().accent.opacity(0.6); + + let mut panel = v_flex() + .w(px(360.)) + .p(px(6.)) + .gap(px(2.)) + .rounded(px(10.)) + .bg(popover) + .border_1() + .border_color(border) + // The page behind us spawns a terminal on *any* left click (the + // empty window's whole job). Without this, a click meant for a row + // bubbles out to that handler, which swaps the home page away + // before the row's own `on_click` — mouse *up* — ever fires. The + // picker would look like it did nothing but open a stray terminal. + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()); + + for row in rows { + let id = row.id; + let live = row.live; + // The context menu builds outside `cx.listener`, so it reaches the + // app the way the tab context menu does — through a weak handle. + let menu_app = cx.entity().downgrade(); + let menu_app2 = menu_app.clone(); + panel = panel.child( + h_flex() + .id(("workspace-row", id.element_key() as usize)) + // Named group so the row's ✕ can reveal itself on hover of + // the whole row, not just of the glyph's own few pixels. + .group("workspace-row") + .items_center() + .justify_between() + .gap_2() + .px(px(10.)) + .py(px(7.)) + .rounded(px(6.)) + .hover(|row| row.bg(hover_fill)) + .cursor_pointer() + // The picker only renders on the home page, so this window + // is empty: swap it over in place rather than opening a + // second window and stranding this blank one. If the + // workspace somehow already has a window, focus that. + // ⌘-click opens in a *new* window, plain click swaps this + // one over — the same gesture browsers and Finder use, so + // the user never has to decide "which container" before + // picking what they want to see. + .on_click(cx.listener(move |this, ev: &gpui::ClickEvent, window, cx| { + if ev.modifiers().platform { + crate::ui::windows::open(cx, Some(id)); + } else { + this.reveal_workspace(id, window, cx); + } + })) + .child( + h_flex() + .items_center() + .gap_2() + .overflow_hidden() + // The same monogram badge the title-bar chip and + // the workspace menu use, with liveness riding its + // corner: a dot means the shells are still running + // in the daemon and reopening reattaches to them. + // No dot means the layout is all that is left and + // reopening spawns fresh — the app's existing + // convention that a resting thing is just its mark. + .child(crate::ui::tab_strip::workspace_avatar( + // Never "current": this page only renders with + // zero tabs, so every row in it is a workspace + // you are *not* looking at. + &row.name, row.live, false, 26., cx, + )) + .child( + v_flex() + .gap(px(1.)) + .overflow_hidden() + .child(div().text_sm().text_color(foreground).child(row.name)) + .child(div().text_xs().text_color(muted).child(row.path)), + ), + ) + .child( + h_flex() + .items_center() + .gap_2() + .flex_shrink_0() + .child( + v_flex() + .items_end() + .gap(px(1.)) + .text_xs() + .text_color(muted) + .child(if row.panes == 1 { + "1 pane".to_string() + } else { + format!("{} panes", row.panes) + }) + .child(row.when), + ) + // One hover action, not a cluster: the sidebar row + // — the busiest row in the app — reveals exactly + // one and keeps the rest on its right-click menu. + // Deleting is the irreversible one, which is + // precisely why it hides until aimed at rather than + // sitting out in the open; stopping is a click away + // on the same row's context menu. + .child( + div() + .invisible() + .group_hover("workspace-row", |x| x.visible()) + // Without this the press also reaches the + // row underneath and opens the very + // workspace being thrown away. + .on_mouse_down(MouseButton::Left, |_, _, cx| { + cx.stop_propagation() + }) + .child( + Button::new(( + "workspace-delete", + id.element_key() as usize, + )) + .icon(IconName::Close) + .ghost() + .xsmall() + .on_click( + cx.listener(move |this, _, window, cx| { + this.delete_workspace(id, window, cx); + }), + ), + ), + ), + ) + // The rest of the row's actions. A right-click menu is what + // every other list in this app uses for its second-tier + // actions (see the tab rows), and it works here because the + // picker is a page — inside the title-bar workspace menu it + // can't be done, since a popup dismisses on any mouse-down + // outside its own bounds and would tear itself down before + // the nested menu's click ever landed. + .context_menu(move |menu, _window, _cx| { + let app = menu_app.clone(); + menu.item( + PopupMenuItem::new("Stop Workspace") + // Nothing to stop on a workspace whose shells + // are already gone. + .disabled(!live) + .on_click(move |_, window, cx| { + let _ = app + .update(cx, |this, cx| this.stop_workspace(id, window, cx)); + }), + ) + .separator() + .item( + PopupMenuItem::new("Delete Workspace…").on_click({ + let app = menu_app2.clone(); + move |_, window, cx| { + let _ = app.update(cx, |this, cx| { + this.delete_workspace(id, window, cx) + }); + } + }), + ) + }), + ); + } + Some(panel) + } } #[cfg(test)] @@ -263,6 +551,55 @@ mod tests { assert!(label.ends_with('…')); } + #[test] + fn relative_time_reads_coarsely_across_the_ranges() { + let now = 10_000_000u64; + assert_eq!(relative_time(now, now), "just now"); + assert_eq!(relative_time(now, now - 30), "just now"); + assert_eq!(relative_time(now, now - 120), "2 min ago"); + assert_eq!(relative_time(now, now - 3600), "1 hour ago"); + assert_eq!(relative_time(now, now - 4 * 3600), "4 hours ago"); + assert_eq!(relative_time(now, now - 90_000), "yesterday"); + assert_eq!(relative_time(now, now - 3 * 86_400), "3 days ago"); + assert_eq!(relative_time(now, now - 30 * 86_400), "over a week ago"); + } + + #[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") }; + + assert_eq!( + 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", + )); + assert!(long.starts_with('…'), "{long} should be front-elided"); + assert!(long.ends_with("thing"), "{long} must keep the tail"); + assert_eq!(long.chars().count(), PICKER_PATH_MAX + 1); + + match saved { + Some(home) => unsafe { std::env::set_var("HOME", home) }, + None => unsafe { std::env::remove_var("HOME") }, + } + } + #[test] fn logo_rows_never_exceed_the_first_row_width() { // The logotype renders as stacked left-aligned text lines; the first diff --git a/src/ui/keymap.rs b/src/ui/keymap.rs index 2cd2c402..e09ed538 100644 --- a/src/ui/keymap.rs +++ b/src/ui/keymap.rs @@ -114,7 +114,15 @@ pub(crate) fn default_bindings() -> Vec<(&'static str, &'static str)> { // and Linux without binding to the Win/Super key, which the OS reserves. vec![ ("NewTab", "secondary-t"), + ("NewWorkspace", "secondary-shift-n"), ("CloseActiveTab", "secondary-w"), + // 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", ""), ("SplitRight", "secondary-d"), ("SplitDown", "secondary-shift-d"), ("FocusNextPane", "secondary-]"), @@ -145,6 +153,19 @@ 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", ""), + ("SelectWorkspace4", ""), + ("SelectWorkspace5", ""), + ("SelectWorkspace6", ""), + ("SelectWorkspace7", ""), + ("SelectWorkspace8", ""), + ("SelectWorkspace9", ""), ("IncreaseFontSize", "secondary-="), ("DecreaseFontSize", "secondary--"), ("ResetFontSize", "secondary-0"), @@ -458,6 +479,10 @@ fn keystroke_is_valid(s: &str) -> bool { fn make_binding(action: &str, keystroke: &str) -> Option { Some(match action { "NewTab" => KeyBinding::new(keystroke, NewTab, None), + "NewWorkspace" => KeyBinding::new(keystroke, NewWorkspace, None), + "StopWorkspace" => KeyBinding::new(keystroke, StopWorkspace, None), + "DeleteWorkspace" => KeyBinding::new(keystroke, DeleteWorkspace, None), + "RenameWorkspace" => KeyBinding::new(keystroke, RenameWorkspace, None), "CloseActiveTab" => KeyBinding::new(keystroke, CloseActiveTab, None), "SplitRight" => KeyBinding::new(keystroke, SplitRight, None), "SplitDown" => KeyBinding::new(keystroke, SplitDown, None), @@ -484,6 +509,15 @@ fn make_binding(action: &str, keystroke: &str) -> Option { "ActivateTab7" => KeyBinding::new(keystroke, ActivateTab7, None), "ActivateTab8" => KeyBinding::new(keystroke, ActivateTab8, None), "ActivateTab9" => KeyBinding::new(keystroke, ActivateTab9, None), + "SelectWorkspace1" => KeyBinding::new(keystroke, SelectWorkspace1, None), + "SelectWorkspace2" => KeyBinding::new(keystroke, SelectWorkspace2, None), + "SelectWorkspace3" => KeyBinding::new(keystroke, SelectWorkspace3, None), + "SelectWorkspace4" => KeyBinding::new(keystroke, SelectWorkspace4, None), + "SelectWorkspace5" => KeyBinding::new(keystroke, SelectWorkspace5, None), + "SelectWorkspace6" => KeyBinding::new(keystroke, SelectWorkspace6, None), + "SelectWorkspace7" => KeyBinding::new(keystroke, SelectWorkspace7, None), + "SelectWorkspace8" => KeyBinding::new(keystroke, SelectWorkspace8, None), + "SelectWorkspace9" => KeyBinding::new(keystroke, SelectWorkspace9, None), "IncreaseFontSize" => KeyBinding::new(keystroke, IncreaseFontSize, None), "DecreaseFontSize" => KeyBinding::new(keystroke, DecreaseFontSize, None), "ResetFontSize" => KeyBinding::new(keystroke, ResetFontSize, None), diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 9356807a..71e4a3cb 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -28,4 +28,5 @@ pub mod tab_sidebar; pub mod tab_strip; pub mod theme; pub mod tray; +pub mod windows; pub mod worktree_prompt; diff --git a/src/ui/palette.rs b/src/ui/palette.rs index 5370d080..1337874d 100644 --- a/src/ui/palette.rs +++ b/src/ui/palette.rs @@ -29,6 +29,20 @@ use crate::core::ssh_profile::parse_quick_connect; #[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, + /// Show `id`'s workspace. In a window that already has one open elsewhere + /// this focuses that window; otherwise the current window swaps over to it + /// and its previous workspace detaches into the picker. + SwitchToWorkspace(crate::core::session::WorkspaceId), + /// 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, @@ -120,6 +134,10 @@ impl CommandKind { use CommandKind::*; Some(match self { NewTab => "NewTab", + NewWorkspace => "NewWorkspace", + OpenWorkspacePicker | SwitchToWorkspace(_) => return None, + StopWorkspace => "StopWorkspace", + DeleteWorkspace => "DeleteWorkspace", SplitRight => "SplitRight", SplitDown => "SplitDown", ClosePane => "CloseActiveTab", @@ -214,6 +232,10 @@ impl Command { use CommandKind::*; vec![ Command::new("New Tab", NewTab), + Command::new("New Workspace", NewWorkspace), + Command::new("Switch Workspace…", OpenWorkspacePicker), + Command::new("Stop Workspace…", StopWorkspace), + Command::new("Delete Workspace…", DeleteWorkspace), Command::new("Split Right", SplitRight), Command::new("Split Down", SplitDown), Command::new("Close Pane/Tab", ClosePane), @@ -272,6 +294,40 @@ impl Command { ] } + /// The workspace sub-list: every workspace tty7 knows about, most recently + /// active first. Open ones are labelled as such — picking one focuses its + /// window rather than opening a second one onto the same panes. + pub fn workspace_commands(cx: &App) -> Vec { + use crate::core::session::WorkspaceStore; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let mut all: Vec<_> = WorkspaceStore::all(cx).workspaces.iter().collect(); + all.sort_by(|a, b| b.last_active.cmp(&a.last_active)); + all.into_iter() + .map(|w| { + let state = if w.open { + "open".to_string() + } else { + crate::ui::home::relative_time(now, w.last_active) + }; + let path = w + .dominant_repo() + .or_else(|| w.first_cwd()) + .map(|p| crate::ui::home::display_path(&p)) + .unwrap_or_default(); + let subtitle = if path.is_empty() { + state + } else { + format!("{path} · {state}") + }; + Command::new(w.display_name(), CommandKind::SwitchToWorkspace(w.id)) + .with_subtitle(subtitle) + }) + .collect() + } + /// 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 @@ -560,6 +616,7 @@ enum PaletteMenu { Root, Theme, SshConnect, + Workspace, } /// The command palette as a self-contained view. It owns the `ListState` @@ -647,7 +704,7 @@ impl PaletteView { match self.menu { PaletteMenu::SshConnect => "user@host [-p 2222 -J jump]", PaletteMenu::Root => "Search or type user@host to connect…", - PaletteMenu::Theme => "Search…", + PaletteMenu::Theme | PaletteMenu::Workspace => "Search…", } } @@ -685,6 +742,11 @@ impl PaletteView { self.menu = PaletteMenu::SshConnect; self.show_ssh_connect(window, cx); } + Some(CommandKind::OpenWorkspacePicker) => { + self.menu = PaletteMenu::Workspace; + let workspaces = Command::workspace_commands(cx); + self.show(workspaces, window, cx); + } Some(CommandKind::OpenSshConnect(input)) if input.trim().is_empty() => {} Some(kind) => cx.emit(PaletteEvent::Confirm(kind)), None => cx.emit(PaletteEvent::Dismiss), diff --git a/src/ui/right_panel.rs b/src/ui/right_panel.rs index 43e8b7cd..bfaef1f1 100644 --- a/src/ui/right_panel.rs +++ b/src/ui/right_panel.rs @@ -26,7 +26,10 @@ use std::rc::Rc; use crate::core::config::{Config, RightPanelTab}; use crate::daemon::protocol::PaneProcs; use crate::terminal::git_diff::{self, DiffSnapshot}; -use crate::ui::app::{CONTENT_INSET, Tty7App}; +use crate::ui::app::{ + CONTENT_INSET, TILE_GLYPH_SM, TILE_SIZE_SM, Tty7App, tile_trailing_inset, + tile_trailing_inset_sm, +}; /// 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 @@ -82,8 +85,8 @@ 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 { - cx.global::().right_panel_visible && !self.tabs.is_empty() + 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 @@ -98,19 +101,25 @@ impl Tty7App { self.right_panel_width.get().clamp(MIN_WIDTH, max) } - /// `ToggleRightPanel` (⌘J). + /// `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) { - let next = !cx.global::().right_panel_visible; + let next = !self.right_panel_visible; + self.right_panel_visible = next; self.update_config(cx, |cfg| cfg.right_panel_visible = next); + 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.right_panel_tab = tab; + self.right_panel_visible = true; self.update_config(cx, |cfg| { cfg.right_panel_tab = tab; cfg.right_panel_visible = true; }); + cx.notify(); } /// The docked column, or `None` while the panel is closed. @@ -145,7 +154,7 @@ impl Tty7App { return None; } let width = self.right_panel_px(window, cx); - let tab = cx.global::().right_panel_tab; + let tab = self.right_panel_tab; let body = match tab { RightPanelTab::Info => self.render_panel_info(window, cx), @@ -212,7 +221,7 @@ impl Tty7App { .on_double_click(|_, window, _| window.titlebar_double_click()) .items_center() .gap(px(2.)) - .pl(px(CONTENT_INSET - crate::ui::app::TILE_PAD)) + .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 @@ -346,9 +355,11 @@ impl Tty7App { .justify_between() .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. + // window; a label-only header just takes the plain inset. `_SM` + // because what hangs here is a body-scale tile, whose glyph sits a + // different distance inside its box than the chrome's does. .pr(px(if trailing.is_some() { - CONTENT_INSET - crate::ui::app::TILE_PAD + tile_trailing_inset_sm() } else { CONTENT_INSET })) @@ -382,14 +393,13 @@ impl Tty7App { /// so a manual refresh is a button that does what already happened. fn files_controls(&self, cx: &mut Context) -> AnyElement { let show_hidden = self.file_tree.show_hidden; - crate::ui::tab_strip::chrome_tile( - Button::new("panel-tree-hidden").icon(Icon::new(IconName::Eye).size(px(13.))), + crate::ui::tab_strip::chrome_tile_sized( + Button::new("panel-tree-hidden").icon(Icon::new(IconName::Eye)), + TILE_SIZE_SM, + TILE_GLYPH_SM, show_hidden, cx, ) - .xsmall() - .w(px(24.)) - .h(px(24.)) .rounded_md() .tooltip(if show_hidden { "Hide dotfiles" @@ -595,18 +605,16 @@ impl Tty7App { }; h_flex() .gap(px(2.)) - .px(px(CONTENT_INSET - crate::ui::app::TILE_PAD)) + .px(px(tile_trailing_inset_sm())) .pt(px(6.)) .child( - crate::ui::tab_strip::chrome_tile( - Button::new("panel-info-reveal") - .icon(Icon::new(IconName::FolderOpen).size(px(13.))), + crate::ui::tab_strip::chrome_tile_sized( + Button::new("panel-info-reveal").icon(Icon::new(IconName::FolderOpen)), + TILE_SIZE_SM, + TILE_GLYPH_SM, false, cx, ) - .xsmall() - .w(px(24.)) - .h(px(24.)) .rounded_md() .tooltip(reveal_label) .on_click({ @@ -615,15 +623,13 @@ impl Tty7App { }), ) .child( - crate::ui::tab_strip::chrome_tile( - Button::new("panel-info-copy-path") - .icon(Icon::new(IconName::Copy).size(px(13.))), + crate::ui::tab_strip::chrome_tile_sized( + Button::new("panel-info-copy-path").icon(Icon::new(IconName::Copy)), + TILE_SIZE_SM, + TILE_GLYPH_SM, false, cx, ) - .xsmall() - .w(px(24.)) - .h(px(24.)) .rounded_md() .tooltip("Copy Path") .on_click(move |_, _window, cx| { @@ -859,9 +865,10 @@ impl Tty7App { app.loopback_panel.managed = managed; } cx.notify(); - let cfg = cx.global::(); + // This window's own panel state, not the config's: another + // window closing its panel must not stop our poll. let wanted = - cfg.right_panel_visible && cfg.right_panel_tab == RightPanelTab::Info; + 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; @@ -879,8 +886,7 @@ impl Tty7App { if app.right_panel.procs_gen != generation { return; } - let cfg = cx.global::(); - let wanted = cfg.right_panel_visible && cfg.right_panel_tab == RightPanelTab::Info; + 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 diff --git a/src/ui/settings.rs b/src/ui/settings.rs index e7515b6f..07ac80e7 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -37,7 +37,10 @@ use crate::core::keychain::CredentialRef; use crate::core::ssh_profile::{ Algorithms, AuthMode, ForwardKind, ForwardRule, HostPort, SshProfile, to_connect_string, }; -use crate::ui::app::{FONT_SIZE_STEP, LINE_HEIGHT_STEP, ThemeEdit, Tty7App}; +use crate::ui::app::{ + FONT_SIZE_STEP, LINE_HEIGHT_STEP, TILE_GLYPH_LINE, TILE_SIZE, TITLE_BAR_HEIGHT, ThemeEdit, + Tty7App, +}; use crate::ui::presets; /// Which section of the settings panel is currently selected in the sidebar. @@ -925,23 +928,35 @@ impl Tty7App { // and carries its own ✕, so keeping this one would stack two ✕ there. .when(!show_theme_panel, |r| { r.child( - // Sized like the title bar's "⋯" (30px, 15px glyph, - // `rounded_lg`), because it stands in the same corner: a - // `small` icon button is 24px, which reads undersized next - // to the 34px window-control tiles this spot belongs to. - // `top` centres it in the title bar's band — (40 − 30) / 2. - div().absolute().top(px(5.)).right(px(10.)).occlude().child( - Button::new("settings-close") - .icon(Icon::new(IconName::Close).size(px(15.))) - .ghost() - .xsmall() - .w(px(30.)) - .h(px(30.)) - .rounded_lg() - .on_click( - cx.listener(|this, _, window, cx| this.close_settings(window, cx)), - ), - ), + // 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.)) + .right(px(10.)) + .occlude() + .child( + 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 + )) + .w(px(TILE_SIZE)) + .h(px(TILE_SIZE)) + .rounded_lg() + .on_click(cx.listener(|this, _, window, cx| { + this.close_settings(window, cx) + })), + ), ) }); diff --git a/src/ui/sftp.rs b/src/ui/sftp.rs index a03fdd74..626f0c99 100644 --- a/src/ui/sftp.rs +++ b/src/ui/sftp.rs @@ -257,12 +257,12 @@ impl Tty7App { /// 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) { - use crate::core::config::{Config, RightPanelTab}; - // The config, not `right_panel_open`: this toggles the same preference - // `toggle_right_panel` does, so the two agree on what "open" means even - // with no tabs to render into. - let cfg = cx.global::(); - if cfg.right_panel_visible && cfg.right_panel_tab == RightPanelTab::Files { + 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; } @@ -1815,7 +1815,8 @@ mod gpui_tests { // 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(Some(Session::default()), window, cx)); + let app = + cx.new(|cx| Tty7App::with_session(None, Some(Session::default()), window, cx)); gpui_component::Root::new(app, window, cx) }); cx.background_executor.run_until_parked(); @@ -1831,10 +1832,13 @@ mod gpui_tests { (app, vcx) } - fn panel(vcx: &mut VisualTestContext) -> (bool, RightPanelTab) { + /// 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, vcx: &mut VisualTestContext) -> (bool, RightPanelTab) { vcx.update(|_, cx| { - let cfg = cx.global::(); - (cfg.right_panel_visible, cfg.right_panel_tab) + let app = app.read(cx); + (app.right_panel_visible, app.right_panel_tab) }) } @@ -1847,18 +1851,18 @@ mod gpui_tests { // From closed: opens the panel on Files. app.update_in(&mut vcx, |app, window, cx| { - app.update_config(cx, |cfg| cfg.right_panel_visible = false); + app.right_panel_visible = false; app.toggle_sftp(window, cx); }); - assert_eq!(panel(&mut vcx), (true, RightPanelTab::Files)); + 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(&mut vcx).0, "second press should close"); + 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(&mut vcx), (true, RightPanelTab::Files)); + assert_eq!(panel(&app, &mut vcx), (true, RightPanelTab::Files)); } /// Open on another tab, `ToggleSftp` is still "take me there" — it switches to @@ -1870,6 +1874,6 @@ mod gpui_tests { app.set_right_panel_tab(RightPanelTab::Info, cx); app.toggle_sftp(window, cx); }); - assert_eq!(panel(&mut vcx), (true, RightPanelTab::Files)); + assert_eq!(panel(&app, &mut vcx), (true, RightPanelTab::Files)); } } diff --git a/src/ui/tab_sidebar.rs b/src/ui/tab_sidebar.rs index 4f39ff5a..fdb7a76d 100644 --- a/src/ui/tab_sidebar.rs +++ b/src/ui/tab_sidebar.rs @@ -753,8 +753,8 @@ impl Tty7App { .items_center() .justify_end() .gap(px(2.)) - // Glyph, not hit box, on the content edge — see `TILE_PAD`. - .pr(px(crate::ui::app::CONTENT_INSET - crate::ui::app::TILE_PAD)) + // Glyph's ink, not hit box, on the content edge — see `TILE_PAD`. + .pr(px(crate::ui::app::tile_trailing_inset())) // 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 @@ -769,15 +769,13 @@ impl Tty7App { // `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( - Button::new("sidebar-add") - .icon(Icon::new(IconName::Plus).size(px(18.))), + crate::ui::tab_strip::chrome_tile_sized( + Button::new("sidebar-add").icon(Icon::new(IconName::Plus)), + crate::ui::app::TILE_SIZE, + crate::ui::app::TILE_GLYPH_LINE, false, cx, ) - .xsmall() - .w(px(32.)) - .h(px(32.)) .rounded_lg(), cx, ), @@ -787,13 +785,10 @@ impl Tty7App { div().occlude().flex_shrink_0().child( crate::ui::tab_strip::chrome_tile( Button::new("sidebar-collapse") - .icon(Icon::empty().path("icons/panel-left.svg").size(px(18.))), + .icon(Icon::empty().path("icons/panel-left.svg")), false, cx, ) - .xsmall() - .w(px(32.)) - .h(px(32.)) .rounded_lg() .tooltip("Hide Sidebar") .on_click(cx.listener(|this, _, _window, cx| this.toggle_left_panel(cx))), diff --git a/src/ui/tab_strip.rs b/src/ui/tab_strip.rs index 304e2856..0521687b 100644 --- a/src/ui/tab_strip.rs +++ b/src/ui/tab_strip.rs @@ -16,10 +16,14 @@ use gpui_component::{ActiveTheme as _, Icon, IconName, Selectable as _, Sizable use std::cell::RefCell; use std::rc::Rc; -use crate::core::actions::{OpenSettings, TogglePalette}; +use crate::core::actions::{ + NewWorkspace, OpenSettings, RenameWorkspace, SelectWorkspace1, SelectWorkspace2, + SelectWorkspace3, SelectWorkspace4, SelectWorkspace5, SelectWorkspace6, SelectWorkspace7, + SelectWorkspace8, SelectWorkspace9, StopWorkspace, TogglePalette, +}; use crate::core::config::{Config, RightPanelTab}; use crate::daemon::protocol::ShellSpec; -use crate::ui::app::{Tab, Tty7App}; +use crate::ui::app::{TILE_GLYPH, TILE_GLYPH_LINE, TILE_SIZE, Tab, Tty7App, tile_trailing_inset}; use crate::ui::hints::tab_badge_label; use crate::ui::reorder::{self, Reorder, Surface}; @@ -173,16 +177,408 @@ pub(crate) fn chrome_tile_variant(cx: &gpui::App) -> ButtonCustomVariant { // 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. .hover(cx.theme().sidebar_accent) - // Selected (a lit toggle) and pressed sit one step darker than hover, so - // an open panel still reads as on while the pointer is over its button. - .active(cx.theme().list_active) + // 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 { - button.custom(chrome_tile_variant(cx)).selected(selected) + 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, + glyph: f32, + selected: bool, + cx: &gpui::App, +) -> Button { + button + .custom(chrome_tile_variant(cx)) + .selected(selected) + .with_size(px(glyph / BUTTON_ICON_SCALE)) + .w(px(tile)) + .h(px(tile)) +} + +/// One workspace row in the title-bar menu, flattened for rendering. +#[derive(Clone)] +struct WorkspaceMenuRow { + id: crate::core::session::WorkspaceId, + name: String, + /// Currently shown by a window. + open: bool, + /// Shown by *this* window. + is_current: bool, + /// Has panes still running in the daemon. + live: bool, +} + +/// Diameter of the monogram badge on a workspace row. Smaller than the +/// title-bar chip's (which sits among 32px tiles) and a shade under the +/// sidebar's 24px, so a menu row stays at menu height. +const MENU_AVATAR_PX: f32 = 20.0; + +/// 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 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. +/// +/// "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: bool, + current: bool, + size: f32, + cx: &App, +) -> impl IntoElement + use<> { + let initial: String = name + .chars() + .next() + .map(|c| c.to_uppercase().to_string()) + .unwrap_or_else(|| "~".to_string()); + div() + .relative() + .flex_shrink_0() + .size(px(size)) + .child( + 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() + .justify_center() + .text_size(px((size * 0.46).round())) + .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)), + ) + .when(live, |badge| { + // 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. + badge.child(Tty7App::status_dot(LIVE_DOT, 0, size, cx.theme().popover)) + }) +} + +/// Render one workspace row: the monogram badge (carrying liveness on its +/// corner), the name, and — on hover — a single close. +/// +/// One button, not three. The sidebar row is the most action-rich row in the +/// app and it reveals exactly one on hover, with everything else on the +/// right-click menu; a menu row has no reason to be busier than that. Stopping +/// and renaming live in the menu's bottom group, where every other +/// current-workspace command already is. +fn workspace_menu_row(row: WorkspaceMenuRow, cx: &App) -> impl IntoElement + use<> { + let id = row.id; + // The row's own hover fill, so the button sits on an opaque patch and the + // name slides under it through the gradient rather than colliding with it. + let backing = cx.theme().accent; + let mut fade_from = backing; + fade_from.a = 0.; + h_flex() + .id(("workspace-menu-row", id.element_key() as usize)) + .group("workspace-menu-row") + .relative() + .w_full() + .items_center() + .gap_2() + .child(workspace_avatar( + &row.name, + row.live, + row.is_current, + MENU_AVATAR_PX, + cx, + )) + // The name stays at full strength on every row: this is a list you read + // to pick from, and dimming seven of eight names to mark the one you're + // already on taxes the reading to make a point the badge already makes. + .child(div().flex_1().min_w_0().truncate().child(row.name)) + .child( + h_flex() + .absolute() + .top_0() + .bottom_0() + .right(px(0.)) + .items_center() + .opacity(0.) + .group_hover("workspace-menu-row", |s| s.opacity(1.)) + .child(div().w(px(14.)).h(px(20.)).bg(linear_gradient( + 90., + linear_color_stop(fade_from, 0.), + linear_color_stop(backing, 1.), + ))) + .child( + div() + .bg(backing) + // Swallow the press so the click doesn't also fire the + // row's own "show this workspace" underneath it. + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .child( + Button::new(("workspace-menu-delete", id.element_key() as usize)) + .icon(IconName::Close) + .ghost() + .xsmall() + .on_click(move |_, window, cx| { + crate::ui::windows::confirm_and_delete(cx, window, id); + }), + ), + ), + ) +} + +/// 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> { + Some(match index { + 0 => Box::new(SelectWorkspace1) as Box, + 1 => Box::new(SelectWorkspace2), + 2 => Box::new(SelectWorkspace3), + 3 => Box::new(SelectWorkspace4), + 4 => Box::new(SelectWorkspace5), + 5 => Box::new(SelectWorkspace6), + 6 => Box::new(SelectWorkspace7), + 7 => Box::new(SelectWorkspace8), + 8 => Box::new(SelectWorkspace9), + _ => return None, + }) } impl Tty7App { + /// Diameter of the workspace avatar, matching the 32px chrome tiles beside + /// it so the corner reads as one row of controls. + const AVATAR_PX: f32 = 26.0; + + /// The title-bar workspace control: a monogram of the current workspace + /// plus a chevron, opening the one 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. + /// + /// A monogram rather than the full name: a fixed-width control can't be + /// pushed off the corner by a long repo name, and it sits level with the + /// icon tiles instead of introducing a third shape. The full name is in the + /// tooltip and checked in the menu. + /// + /// 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_chip( + &self, + window: &Window, + cx: &mut Context, + ) -> 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() + .h(px(32.)) + .w(px(150.)) + .px(px(8.)) + .rounded_lg() + .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(); + } + + let current = crate::core::session::WorkspaceStore::all(cx) + .get(self.workspace) + .map(|w| w.display_name()) + .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() + .map(|c| c.to_uppercase().to_string()) + .unwrap_or_else(|| "~".to_string()); + + // Same `action_context` trick as the old "⋯": `.menu(label, Action)` + // dispatches the real action, so a click and its shortcut travel one + // path and the row renders the shortcut hint for free. + let action_ctx = self + .tabs + .get(self.active) + .and_then(|t| t.pane.focused_or_first(window, cx)) + .map(|leaf| leaf.read(cx).focus_handle.clone()) + .unwrap_or_else(|| self.home_focus.clone()); + // The only thing the rows need from `self`. Everything else — the + // order, the names, which shells are still running — is read inside the + // menu builder below, at the moment the menu opens. + let current_id = self.workspace; + + div() + .occlude() + .flex_shrink_0() + .child( + Button::new("titlebar-workspace") + .custom(chrome_tile_variant(cx)) + .child( + h_flex() + .items_center() + .gap(px(3.)) + .child( + div() + .flex() + .items_center() + .justify_center() + .size(px(Self::AVATAR_PX)) + .rounded_full() + .bg(cx.theme().secondary) + .text_size(px(11.)) + .font_weight(FontWeight::SEMIBOLD) + .child(monogram), + ) + // A chevron, unlike the toggles beside it: those do + // one thing on click, this opens a menu, and the + // glyph is what says so. + .child(Icon::new(IconName::ChevronDown).size(px(11.))), + ) + .xsmall() + .h(px(32.)) + .rounded_lg() + .tooltip(SharedString::from(current)) + .dropdown_menu_with_anchor( + gpui::Anchor::TopRight, + move |mut menu, _window, cx| { + menu = menu.min_w(px(260.)).action_context(action_ctx.clone()); + // Built here, not at title-bar render time. The rows + // carry a liveness snapshot, and a snapshot taken when + // the chip last drew is whatever happened to be true + // then — a shell that exited since would still show its + // dot, because nothing re-renders the title bar when a + // pane dies in another window. Reading it on open costs + // one daemon round-trip per menu, which is exactly when + // it is worth paying for. + let alive = crate::ui::app::alive_panes(); + let rows: Vec = crate::ui::windows::menu_order(cx) + .into_iter() + .map(|(id, open)| { + let ws = crate::core::session::WorkspaceStore::all(cx).get(id); + WorkspaceMenuRow { + id, + name: ws.map(|w| w.display_name()).unwrap_or_default(), + open, + is_current: id == current_id, + live: ws + .map(|w| w.pane_ids().iter().any(|p| alive.contains(p))) + .unwrap_or(false), + } + }) + .collect(); + let mut separated = false; + for (i, row) in rows.iter().enumerate() { + let Some(action) = select_workspace_action(i) else { + break; + }; + // One rule between what is on screen and what is put + // away, drawn once and never leading. + if !row.open && !separated { + separated = true; + if i > 0 { + menu = menu.separator(); + } + } + let row = row.clone(); + // Deliberately *not* `menu_element_with_check`: a + // single checked item makes `PopupMenu` reserve a + // left icon gutter on every row in the menu, and + // seven of eight of them have nothing to put in it. + // The current workspace is marked on its avatar + // instead — same information, no column. + menu = menu.menu_element(action, move |_window, cx| { + workspace_menu_row(row.clone(), cx) + }); + } + menu.separator() + .menu("New Workspace", Box::new(NewWorkspace)) + // The two actions a row's single close doesn't + // carry. They act on *this* window's workspace, + // like every other entry below the rows — and + // rename has to: the field it opens is the chip in + // this title bar, so it can only ever edit the name + // shown there. + .menu("Rename Workspace…", Box::new(RenameWorkspace)) + .menu("Stop Workspace…", Box::new(StopWorkspace)) + // The app-level entries the "⋯" used to carry. + // Folded in here so the corner has one menu rather + // than two adjacent ones. + .separator() + .menu("Command Palette", Box::new(TogglePalette)) + .menu("Settings…", Box::new(OpenSettings)) + }, + ), + ) + .into_any_element() + } + /// 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 @@ -195,25 +591,20 @@ impl Tty7App { cx: &mut Context, ) -> impl IntoElement + use<> { let panel_open = self.right_panel_open(cx); - // `.menu(label, Action)` dispatches the real action, so a click and the - // shortcut travel one path and the row auto-renders the shortcut hint; it - // needs an `action_context` inside the app's element tree to land on the - // root `on_action` handlers, so we hand it the focused pane (falling back - // to the home page's handle when no tab is open). - let action_ctx = self - .tabs - .get(self.active) - .and_then(|t| t.pane.focused_or_first(window, cx)) - .map(|leaf| leaf.read(cx).focus_handle.clone()) - .unwrap_or_else(|| self.home_focus.clone()); h_flex() .flex_shrink_0() .items_center() .gap(px(2.)) + // The workspace control leads the corner chrome: it is the only + // thing in the window that says *which* workspace this is, which + // starts to matter the moment there are two. It also absorbed the + // old "⋯" menu, so the corner has one menu instead of two adjacent + // ones, and nothing workspace-scoped is left behind a modifier + // gesture or a palette entry the user has to already know about. // 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* there instead of its 30px hit box. - .pr(px(crate::ui::app::CONTENT_INSET - crate::ui::app::TILE_PAD)) + // *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 @@ -227,13 +618,10 @@ impl Tty7App { div().occlude().flex_shrink_0().child( chrome_tile( Button::new("titlebar-right-panel") - .icon(Icon::empty().path("icons/panel-right.svg").size(px(18.))), + .icon(Icon::empty().path("icons/panel-right.svg")), panel_open, cx, ) - .xsmall() - .w(px(32.)) - .h(px(32.)) .rounded_lg() .tooltip("Detail Panel") .on_click(cx.listener(|this, _, _window, cx| { @@ -241,35 +629,13 @@ impl Tty7App { })), ), ) - .child( - div().occlude().flex_shrink_0().child( - chrome_tile( - Button::new("titlebar-menu") - .icon(Icon::new(IconName::Ellipsis).size(px(18.))), - false, - cx, - ) - .xsmall() - .w(px(32.)) - .h(px(32.)) - .rounded_lg() - .dropdown_menu_with_anchor( - gpui::Anchor::TopRight, - move |menu, _window, _cx| { - menu.min_w(px(220.)) - .action_context(action_ctx.clone()) - .menu("Command Palette", Box::new(TogglePalette)) - .menu("Settings…", Box::new(OpenSettings)) - }, - ), - ), - ) + .child(self.workspace_chip(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. pub(crate) fn right_panel_tabs(&self, cx: &mut Context) -> Vec { - let active_tab = cx.global::().right_panel_tab; + let active_tab = self.right_panel_tab; [ ( RightPanelTab::Info, @@ -302,13 +668,10 @@ impl Tty7App { .flex_shrink_0() .child( chrome_tile( - Button::new(("right-panel-tab", tab as usize)).icon(icon.size(px(18.))), + Button::new(("right-panel-tab", tab as usize)).icon(icon), active_tab == tab, cx, ) - .xsmall() - .w(px(32.)) - .h(px(32.)) .rounded_lg() .tooltip(label) .on_click(cx.listener(move |this, _, _window, cx| { @@ -327,10 +690,12 @@ impl Tty7App { /// 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. - fn status_dot(rgb: u32, unread: usize, size: f32, cx: &App) -> gpui::AnyElement { + /// `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 = cx.theme().background; + 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 @@ -407,7 +772,7 @@ impl Tty7App { // 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)); + .map(|rgb| Self::status_dot(rgb, unread, size, cx.theme().background)); base.relative() .rounded_full() .bg(gpui::rgb(agent.accent_rgb())) @@ -437,7 +802,9 @@ impl Tty7App { // 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))) + .when_some(ssh, |b, rgb| { + b.child(Self::status_dot(rgb, 0, size, cx.theme().background)) + }) .into_any_element(), } } @@ -1082,24 +1449,24 @@ impl Tty7App { // spot; ⌘T still opens a default tab in one), followed by every shell // discovered on this machine (`detected_shells`, probed at startup). // Built on gpui-component's `DropdownMenu`, which is only implemented - // for `Button` — hence a ghost Button restyled to the title bar's 30px - // tile rhythm (30px box, 15px glyph, soft corners) rather than the - // hand-rolled tile the "+" used to be. + // 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( - Button::new("tab-add") - .icon(Icon::new(IconName::Plus).size(px(18.))), + chrome_tile_sized( + Button::new("tab-add").icon(Icon::new(IconName::Plus)), + TILE_SIZE, + TILE_GLYPH_LINE, false, cx, ) - .xsmall() - .w(px(32.)) - .h(px(32.)) .rounded_lg(), cx, ), @@ -1121,15 +1488,14 @@ impl Tty7App { .child( div().occlude().flex_shrink_0().child( self.attach_new_tab_menu( - chrome_tile( + chrome_tile_sized( Button::new("titlebar-add-collapsed") - .icon(Icon::new(IconName::Plus).size(px(18.))), + .icon(Icon::new(IconName::Plus)), + TILE_SIZE, + TILE_GLYPH_LINE, false, cx, ) - .xsmall() - .w(px(32.)) - .h(px(32.)) .rounded_lg(), cx, ), @@ -1139,13 +1505,10 @@ impl Tty7App { div().occlude().flex_shrink_0().child( chrome_tile( Button::new("titlebar-expand-sidebar") - .icon(Icon::empty().path("icons/panel-left.svg").size(px(18.))), + .icon(Icon::empty().path("icons/panel-left.svg")), false, cx, ) - .xsmall() - .w(px(32.)) - .h(px(32.)) .rounded_lg() .tooltip("Show Sidebar") .on_click(cx.listener(|this, _, _window, cx| this.toggle_left_panel(cx))), diff --git a/src/ui/theme.rs b/src/ui/theme.rs index 936e8202..f15e3a4f 100644 --- a/src/ui/theme.rs +++ b/src/ui/theme.rs @@ -40,6 +40,7 @@ pub(crate) fn set_menus(cx: &mut App) { ]), Menu::new("Shell").items([ MenuItem::action("New Tab", NewTab), + MenuItem::action("New Workspace", NewWorkspace), MenuItem::action("Split Right", SplitRight), MenuItem::action("Split Down", SplitDown), MenuItem::separator(), @@ -50,7 +51,16 @@ pub(crate) fn set_menus(cx: &mut App) { MenuItem::action("Reopen Closed Tab", ReopenClosedTab), MenuItem::separator(), MenuItem::action("Close Pane / Tab", CloseActiveTab), + // Last, and separated: the only two items here that touch running + // sessions. Everything above them — including closing the window — + // leaves the shells alive in the daemon, so these sit apart rather + // than a mis-click away from "Close Pane / Tab". Stop keeps the + // layout; Delete is the only thing that discards it. + MenuItem::separator(), + MenuItem::action("Stop Workspace…", StopWorkspace), + MenuItem::action("Delete Workspace…", DeleteWorkspace), ]), + Menu::new("Window").items(window_menu_items(cx)), Menu::new("View").items([ MenuItem::action("Increase Font Size", IncreaseFontSize), MenuItem::action("Decrease Font Size", DecreaseFontSize), @@ -61,6 +71,71 @@ pub(crate) fn set_menus(cx: &mut App) { ]); } +/// 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 { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + 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; + + let mut items = Vec::new(); + let mut separated = false; + for (i, (id, open)) in order.iter().enumerate() { + let Some(workspace) = store.get(*id) else { + 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; + if !items.is_empty() { + items.push(MenuItem::Separator); + } + } + let label = if *open { + workspace.display_name() + } else { + // The age is the useful discriminator among detached ones — several + // may share a repo name. + format!( + "{} — {}", + workspace.display_name(), + crate::ui::home::relative_time(now, workspace.last_active) + ) + }; + items.push(MenuItem::Action { + name: label.into(), + action, + os_action: None, + checked: false, + disabled: false, + }); + } + if items.is_empty() { + // Never hand back an empty menu — an unclickable "Window" title 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 diff --git a/src/ui/tray/mod.rs b/src/ui/tray/mod.rs index df8b0a0b..2de4ae08 100644 --- a/src/ui/tray/mod.rs +++ b/src/ui/tray/mod.rs @@ -36,8 +36,7 @@ use sni::Backend; use crate::core::cli_agent::AgentStatus; use crate::core::config::{Config, NotifyMode}; -use crate::ui::app::Tty7App; -use gpui::Context; +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 @@ -70,6 +69,16 @@ pub(crate) enum TrayAction { 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, + AgentStatus::Working => 2, + AgentStatus::Done => 1, + AgentStatus::Idle => 0, + } +} + /// One agent pane, as shown in the tray menu. #[derive(Clone, PartialEq, Eq)] pub(crate) struct AgentRow { @@ -318,27 +327,90 @@ 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(); + for (_, weak) in windows { + 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, + notify_mode: cx.global::().notify_on_command_finish, + } +} + +/// 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; + + let target = match action { + TrayAction::RevealPane { leaf_id } => WindowRegistry::open_windows(cx) + .into_iter() + .find(|(_, weak)| { + weak.upgrade() + .is_some_and(|app| app.read(cx).owns_leaf(leaf_id)) + }) + .map(|(workspace, _)| workspace), + _ => None, + } + .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(); + } + return; + }; + let (Some(handle), Some(weak)) = ( + WindowRegistry::window_for(cx, workspace), + WindowRegistry::app_for(cx, workspace), + ) else { + return; + }; + let _ = handle.update(cx, |_, window, cx| { + if let Some(app) = weak.upgrade() { + app.update(cx, |app, cx| app.handle_tray_action(action, window, cx)); + } + }); +} + /// Wire the tray up: one task pumps menu clicks into the app, another polls -/// the app into the tray. Called once from `Tty7App::with_session`; both -/// tasks end (dropping the tray icon) when the app entity drops. +/// 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 Context) { +pub(crate) fn init(cx: &mut App) { let (tx, rx) = smol::channel::unbounded::(); // 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. - cx.spawn(async move |this, cx| { + // + // 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 { - let alive = this.update_in(cx, |app, window, cx| { - app.handle_tray_action(action, window, cx) - }); - if alive.is_err() { - break; - } + cx.update(|cx| dispatch(action, cx)); } }) .detach(); @@ -346,7 +418,7 @@ pub(crate) fn init(cx: &mut Context) { // 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 |this, cx| { + cx.spawn(async move |cx| { let mut backend: Option = None; // Last snapshot actually pushed; `None` forces a push after // (re)creation so a fresh icon never shows a stale menu. @@ -363,11 +435,8 @@ pub(crate) fn init(cx: &mut Context) { let mut cooldown = 0u32; loop { cx.background_executor().timer(POLL).await; - let Ok((enabled, snap)) = this.update(cx, |app, cx| { - (cx.global::().show_tray_icon, app.tray_snapshot(cx)) - }) else { - break; // app gone — backend drops with the task - }; + let (enabled, snap) = + cx.update(|cx| (cx.global::().show_tray_icon, app_snapshot(cx))); if !enabled { backend = None; shown = None; diff --git a/src/ui/windows.rs b/src/ui/windows.rs new file mode 100644 index 00000000..dc857294 --- /dev/null +++ b/src/ui/windows.rs @@ -0,0 +1,552 @@ +//! 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 _, Bounds, Global, Styled as _, TitlebarOptions, + WeakEntity, Window, WindowBounds, WindowOptions, point, px, size, +}; +use gpui_component::{Root, TitleBar, WindowExt as _}; + +use crate::core::config::{Config, StartupMode}; +use crate::core::session::{WorkspaceId, WorkspaceStore}; +use crate::core::window_state::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, +} + +/// Every window tty7 currently has open. +#[derive(Default)] +pub struct WindowRegistry { + windows: Vec, +} + +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::().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)> { + Self::sweep(cx); + cx.global::() + .windows + .iter() + .map(|w| (w.workspace, w.app.clone())) + .collect() + } + + /// The window showing `workspace`, if one is open. + pub fn window_for(cx: &mut App, workspace: WorkspaceId) -> Option { + Self::sweep(cx); + cx.global::() + .windows + .iter() + .find(|w| w.workspace == workspace) + .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 { + Self::sweep(cx); + let active = WorkspaceStore::all(cx).active; + let registry = cx.global::(); + active + .filter(|id| registry.windows.iter().any(|w| w.workspace == *id)) + .or_else(|| registry.windows.first().map(|w| w.workspace)) + } + + /// The `Tty7App` showing `workspace`, if one is open. + pub fn app_for(cx: &mut App, workspace: WorkspaceId) -> Option> { + Self::sweep(cx); + cx.global::() + .windows + .iter() + .find(|w| w.workspace == workspace) + .map(|w| w.app.clone()) + } + + fn register( + cx: &mut App, + workspace: WorkspaceId, + handle: AnyWindowHandle, + app: WeakEntity, + ) { + cx.global_mut::().windows.push(WindowEntry { + workspace, + handle, + app, + }); + } + + /// 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::() + .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::() + .windows + .iter_mut() + .find(|w| w.workspace == from) + { + entry.workspace = to; + } + } + + /// 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 = cx + .global::() + .windows + .iter() + .filter(|w| w.app.upgrade().is_none()) + .map(|w| w.workspace) + .collect(); + if dead.is_empty() { + return; + } + cx.global_mut::() + .windows + .retain(|w| !dead.contains(&w.workspace)); + } +} + +/// What a *brand-new* workspace's window starts with. Only consulted when the +/// window is opening on a freshly minted workspace — one restored from +/// `session.json` always rebuilds its saved tabs. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum FreshStart { + /// A single default terminal, the way every previous launch of tty7 came + /// up. What `New Workspace` and a genuine first run want: a window whose + /// workspace has nothing in it yet is a window you asked for to work in. + Shell, + /// No tabs — the home page. Used at launch when there *are* saved + /// workspaces but none were open at quit: the picker listing them is the + /// whole point of that window, and a shell in front of it would bury it. + HomePage, +} + +/// Open a window on `workspace` — or on a brand-new workspace when `None`, +/// which starts with a single terminal (see [`open_with`] for the other case). +/// +/// 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) { + open_with(cx, workspace, FreshStart::Shell); +} + +/// [`open`], with a say in what a brand-new workspace comes up holding. +pub fn open_with(cx: &mut App, workspace: Option, fresh: FreshStart) { + if let Some(id) = workspace + && let Some(handle) = WindowRegistry::window_for(cx, id) + { + let _ = handle.update(cx, |_, window, _| window.activate_window()); + return; + } + + 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> = None; + let opened = cx.open_window(options, |window, cx| { + let app = cx.new(|cx| Tty7App::for_workspace(workspace, fresh, 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())) + }); + + let handle = match opened { + Ok(handle) => handle, + Err(e) => { + log::error!("failed to open window: {e}"); + return; + } + }; + let Some(app) = created else { + log::error!("opened a window but its Tty7App was never built; not registering"); + 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); +} + +/// Tell the user *once* that closing a window put its workspace away rather +/// than ending it, and where to find it again. +/// +/// ⌘W is muscle memory and its result is off-screen, so the very first time it +/// detaches real work the user deserves a pointer — and never again after that. +/// Shown on whichever window survives; with none left (the app is quitting) +/// there is nowhere to put it and nothing to come back to yet, so it waits for +/// a later detach. +pub fn hint_detached(cx: &mut App, name: &str) { + if cx.global::().workspace_detach_hint_seen { + return; + } + let Some(target) = WindowRegistry::most_recent(cx) else { + return; + }; + let Some(handle) = WindowRegistry::window_for(cx, target) else { + return; + }; + cx.global_mut::().workspace_detach_hint_seen = true; + cx.global::().save(); + // The title bar's workspace menu, not the macOS Window menu: Windows and + // Linux have no menu bar, and the corner chip lists workspaces everywhere. + let message = + format!("“{name}” is still running — reopen it from the workspace menu in the title bar"); + let _ = handle.update(cx, |_, window, cx| { + window.push_notification(message, 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.workspaces.iter().filter(|w| w.open).collect(); + let mut closed: Vec<_> = all.workspaces.iter().filter(|w| !w.open).collect(); + open.sort_by(|a, b| b.last_active.cmp(&a.last_active)); + closed.sort_by(|a, b| b.last_active.cmp(&a.last_active)); + open.into_iter() + .map(|w| (w.id, true)) + .chain(closed.into_iter().map(|w| (w.id, false))) + .take(MENU_SLOTS) + .collect() +} + +/// How many of a workspace's panes are still running in the daemon. Zero means +/// closing it destroys nothing — every shell already exited — so the caller can +/// skip the confirmation prompt. +pub fn live_pane_count(cx: &App, workspace: WorkspaceId) -> usize { + let Some(ws) = WorkspaceStore::all(cx).get(workspace) else { + return 0; + }; + let claimed = ws.pane_ids(); + if claimed.is_empty() { + return 0; + } + // One short-lived control connection, only when there is something to ask + // about — the picker renders far more often than a workspace is closed. + let alive: std::collections::HashSet = crate::terminal::RemoteTerminal::list_panes() + .into_iter() + .filter(|p| p.alive) + .map(|p| p.pane_id) + .collect(); + claimed.iter().filter(|id| alive.contains(id)).count() +} + +/// 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); +} + +/// 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, + workspace: WorkspaceId, + verb: &'static str, + act: fn(&mut App, WorkspaceId), +) { + let live = live_pane_count(cx, workspace); + let name = WorkspaceStore::all(cx) + .get(workspace) + .map(|w| w.display_name()) + .unwrap_or_else(|| "this workspace".to_string()); + if live == 0 && verb == "Stop" { + act(cx, workspace); + return; + } + let detail = match (live, verb) { + (0, _) => "Its layout and working directories will be forgotten.".to_string(), + (1, "Delete") => "1 running session will be ended and its layout forgotten.".to_string(), + (n, "Delete") => format!("{n} running sessions will be ended and the layout forgotten."), + (1, _) => "1 running session will be ended.".to_string(), + (n, _) => format!("{n} running sessions will be ended."), + }; + let answer = window.prompt( + gpui::PromptLevel::Warning, + &format!("{verb} workspace \u{201c}{name}\u{201d}?"), + Some(&detail), + &["Cancel", verb], + cx, + ); + cx.spawn(async move |cx| { + // Index 1 == the verb button; Cancel and a dismissed prompt both leave + // the workspace alone. + if let Ok(1) = answer.await { + cx.update(|cx| act(cx, workspace)); + } + }) + .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 when [`live_pane_count`] is non-zero; with nothing +/// running there is nothing to lose. +pub fn stop_workspace(cx: &mut App, workspace: WorkspaceId) { + if let Some(ws) = WorkspaceStore::all(cx).get(workspace) { + for pane_id in ws.pane_ids() { + crate::terminal::RemoteTerminal::kill_pane(pane_id); + } + } + // 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); + 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) { + stop_workspace(cx, workspace); + WorkspaceStore::remove(cx, workspace); + refresh_menu(cx); +} + +/// 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 { + return; + }; + let Some(app) = showing.and_then(|weak| weak.upgrade()) else { + return; + }; + + if WindowRegistry::count(cx) > 1 { + WindowRegistry::unregister(cx, workspace); + let _ = handle.update(cx, |_, window, _| window.remove_window()); + return; + } + + let (fresh, session) = WorkspaceStore::claim(cx, None); + WindowRegistry::rebind(cx, workspace, fresh); + let _ = handle.update(cx, |_, window, cx| { + app.update(cx, |app, cx| { + app.adopt_workspace(fresh, session, window, cx) + }); + }); +} + +/// 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) -> WindowOptions { + let remember = cx.global::().remember_window_size; + let remembered = remember + .then(|| { + workspace + .and_then(|id| WorkspaceStore::all(cx).get(id).and_then(|w| w.window)) + .or_else(WindowState::load) + }) + .flatten(); + + 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)) { + bounds + } else { + Bounds::centered(None, bounds.size, cx) + } + } + None => Bounds::centered(None, size(px(DEFAULT_SIZE.0), px(DEFAULT_SIZE.1)), cx), + }; + 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::().startup_mode { + _ if existing > 0 => WindowBounds::Windowed(bounds), + StartupMode::Normal => WindowBounds::Windowed(bounds), + StartupMode::Maximized => WindowBounds::Maximized(bounds), + StartupMode::Fullscreen => WindowBounds::Fullscreen(bounds), + }; + + WindowOptions { + window_bounds: Some(window_bounds), + // 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, existing: usize) -> Bounds { + 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)), + size: bounds.size, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn bounds_at(x: f32, y: f32) -> Bounds { + Bounds { + origin: point(px(x), px(y)), + size: size(px(800.), px(600.)), + } + } + + #[test] + fn the_first_window_is_not_cascaded() { + let b = bounds_at(100., 100.); + assert_eq!(cascade(b, 0).origin, b.origin); + } + + #[test] + fn each_extra_window_steps_down_and_right() { + let b = bounds_at(100., 100.); + assert_eq!( + cascade(b, 1).origin, + point(px(100. + CASCADE_STEP), px(100. + CASCADE_STEP)) + ); + assert_eq!( + 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); + } +}