Merge branch 'main' into fix/prompt-ctrl-chords

CHANGELOG: keep both Unreleased sets, with the history-search entry under
Added beside the multi-window ones and the Ctrl+J/M fix in its own Fixed
section.
This commit is contained in:
l0ng-ai
2026-07-25 15:25:35 +08:00
22 changed files with 3097 additions and 356 deletions
+29
View File
@@ -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
+28
View File
@@ -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,
+23 -6
View File
@@ -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,
+702 -15
View File
@@ -91,7 +91,11 @@ pub struct SessionTab {
pub sidebar_group: Option<std::path::PathBuf>,
}
/// 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<SessionTab>,
}
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<Session> {
/// 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<String>,
#[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<crate::core::window_state::WindowState>,
/// 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<PathBuf> {
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<PathBuf> {
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<u64> {
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<Workspace>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub active: Option<WorkspaceId>,
}
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<Self> {
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::<Session>(&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<Self, serde_json::Error> {
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<Item = &Workspace> {
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<u64> = 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<Workspaces> = std::sync::OnceLock::new();
match cx.try_global::<Self>() {
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::<Self>().then(|| cx.global_mut::<Self>())
}
/// Take over an existing workspace to show in a window, or mint a fresh one
/// when `id` is `None` / no longer on file (the "New Workspace" path). Marks it
/// open and returns its id plus the tabs the window should rebuild.
pub fn claim(cx: &mut gpui::App, id: Option<WorkspaceId>) -> (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<crate::core::window_state::WindowState>,
) {
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<String>) {
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<String> {
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<PathBuf> {
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<u64>) {
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<u64>,
) -> 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<u64>) -> 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<SessionTab>) -> 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<_>>(),
vec![open_id]
);
// The picker lists most-recently-active first.
assert_eq!(
all.closed_workspaces()
.iter()
.map(|w| w.id)
.collect::<Vec<_>>(),
vec![newer_id, older_id]
);
}
}
+14 -1
View File
@@ -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.
+36 -66
View File
@@ -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::<Config>().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::<Config>().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));
}
}
});
}
+557 -81
View File
@@ -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<Subscription>,
}
/// In-flight inline rename of the current workspace (the title-bar chip turns
/// into a text field). Mirrors [`Renaming`], but keyed to nothing — there is
/// only ever one current workspace per window.
pub(crate) struct WorkspaceRename {
pub(crate) input: Entity<InputState>,
_subs: Vec<Subscription>,
}
pub(crate) struct LoopbackForwardPanelState {
/// The pane whose add/edit form is expanded under the Info tab's Forwards
/// band, or `None` while the band is just its list. Per-pane rather than a
@@ -451,6 +514,19 @@ pub struct Tty7App {
/// exactly the reason `sidebar_width` is — see there.
pub(crate) right_panel_width: Rc<Cell<f32>>,
pub(crate) right_panel_dragging: Rc<Cell<bool>>,
/// Which chrome this *window* is showing: is the detail panel docked open,
/// which of its tabs is selected, and is the tab rail collapsed.
///
/// Window-level rather than `Config`, which is a global: with one window the
/// two were indistinguishable, but with several, reading the config meant
/// opening the detail panel in one window opened it in every other one too.
/// A window is a *view* — what it has on screen is its own. The config
/// fields of the same names survive as what a newly opened window starts
/// with, written back on each toggle so a new window (and the next launch)
/// inherits the last thing the user actually chose.
pub(crate) right_panel_visible: bool,
pub(crate) right_panel_tab: RightPanelTab,
pub(crate) sidebar_collapsed: bool,
/// Scroll handle for the sidebar's row list, so activating a tab scrolls its
/// row into view.
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<Pixels>,
/// Which persistent workspace this window is showing. The window is the
/// transient view; the workspace is the identity that survives closing it
/// and shows up in the home-page picker. Every `save_session` writes back
/// under this id, so two windows never overwrite each other's tabs.
pub(crate) workspace: WorkspaceId,
/// 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<Option<(std::time::Instant, HashSet<u64>)>>,
/// `Some` while the title-bar workspace chip is being renamed inline.
/// Separate from `renaming` (tabs) because the two live in different
/// widgets and can't be in flight at once anyway.
pub(crate) workspace_rename: Option<WorkspaceRename>,
/// Last title pushed to the OS window, so the common case (nothing
/// changed) skips the platform call. `RefCell` because the sync runs from
/// `focus_active`, which only takes `&self`.
window_title: std::cell::RefCell<String>,
}
/// 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>) -> 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::<Config>().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<WorkspaceId>,
fresh: crate::ui::windows::FreshStart,
window: &mut Window,
cx: &mut Context<Self>,
) -> 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::<Config>().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<WorkspaceId>,
session: Option<Session>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
// Tests build a window without going through the store; give them a
// detached identity rather than requiring the global to be installed.
let workspace = workspace.unwrap_or_default();
// 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::<Config>().sidebar_width;
let right_panel_width = cx.global::<Config>().right_panel_width;
// The config's copies are this window's *starting* chrome; from here on
// the window owns them (see the fields' doc comment).
let right_panel_visible = cx.global::<Config>().right_panel_visible;
let right_panel_tab = cx.global::<Config>().right_panel_tab;
let sidebar_collapsed = cx.global::<Config>().sidebar_collapsed;
// Live-apply hot-reloaded config: the watcher in `main.rs` swaps the
// `Config` global on every `config.json` change, which fires this. The
// window-aware variant so the reload can re-run `apply_theme` with the
@@ -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<SessionTab> = 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<Self>,
) {
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<Self>,
) {
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<Self>,
) {
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<Self>,
) {
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<Self>,
) {
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>,
) {
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<crate::ui::tray::AgentRow> {
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::<Config>().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<Self>) {
let cfg = cx.global::<Config>();
let (pos, collapsed) = match cfg.tab_bar_position {
let (pos, collapsed) = match cx.global::<Config>().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::<Config>().tab_bar_position, TabBarPosition::Left)
&& !cx.global::<Config>().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<u64> {
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<Self>) {
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<Self>) {
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::<Config>().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<u64> {
pub(crate) fn alive_panes() -> std::collections::HashSet<u64> {
crate::terminal::RemoteTerminal::list_panes()
.into_iter()
.filter(|p| p.alive)
@@ -5005,7 +5480,7 @@ fn alive_panes() -> std::collections::HashSet<u64> {
/// 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<Session>,
@@ -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
+9 -7
View File
@@ -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| {
+10 -5
View File
@@ -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);
+5 -4
View File
@@ -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.
+339 -2
View File
@@ -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<Self>) -> Option<impl IntoElement + use<>> {
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<PickerRow> = 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
+34
View File
@@ -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: ⌘19 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<KeyBinding> {
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<KeyBinding> {
"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),
+1
View File
@@ -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;
+63 -1
View File
@@ -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<Command> {
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),
+37 -31
View File
@@ -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::<Config>().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<Self>) {
let next = !cx.global::<Config>().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>) {
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::<Config>().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<Self>) -> 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::<Config>();
// 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::<Config>();
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
+33 -18
View File
@@ -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)
})),
),
)
});
+19 -15
View File
@@ -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<Self>) {
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::<Config>();
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<Tty7App>, vcx: &mut VisualTestContext) -> (bool, RightPanelTab) {
vcx.update(|_, cx| {
let cfg = cx.global::<Config>();
(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));
}
}
+7 -12
View File
@@ -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))),
+437 -74
View File
@@ -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<Box<dyn gpui::Action>> {
Some(match index {
0 => Box::new(SelectWorkspace1) as Box<dyn gpui::Action>,
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<Self>,
) -> impl IntoElement + use<> {
if let Some(rename) = self.workspace_rename.as_ref() {
// The tile itself becomes the field — same height, same radius, and
// the hover fill standing in for "this control is being edited".
// A bordered input would drop a form control into a strip that has
// none, and `Input`'s default pill fights every other shape here;
// `appearance(false)` is what the tab rename uses for the same
// reason.
return h_flex()
.id("workspace-rename")
.flex_shrink_0()
.items_center()
.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<WorkspaceMenuRow> = 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<Self>,
) -> 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<Self>) -> Vec<AnyElement> {
let active_tab = cx.global::<Config>().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))),
+75
View File
@@ -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<MenuItem> {
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
+87 -18
View File
@@ -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::<Config>().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<Tty7App>) {
pub(crate) fn init(cx: &mut App) {
let (tx, rx) = smol::channel::unbounded::<TrayAction>();
// Menu clicks → the app. The platform handler feeds `tx` from wherever
// the OS delivers menu events; this task is the only place they touch
// gpui state, with a real window + context in hand.
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<Tty7App>) {
// 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<Backend> = 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<Tty7App>) {
let mut cooldown = 0u32;
loop {
cx.background_executor().timer(POLL).await;
let Ok((enabled, snap)) = this.update(cx, |app, cx| {
(cx.global::<Config>().show_tray_icon, app.tray_snapshot(cx))
}) else {
break; // app gone — backend drops with the task
};
let (enabled, snap) =
cx.update(|cx| (cx.global::<Config>().show_tray_icon, app_snapshot(cx)));
if !enabled {
backend = None;
shown = None;
+552
View File
@@ -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<Tty7App>,
}
/// Every window tty7 currently has open.
#[derive(Default)]
pub struct WindowRegistry {
windows: Vec<WindowEntry>,
}
impl Global for WindowRegistry {}
impl WindowRegistry {
/// Install the empty registry. Call once, before the first window opens.
pub fn init(cx: &mut App) {
cx.set_global(Self::default());
}
/// Number of live windows. Drives "is this the last window?" — the check
/// that decides whether closing one quits the app.
pub fn count(cx: &mut App) -> usize {
Self::sweep(cx);
cx.global::<Self>().windows.len()
}
/// The workspaces currently on screen, with the entity to read their tabs
/// from. Used by the quit hook to record every window's final state.
pub fn open_windows(cx: &mut App) -> Vec<(WorkspaceId, WeakEntity<Tty7App>)> {
Self::sweep(cx);
cx.global::<Self>()
.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<AnyWindowHandle> {
Self::sweep(cx);
cx.global::<Self>()
.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<WorkspaceId> {
Self::sweep(cx);
let active = WorkspaceStore::all(cx).active;
let registry = cx.global::<Self>();
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<WeakEntity<Tty7App>> {
Self::sweep(cx);
cx.global::<Self>()
.windows
.iter()
.find(|w| w.workspace == workspace)
.map(|w| w.app.clone())
}
fn register(
cx: &mut App,
workspace: WorkspaceId,
handle: AnyWindowHandle,
app: WeakEntity<Tty7App>,
) {
cx.global_mut::<Self>().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::<Self>()
.windows
.retain(|w| w.workspace != workspace);
}
/// Point an existing window at a different workspace, keeping its handle
/// and entity. Used when the picker swaps a window's contents in place
/// rather than opening a second window (see `Tty7App::switch_workspace`).
pub fn rebind(cx: &mut App, from: WorkspaceId, to: WorkspaceId) {
if let Some(entry) = cx
.global_mut::<Self>()
.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<WorkspaceId> = cx
.global::<Self>()
.windows
.iter()
.filter(|w| w.app.upgrade().is_none())
.map(|w| w.workspace)
.collect();
if dead.is_empty() {
return;
}
cx.global_mut::<Self>()
.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<WorkspaceId>) {
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<WorkspaceId>, 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<gpui::Entity<Tty7App>> = 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::<Config>().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::<Config>().workspace_detach_hint_seen = true;
cx.global::<Config>().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<u64> = 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<WorkspaceId>) -> WindowOptions {
let remember = cx.global::<Config>().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::<Config>().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<gpui::Pixels>, existing: usize) -> Bounds<gpui::Pixels> {
if existing == 0 {
return bounds;
}
// Wrap after a few steps so a long-lived session doesn't march windows off
// the bottom-right of the display.
let step = (existing % 5) as f32 * CASCADE_STEP;
Bounds {
origin: bounds.origin + point(px(step), px(step)),
size: bounds.size,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn bounds_at(x: f32, y: f32) -> Bounds<gpui::Pixels> {
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);
}
}