mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 00:02:23 +00:00
Merge pull request #169 from l0ng-ai/feat/multi-window
feat(windows): one window per workspace
This commit is contained in:
@@ -7,6 +7,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 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.
|
||||
|
||||
### 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.
|
||||
|
||||
## [26.7.2] - 2026-07-21
|
||||
|
||||
### Added
|
||||
|
||||
@@ -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
@@ -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")]
|
||||
@@ -530,6 +546,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
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
@@ -2218,7 +2565,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.
|
||||
@@ -3018,6 +3388,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
|
||||
@@ -3031,6 +3454,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();
|
||||
}
|
||||
@@ -3145,6 +3569,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),
|
||||
@@ -4512,7 +4943,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).
|
||||
@@ -4722,6 +5153,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.
|
||||
@@ -4990,7 +5465,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)
|
||||
@@ -5001,7 +5476,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>,
|
||||
@@ -5461,7 +5936,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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -114,7 +114,15 @@ pub(crate) fn default_bindings() -> Vec<(&'static str, &'static str)> {
|
||||
// and Linux without binding to the Win/Super key, which the OS reserves.
|
||||
vec![
|
||||
("NewTab", "secondary-t"),
|
||||
("NewWorkspace", "secondary-shift-n"),
|
||||
("CloseActiveTab", "secondary-w"),
|
||||
// No default chord on purpose: this is the one action that kills running
|
||||
// sessions, and it must not sit one slip away from ⌘W. Reachable from
|
||||
// the Shell menu and the palette; bindable in Settings for anyone who
|
||||
// wants it.
|
||||
("StopWorkspace", ""),
|
||||
("DeleteWorkspace", ""),
|
||||
("RenameWorkspace", ""),
|
||||
("SplitRight", "secondary-d"),
|
||||
("SplitDown", "secondary-shift-d"),
|
||||
("FocusNextPane", "secondary-]"),
|
||||
@@ -145,6 +153,19 @@ pub(crate) fn default_bindings() -> Vec<(&'static str, &'static str)> {
|
||||
("ActivateTab7", "secondary-7"),
|
||||
("ActivateTab8", "secondary-8"),
|
||||
("ActivateTab9", "secondary-9"),
|
||||
// Workspace slots in the Window menu's order. No default chord: ⌘1–9 is
|
||||
// already the tab row's, and a workspace switch is a rarer move than a
|
||||
// tab switch. Clickable in the Window menu and the title-bar chip, and
|
||||
// bindable here for anyone who wants the chord.
|
||||
("SelectWorkspace1", ""),
|
||||
("SelectWorkspace2", ""),
|
||||
("SelectWorkspace3", ""),
|
||||
("SelectWorkspace4", ""),
|
||||
("SelectWorkspace5", ""),
|
||||
("SelectWorkspace6", ""),
|
||||
("SelectWorkspace7", ""),
|
||||
("SelectWorkspace8", ""),
|
||||
("SelectWorkspace9", ""),
|
||||
("IncreaseFontSize", "secondary-="),
|
||||
("DecreaseFontSize", "secondary--"),
|
||||
("ResetFontSize", "secondary-0"),
|
||||
@@ -458,6 +479,10 @@ fn keystroke_is_valid(s: &str) -> bool {
|
||||
fn make_binding(action: &str, keystroke: &str) -> Option<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),
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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))),
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user