diff --git a/CHANGELOG.md b/CHANGELOG.md index f9c89c0a..b2188af6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -81,6 +81,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **The machine that runs your panes now owns their layout** — the workspace, + tab and pane tree has moved out of the app and into the background service, so + one machine has one tree that every client of it reads: the window on it, a + laptop connected to it across the world, and (next) the session CLI. Clients + send named edits ("split this pane", "rename that tab") and receive the + incremental changes other clients make, which is what lets two windows on one + machine both land their work instead of the last one to save winning. A pane's + working directory, its coding agent and whether it is still running are now + observed by the service that owns the PTY rather than remembered by whichever + client last wrote a file — so after a service restart every pane is *known* + dead and revives into its recorded directory with its agent conversation + resumed, with no guessing about which saved ids survived. + + Two consequences worth knowing before you upgrade: + + - **Saved layouts do not carry over.** The tree is a new file + (`~/.local/share/tty7/machine.json`) and the old `session.json` is not read; + the upgrade also replaces the background service, which ends the panes it was + holding. The first launch after upgrading comes up on a fresh workspace, and + tabs from before it are not recoverable. `views.json` (window geometry and + which workspaces you had open) replaces `session.json` for the client's own + half; the old file is left on disk, unread. + - **Windows keeps its panes but not its layout, for now.** The tree is served + over the same control channel remote machines use, and that channel is + Unix-socket-only today, so on Windows tabs do not come back across a restart. + Panes, splits, agents and shell integration are unaffected within a session. + (#260) + - **The prompt editor's soft newline is now a rebindable action** — `Shift+Enter` and `Alt+Enter` have inserted a literal newline into the command editor since the multi-line prompt editor landed, but the chords were hardcoded in the key diff --git a/Cargo.lock b/Cargo.lock index 28b53448..635a2cf0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9606,7 +9606,6 @@ dependencies = [ name = "tty7-server" version = "26.7.6" dependencies = [ - "serde_json", "tempfile", "tty7-core", ] diff --git a/crates/tty7-core/src/core/config.rs b/crates/tty7-core/src/core/config.rs index cbe4e8fd..78b5f1cc 100644 --- a/crates/tty7-core/src/core/config.rs +++ b/crates/tty7-core/src/core/config.rs @@ -963,7 +963,7 @@ pub fn default_config_dir() -> Option { } /// Resolve a file under the config directory (no `dirs` dep). Shared by every -/// config-dir file (`config.json`, `session.json`, `history`). +/// config-dir file (`config.json`, `views.json`, `history`). pub fn config_path(file: &str) -> Option { Some(config_dir()?.join(file)) } @@ -988,8 +988,30 @@ pub fn strip_bom(text: &str) -> &str { /// old file or the new one intact — never a truncated/half-written file that /// fails to parse and silently reverts the user's settings to defaults. The temp /// lives in the same directory so the rename stays on one filesystem (atomic). -/// Shared by `Config::save` and `Session::save`. +/// Shared by `Config::save` and `WindowViews::save`. pub fn write_atomic(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> { + write_atomic_mode(path, bytes, false) +} + +/// [`write_atomic`], with the target owner-only from the first instant its final +/// name exists. +/// +/// The mode is set on the *temp* file, before the rename, for the same reason +/// [`bind_control_socket`](crate::host::server::bind_control_socket) tightens +/// the umask around its `bind` rather than chmod-ing afterwards: a fix-up on the +/// next line is a window in which the file is readable, and under a `umask 002` +/// — the default wherever user-private groups are configured — that window is +/// group-readable. For documents whose contents are the user's business alone: +/// `machine.json` names every workspace's directories, SSH users and hosts, and +/// agent session ids. +/// +/// A no-op difference on Windows, which has no mode bits: the config directory's +/// own ACL is the boundary there, as it is for the daemon's port file. +pub fn write_atomic_private(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> { + write_atomic_mode(path, bytes, true) +} + +fn write_atomic_mode(path: &std::path::Path, bytes: &[u8], private: bool) -> std::io::Result<()> { use std::io::Write as _; let dir = path.parent().unwrap_or_else(|| std::path::Path::new(".")); // Per-process-unique temp name so two concurrent writers don't clobber the @@ -1001,7 +1023,16 @@ pub fn write_atomic(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> std::process::id() )); { - let mut f = std::fs::File::create(&tmp)?; + let mut open = std::fs::OpenOptions::new(); + open.write(true).create(true).truncate(true); + #[cfg(unix)] + if private { + use std::os::unix::fs::OpenOptionsExt as _; + open.mode(0o600); + } + #[cfg(not(unix))] + let _ = private; + let mut f = open.open(&tmp)?; f.write_all(bytes)?; f.flush()?; let _ = f.sync_all(); diff --git a/crates/tty7-core/src/core/machine.rs b/crates/tty7-core/src/core/machine.rs new file mode 100644 index 00000000..d4f636c8 --- /dev/null +++ b/crates/tty7-core/src/core/machine.rs @@ -0,0 +1,2649 @@ +//! The machine's workspace tree, owned by the daemon: the tmux model. +//! +//! # What this replaces, and why +//! +//! The previous design (`core::workspace_store`, since deleted) was an +//! *opaque* record store, where the client owned the schema and the server +//! filed JSON blobs it never read. That shape was right when there was +//! exactly one writer (the GUI) +//! and the server's only job was to make a laptop's layout visible from a +//! desktop. It stops being right the moment two clients — a GUI and a CLI, or +//! two GUIs — write concurrently: whole-record `Put` is last-writer-wins, and +//! a lost update's only symptom is a tab that quietly un-moves itself. +//! +//! So the daemon now owns the tree outright, the way tmux's server owns its +//! sessions: clients send *semantic operations* ("split this pane", "rename +//! that tab"), the daemon validates each against the tree it holds, persists, +//! and broadcasts an incremental [`LayoutDelta`] to every other client. Two +//! clients editing different corners of one workspace both land; a client that +//! falls behind re-pulls the tree it fell behind on. +//! +//! # The shape of the tree +//! +//! ```text +//! Machine +//! ├── workspaces: Vec }> +//! └── panes: Vec ← the pane registry +//! ``` +//! +//! A [`PaneNode::Leaf`] holds a **pane id and nothing else**. Everything that +//! used to ride the client's leaf — cwd, ssh spec, agent identity — is a fact +//! *about the pane*, observed by the daemon itself (OSC 7, the agent hooks, +//! the spawn request), and lives once in the pane registry rather than being a +//! snapshot some client remembered. That is what makes revival sound: after a +//! daemon restart the tree still names its panes, every named pane is known +//! dead (see below), and the pane's own record carries exactly what a client +//! needs to start its successor — the cwd to spawn in, the SSH spec to +//! reconnect, the agent session to `--resume`. +//! +//! # Restart means every pane is dead, and the tree says so +//! +//! PTYs die with the daemon process, so [`load_machine`] force-clears every +//! [`PaneRecord::live`] flag: a freshly-opened store *cannot* claim a live +//! pane, and a leaf whose record answers `live == false` is by construction +//! "awaiting revival". No client-side instance stamps, no id-reuse heuristics +//! — the process that owns the PTYs is the process answering the question, so +//! the answer is a fact rather than a guess. +//! +//! # Paths are `String` here +//! +//! The tree crosses the control wire (replies and [`LayoutDelta`] events), and +//! the dialect's rule is that paths travel as `String` — `PathBuf`'s serde +//! form for a non-UTF-8 path is platform-dependent and unencodable as JSON, +//! and one such cwd must not make a whole workspace unreadable. Lossy +//! conversion happens where the fact is recorded, which is also where the loss +//! is visible in a log. +//! +//! # Concurrency +//! +//! One mutex over the tree *and* the file write, exactly like the store this +//! replaces: the on-disk order is the in-memory order. Deltas are delivered +//! outside the lock, and a subscriber's callback must only enqueue — a peer +//! that stopped reading its socket must not stall another peer's edit. +//! +//! # Two durabilities, because two kinds of change +//! +//! A *structural* edit is persisted before its delta goes out: a change nobody +//! can re-read must be a change nobody was told about ([`Persist::Now`]). +//! +//! An *observation* — a pane's cwd, its agent, its liveness, a workspace's +//! focus stamp — takes [`Persist::Soon`] instead: the delta goes out at once +//! and the file catches up within [`FACT_FLUSH_INTERVAL`]. These arrive from +//! the PTY reader threads, one per OSC 7 report, i.e. once per prompt per pane; +//! writing the whole document (and `fsync`ing it) on each would put a disk +//! stall in the pane's own output path and, because the write happens under +//! `notify_order`, would serialize every other client's edits behind it. What +//! is risked by deferring is at most [`FACT_FLUSH_INTERVAL`] of observations on +//! a `SIGKILL`; the layout itself is never deferred. + +use std::io; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; + +use crate::core::cli_agent::CLIAgent; +use crate::core::session::WorkspaceId; +use crate::daemon::protocol::NativeSshSpec; + +/// The file's name under the data directory ([`DATA_DIR_ENV`] resolves where +/// that is). +/// +/// Deliberately **not** `workspaces.json`: that name belonged to the retired +/// opaque-record store, whose reader quarantined anything it could not parse. +/// A build downgraded across that refactor must find its old file untouched, +/// and this build's tree must not be "repaired" away by the old reader. +pub const MACHINE_FILE: &str = "machine.json"; + +/// Overrides where the machine's data directory lives. Set by tests and by a +/// second server on a shared box — the same escape hatch +/// [`CONTROL_SOCK_ENV`](crate::host::server::CONTROL_SOCK_ENV) is for the +/// socket. +pub const DATA_DIR_ENV: &str = "TTY7_DATA_DIR"; + +/// Ceiling on workspaces, carried over from the old store: a client looping on +/// "create workspace" should hit a named error rather than grow the file until +/// the disk fills. +pub const MAX_WORKSPACES: usize = 1024; + +/// Ceiling on panes the registry will hold. Panes are bounded by what a machine +/// can actually run, so this only ever catches a client gone wrong. +pub const MAX_PANES: usize = 16 * 1024; + +/// How long an observation ([`Persist::Soon`]) may sit in memory before the +/// flusher writes it out. +/// +/// Short enough that a crash costs a stale cwd rather than a stale layout, long +/// enough that a shell looping over directories — a `cd` per iteration, per +/// pane — costs one write rather than one per iteration. +#[cfg(not(test))] +pub const FACT_FLUSH_INTERVAL: Duration = Duration::from_secs(2); + +/// Out of reach under test, so the assertions about *what defers* are not also +/// assertions about how fast the suite runs: a test that wants the write calls +/// [`MachineStore::flush`], which is the same code path the timer takes. +#[cfg(test)] +pub const FACT_FLUSH_INTERVAL: Duration = Duration::from_secs(600); + +// --------------------------------------------------------------------------- +// Identity +// --------------------------------------------------------------------------- + +/// Stable identity for one tab, minted by the daemon when the tab is created +/// and carried across restarts. +/// +/// Tabs need an identity of their own because operations address them across +/// reorders: "rename tab 2" from a client that has not yet heard about another +/// client's move would rename the wrong tab, while "rename tab `t-…`" cannot. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct TabId(uuid::Uuid); + +impl TabId { + pub fn new() -> Self { + Self(uuid::Uuid::new_v4()) + } +} + +impl Default for TabId { + fn default() -> Self { + Self::new() + } +} + +impl std::fmt::Display for TabId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +/// Split orientation. Its own enum rather than a reuse of the client session +/// model's, because this schema is the daemon's to evolve and must not be +/// coupled to a file format that is on its way out. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Axis { + Horizontal, + Vertical, +} + +/// Which child of a [`PaneNode::Split`] a path step descends into. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Side { + A, + B, +} + +// --------------------------------------------------------------------------- +// The tree +// --------------------------------------------------------------------------- + +/// Everything one machine's daemon knows about its workspaces. The document +/// [`MachineStore`] persists, and the payload a full pull returns. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct Machine { + #[serde(default)] + pub workspaces: Vec, + /// The pane registry: every pane the tree references, by id. Facts about + /// panes live here exactly once — see the module header. + #[serde(default)] + pub panes: Vec, +} + +/// Who is currently attached to a workspace. +/// +/// **Data only.** The takeover behaviour — push `Preempted { by }` to the old +/// session, close its streams, offer a take-back button — lives in the control +/// server. What is here is the record that machinery needs to exist before it +/// can be written: the random token that tells two connections from the same +/// client apart, and the hostname that fills in "already open on ". Both +/// arrive in the [`ControlHello`](crate::daemon::control::ControlHello). +/// +/// **Never persisted** (the field carrying it is `#[serde(skip)]`): an +/// attachment describes a live connection; after a server restart there are +/// none, and a stale one on disk would report a takeover against a client +/// that no longer exists. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Attachment { + /// The client's per-session random token, from `ControlHello::client_token`. + pub token: String, + /// The client machine's hostname, shown to the user in the preempted + /// window's status bar. + pub hostname: String, + /// Unix seconds when the attach happened. + pub since: u64, +} + +impl Attachment { + /// An attachment stamped now. + pub fn new(token: impl Into, hostname: impl Into) -> Attachment { + Attachment { + token: token.into(), + hostname: hostname.into(), + since: unix_now(), + } + } +} + +/// One workspace: a named group of tabs. The unit a window shows and a client +/// attaches to. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Workspace { + #[serde(default)] + pub id: WorkspaceId, + /// User-set name. `None` lets clients derive one from the tabs' repo/cwd. + #[serde(default)] + pub name: Option, + /// Unix seconds when a client last focused this workspace. 0 == never. + #[serde(default)] + pub last_active: u64, + #[serde(default)] + pub tabs: Vec, + /// Which tab is active. `None` for a workspace with no tabs (a real state: + /// the home page), and healed to a real tab whenever one exists. + #[serde(default)] + pub active_tab: Option, + /// Who is attached right now. **Runtime only** — an attachment describes a + /// live connection, and a stale one on disk would report a takeover + /// against a client that no longer exists. + #[serde(skip)] + pub attachment: Option, +} + +impl Default for Workspace { + fn default() -> Self { + Workspace { + id: WorkspaceId::new(), + name: None, + last_active: unix_now(), + tabs: Vec::new(), + active_tab: None, + attachment: None, + } + } +} + +/// One tab: a pane tree plus its labels. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Tab { + #[serde(default)] + pub id: TabId, + /// User-set name from "Rename Tab". `None` falls back to a title-derived + /// label at render time, on the client. + #[serde(default)] + pub name: Option, + /// The tab's sidebar repo group (its repository home), as the client that + /// resolved it reported. A path in the *machine's* namespace, as a string + /// for the same reason every other path here is. + #[serde(default)] + pub sidebar_group: Option, + pub root: PaneNode, +} + +impl Tab { + /// A tab holding exactly `pane`. + pub fn leaf(pane: u64) -> Tab { + Tab { + id: TabId::new(), + name: None, + sidebar_group: None, + root: PaneNode::Leaf { pane }, + } + } +} + +/// A tab's split structure. Leaves hold a pane **id and nothing else**; every +/// fact about the pane lives in the registry ([`PaneRecord`]). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum PaneNode { + Leaf { + pane: u64, + }, + Split { + axis: Axis, + #[serde(default = "default_ratio")] + ratio: f32, + a: Box, + b: Box, + }, +} + +fn default_ratio() -> f32 { + 0.5 +} + +impl PaneNode { + /// Every pane id under this node, in layout order. + pub fn pane_ids(&self) -> Vec { + let mut out = Vec::new(); + self.collect_panes(&mut out); + out + } + + fn collect_panes(&self, out: &mut Vec) { + match self { + PaneNode::Leaf { pane } => out.push(*pane), + PaneNode::Split { a, b, .. } => { + a.collect_panes(out); + b.collect_panes(out); + } + } + } + + /// Whether `pane` appears as a leaf under this node. + pub fn contains(&self, pane: u64) -> bool { + match self { + PaneNode::Leaf { pane: p } => *p == pane, + PaneNode::Split { a, b, .. } => a.contains(pane) || b.contains(pane), + } + } + + /// The node a split path resolves to, if the path is still valid. Public + /// for the same reason the surgery methods are: a client applying a + /// [`LayoutDelta::RatioChanged`] resolves the identical path. + pub fn descend_mut(&mut self, path: &[Side]) -> Option<&mut PaneNode> { + match path.split_first() { + None => Some(self), + Some((side, rest)) => match self { + PaneNode::Leaf { .. } => None, + PaneNode::Split { a, b, .. } => match side { + Side::A => a.descend_mut(rest), + Side::B => b.descend_mut(rest), + }, + }, + } + } + + /// Replace the leaf holding `pane` with a split of it and `new`, answering + /// whether the leaf was found. + /// + /// Public (as are [`remove_leaf`](PaneNode::remove_leaf) and + /// [`replace_leaf`](PaneNode::replace_leaf)) because a client predicting the + /// outcome of its own operation must run *this* surgery, not a + /// reimplementation that could disagree with the server's. + pub fn split_leaf(&mut self, pane: u64, new: u64, axis: Axis, ratio: f32, first: bool) -> bool { + match self { + PaneNode::Leaf { pane: p } if *p == pane => { + let old = PaneNode::Leaf { pane }; + let added = PaneNode::Leaf { pane: new }; + let (a, b) = if first { (added, old) } else { (old, added) }; + *self = PaneNode::Split { + axis, + ratio, + a: Box::new(a), + b: Box::new(b), + }; + true + } + PaneNode::Leaf { .. } => false, + PaneNode::Split { a, b, .. } => { + a.split_leaf(pane, new, axis, ratio, first) + || b.split_leaf(pane, new, axis, ratio, first) + } + } + } + + /// Remove the leaf holding `pane`, collapsing its parent split so the + /// sibling takes the whole space. `None` when the node *is* that leaf — + /// the caller then removes the tab. `Some(found)` otherwise. + pub fn remove_leaf(&mut self, pane: u64) -> Option { + match self { + PaneNode::Leaf { pane: p } => { + if *p == pane { + None + } else { + Some(false) + } + } + PaneNode::Split { a, b, .. } => { + if matches!(&**a, PaneNode::Leaf { pane: p } if *p == pane) { + *self = (**b).clone(); + return Some(true); + } + if matches!(&**b, PaneNode::Leaf { pane: p } if *p == pane) { + *self = (**a).clone(); + return Some(true); + } + match a.remove_leaf(pane) { + Some(true) => Some(true), + Some(false) => b.remove_leaf(pane), + // A whole subtree cannot be the leaf; unreachable because + // leaf children are handled above, but total anyway. + None => Some(false), + } + } + } + } + + /// Rebind the leaf holding `old` to `new`, answering whether it was found. + pub fn replace_leaf(&mut self, old: u64, new: u64) -> bool { + match self { + PaneNode::Leaf { pane } if *pane == old => { + *pane = new; + true + } + PaneNode::Leaf { .. } => false, + PaneNode::Split { a, b, .. } => a.replace_leaf(old, new) || b.replace_leaf(old, new), + } + } +} + +/// One pane, as the daemon knows it: identity, liveness, and the facts a dead +/// pane's successor is started from. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PaneRecord { + /// The daemon's pane id — the same number the pane protocol's `Spawn` + /// answered with. One id space, so a leaf, a `PaneInfo` and this record + /// can only ever mean the same pane. + pub id: u64, + /// Working directory, from OSC 7 (or the spawn request until the first + /// report). The machine's own namespace. + #[serde(default)] + pub cwd: Option, + // No `title` field, deliberately. The pane's title is a *live* answer (a + // foreground-process query at `PaneInfo` time), not tracked state the + // reader loop observes — so a record field for it was never written, and + // a field that is always empty is a standing invitation to trust it. + // Revival labels derive from `cwd` and `agent` instead. + /// The native-SSH spec this pane ran, **secrets stripped** + /// ([`NativeSshSpec::without_secrets`]). What a revival reconnects with. + #[serde(default)] + pub ssh_spec: Option>, + /// The coding agent running in this pane, if the hooks reported one. + #[serde(default)] + pub agent: Option, + /// Whether a PTY for this pane exists **in this daemon process**. + /// + /// Serialized, because clients read it off the wire — `false` on a leaf's + /// record *is* the "awaiting revival" state a client renders and revives. + /// But it is a fact about a *process*, so [`load_machine`] force-clears it + /// on open: PTYs die with the daemon, and whatever the file claims, a + /// freshly-started process has none. No client-side instance stamp or + /// id-reuse heuristic is needed, because the process that owns the PTYs is + /// the one answering. + #[serde(default)] + pub live: bool, +} + +impl PaneRecord { + /// A bare record for `id`, with no facts yet. + pub fn new(id: u64) -> PaneRecord { + PaneRecord { + id, + cwd: None, + ssh_spec: None, + agent: None, + live: false, + } + } +} + +/// What the daemon knows about the agent a pane runs — enough to resume the +/// conversation in a successor pane after the original dies. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct AgentFacts { + pub agent: CLIAgent, + /// The agent's own session id, from its `session-start` hook. What + /// `claude --resume ` (and each agent's equivalent) takes. + #[serde(default)] + pub session_id: Option, + /// The argv the agent was launched with, so a resume carries the user's + /// flags (`--dangerously-skip-permissions`, …) instead of resuming bare. + #[serde(default)] + pub launch_argv: Option>, + /// Latest coarse status the daemon's sniffer folded from the agent's + /// hook events. Display only; never load-bearing. + #[serde(default)] + pub status: Option, +} + +/// The facts a client hands over when an operation introduces a pane the store +/// has not seen — a new tab's pane, a split's second pane, a revival's +/// replacement. The pane itself was spawned over the pane protocol (that is +/// where PTYs come from); this is its birth certificate for the tree. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PaneSeed { + pub pane: u64, + #[serde(default)] + pub cwd: Option, + #[serde(default)] + pub ssh_spec: Option>, + #[serde(default)] + pub agent: Option, +} + +impl PaneSeed { + /// A seed carrying only the id. + pub fn bare(pane: u64) -> PaneSeed { + PaneSeed { + pane, + cwd: None, + ssh_spec: None, + agent: None, + } + } + + fn into_record(self, live: bool) -> PaneRecord { + PaneRecord { + id: self.pane, + cwd: self.cwd, + ssh_spec: self.ssh_spec.map(|s| Box::new(s.without_secrets())), + agent: self.agent, + live, + } + } +} + +// --------------------------------------------------------------------------- +// Deltas +// --------------------------------------------------------------------------- + +/// One incremental change to one workspace's tree, as broadcast to every +/// client but the writer. +/// +/// The granularity rule: label changes are carried field-by-field, structural +/// changes carry the whole affected [`Tab`]. A tab is small (a few hundred +/// bytes), and shipping it whole means a client applies structure by +/// *replacement* instead of by re-implementing the server's tree surgery — +/// the class of client/server divergence that cannot happen is the class that +/// was never written. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LayoutDelta { + /// A workspace appeared. Carries it whole (it is newborn, so small). + WorkspaceCreated { + workspace: Workspace, + }, + WorkspaceRenamed { + name: Option, + }, + WorkspaceDeleted, + WorkspaceTouched { + last_active: u64, + }, + /// Which tab is active changed — by an explicit set, by a created tab + /// becoming active, or by the close paths healing a dangling active id. + /// Emitted for every *implicit* change too, so a mirroring client never + /// has to re-implement the server's heal rule; the one inexpressible case + /// (a workspace losing its last tab has no active tab) needs no delta, + /// because "no tabs → no active tab" is a fact, not surgery. + ActiveTabChanged { + tab: TabId, + }, + /// A tab appeared at `at`. Structural, so it carries the tab whole. + TabCreated { + at: usize, + tab: Tab, + }, + TabClosed { + tab: TabId, + }, + TabRenamed { + tab: TabId, + name: Option, + }, + TabMoved { + tab: TabId, + to: usize, + }, + TabRegrouped { + tab: TabId, + group: Option, + }, + /// A tab's pane structure changed (split, close, revival rebind). The tab + /// is carried whole — see the enum's granularity rule. `pane` names the + /// registry record that changed alongside, when one did. + TabRestructured { + tab: Tab, + pane: Option, + }, + /// One split's divider moved. Fine-grained because ratio drags are the + /// hottest structural edit and the only one where shipping a whole tab + /// per event would be felt. + RatioChanged { + tab: TabId, + path: Vec, + ratio: f32, + }, + /// A pane's facts changed (cwd, agent, liveness). Not a layout change, + /// but clients rendering "awaiting revival" or an agent chip need it. + PaneFacts { + pane: PaneRecord, + }, +} + +/// Identifies one subscriber, so a writer is excluded from its own echo. +/// Same shape as the old store's, for the same reason. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct SubscriberId(pub u64); + +/// What a subscriber receives: which workspace, and what changed. Runs on the +/// writer's thread — enqueue and return. +pub type Notify = Arc; + +/// A live subscription; dropping it unsubscribes. +pub struct Subscription { + store: Arc, + id: SubscriberId, +} + +impl Subscription { + /// This subscriber's id — pass it as the `origin` of your own writes. + pub fn id(&self) -> SubscriberId { + self.id + } +} + +impl Drop for Subscription { + fn drop(&mut self) { + self.store.unsubscribe(self.id); + } +} + +// --------------------------------------------------------------------------- +// The store +// --------------------------------------------------------------------------- + +/// How the store asks the process serving panes whether an id has a live PTY +/// *right now* — see [`MachineStore::set_liveness_probe`]. +pub type LivenessProbe = Arc bool + Send + Sync>; + +/// The daemon's tree, and the one writer to its file. +pub struct MachineStore { + path: PathBuf, + state: Mutex, + /// Answers "does this pane have a live PTY right now", installed by the + /// daemon's pane server. `None` (a store opened by tests, or before the + /// pane listener is wired) trusts the seed. See + /// [`set_liveness_probe`](MachineStore::set_liveness_probe). + liveness: Mutex>, + /// Serializes each mutation *with its own delivery*. The state lock alone + /// orders the mutations, but deltas are delivered after it is released — + /// without this, writer B's deltas could overtake writer A's and every + /// subscriber would apply the store's history in the wrong order, ending + /// on the losing state with no error to trigger a re-pull. Cheap to hold + /// across delivery because a subscriber's callback is enqueue-only by + /// contract. Always taken before `state`, never inside it. + notify_order: Mutex<()>, + subscribers: Mutex>, + next_subscriber: AtomicU64, + /// Set by a [`Persist::Soon`] mutation, cleared by every write — the + /// flusher's whole state. Never a reason to write on its own: a store that + /// only ever sees structural edits has no flusher at all. + unwritten: AtomicBool, + /// Whether the flusher thread has been started, so the first observation + /// starts it and the rest cost one atomic load. + flushing: AtomicBool, +} + +/// When an operation's change has to be on disk. See the module header. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum Persist { + /// Before the deltas go out — every structural edit. + Now, + /// Within [`FACT_FLUSH_INTERVAL`] — the machine's own observations. + Soon, +} + +/// The error every invalid operation answers with. `InvalidInput` so the wire +/// layer maps it to a client-visible refusal rather than a server fault. +fn refuse(msg: impl Into) -> io::Error { + io::Error::new(io::ErrorKind::InvalidInput, msg.into()) +} + +fn not_found(msg: impl Into) -> io::Error { + io::Error::new(io::ErrorKind::NotFound, msg.into()) +} + +impl MachineStore { + /// Open the store at `path`, reading whatever is there. + /// + /// Infallible by design: a machine whose tree file is missing or + /// unreadable must still serve panes and files. A file that does not parse + /// is copied aside as `machine.json.corrupt` before anything overwrites + /// it, so "the tree came up empty" is recoverable by hand. + pub fn open(path: impl Into) -> Arc { + let path = path.into(); + let machine = load_machine(&path); + Arc::new(MachineStore { + path, + state: Mutex::new(machine), + liveness: Mutex::new(None), + notify_order: Mutex::new(()), + subscribers: Mutex::new(Vec::new()), + next_subscriber: AtomicU64::new(1), + unwritten: AtomicBool::new(false), + flushing: AtomicBool::new(false), + }) + } + + /// Install the pane server's answer to "is this pane alive right now", + /// consulted whenever a seed introduces a pane to the registry. + /// + /// A seed used to enter the registry `live: true` unconditionally — but a + /// pane that died between its spawn and its adopting operation had its + /// death observation dropped ([`MachineStore::note_pane_facts`] ignores + /// panes the tree does not hold), and nothing ever flipped the record back: + /// the leaf claimed a live pane forever and revival never offered. Asking + /// the process that owns the PTYs at registration time closes the window. + pub fn set_liveness_probe(&self, probe: LivenessProbe) { + *self.liveness.lock().unwrap_or_else(|e| e.into_inner()) = Some(probe); + } + + /// Whether a seeded pane is alive, per the installed probe. Without one + /// the seed is trusted (`true`): the seeding client just spawned it. + fn seed_is_live(&self, pane: u64) -> bool { + let probe = self + .liveness + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone(); + match probe { + Some(probe) => probe(pane), + None => true, + } + } + + /// Open the store at its default location under the data directory. + pub fn shared() -> io::Result> { + Ok(MachineStore::open(default_machine_path()?)) + } + + /// Where this store is persisted. + pub fn path(&self) -> &Path { + &self.path + } + + // ----- reads ----------------------------------------------------------- + + /// A snapshot of the whole tree. What a full pull answers with. + pub fn machine(&self) -> Machine { + self.locked().clone() + } + + /// One workspace, whole. `NotFound` when there is no such workspace. + pub fn workspace(&self, id: WorkspaceId) -> io::Result { + self.locked() + .workspaces + .iter() + .find(|w| w.id == id) + .cloned() + .ok_or_else(|| not_found(format!("no workspace {id} on this machine"))) + } + + /// One pane's record. + pub fn pane(&self, id: u64) -> Option { + self.locked().panes.iter().find(|p| p.id == id).cloned() + } + + // ----- workspace operations -------------------------------------------- + + /// Create a workspace (empty — its first tab arrives as its own op). + /// + /// `id` lets the *client* mint the identity. A window exists before its + /// first round trip completes — the window registry, the view file and + /// every queued operation already name the workspace — so making the + /// daemon the minter would force every client to hold its ops until a + /// reply carried the "real" id back. Ids are uuids, so a client-minted one + /// is as unique as a daemon-minted one; a collision with an existing + /// workspace is refused rather than adopted, because "create" answering an + /// unrelated workspace's tree would hand one client another's tabs. + pub fn workspace_create( + &self, + id: Option, + name: Option, + origin: Option, + ) -> io::Result { + let created = self.mutate(origin, |m| { + if m.workspaces.len() >= MAX_WORKSPACES { + return Err(refuse(format!( + "this machine already holds {MAX_WORKSPACES} workspaces" + ))); + } + if let Some(id) = id + && m.workspaces.iter().any(|w| w.id == id) + { + return Err(refuse(format!("workspace {id} already exists"))); + } + let workspace = Workspace { + id: id.unwrap_or_default(), + name: name.clone(), + ..Workspace::default() + }; + m.workspaces.push(workspace.clone()); + Ok(( + workspace.clone(), + vec![( + workspace.id, + LayoutDelta::WorkspaceCreated { + workspace: workspace.clone(), + }, + )], + )) + })?; + Ok(created) + } + + /// Set (or clear) a workspace's user-chosen name. + pub fn workspace_rename( + &self, + id: WorkspaceId, + name: Option, + origin: Option, + ) -> io::Result<()> { + self.mutate(origin, |m| { + let ws = find_workspace(m, id)?; + ws.name = name.clone(); + Ok(((), vec![(id, LayoutDelta::WorkspaceRenamed { name })])) + }) + } + + /// Forget a workspace and every pane record only it referenced. + /// + /// Answers the ids of the panes that went with it, so the caller can kill + /// their PTYs — the store never touches a process, only bookkeeping. + pub fn workspace_delete( + &self, + id: WorkspaceId, + origin: Option, + ) -> io::Result> { + self.mutate(origin, |m| { + let index = m + .workspaces + .iter() + .position(|w| w.id == id) + .ok_or_else(|| not_found(format!("no workspace {id} on this machine")))?; + m.workspaces.remove(index); + let orphans = collect_orphan_panes(m); + m.panes.retain(|p| !orphans.contains(&p.id)); + Ok((orphans, vec![(id, LayoutDelta::WorkspaceDeleted)])) + }) + } + + /// Stamp a workspace as just-focused. + /// + /// An observation, not a structural edit ([`Persist::Soon`]): every window + /// focus change on every client lands one, and a picker's ordering is not + /// worth a `fsync` per keystroke-of-attention. + pub fn workspace_touch( + self: &Arc, + id: WorkspaceId, + origin: Option, + ) -> io::Result<()> { + self.ensure_flusher(); + self.mutate_with(origin, Persist::Soon, |m| { + let ws = find_workspace(m, id)?; + let now = unix_now(); + ws.last_active = now; + Ok(( + (), + vec![(id, LayoutDelta::WorkspaceTouched { last_active: now })], + )) + }) + } + + /// Change which tab is active. + pub fn workspace_set_active_tab( + &self, + id: WorkspaceId, + tab: TabId, + origin: Option, + ) -> io::Result<()> { + self.mutate(origin, |m| { + let ws = find_workspace(m, id)?; + if !ws.tabs.iter().any(|t| t.id == tab) { + return Err(not_found(format!("workspace {id} has no tab {tab}"))); + } + ws.active_tab = Some(tab); + Ok(((), vec![(id, LayoutDelta::ActiveTabChanged { tab })])) + }) + } + + // ----- tab operations -------------------------------------------------- + + /// Create a tab holding `pane`, at `at` (clamped; `None` appends), and make + /// it active — a created tab is one the user is about to type into. + /// + /// `id` is client-mintable for the same reason + /// [`workspace_create`](MachineStore::workspace_create)'s is: the client's + /// window holds the tab (and may already have queued operations against it) + /// before the reply lands, and a uuid minted there is as good as one minted + /// here. A duplicate is refused, never adopted. + pub fn tab_create( + &self, + workspace: WorkspaceId, + at: Option, + pane: PaneSeed, + id: Option, + origin: Option, + ) -> io::Result { + let live = self.seed_is_live(pane.pane); + self.mutate(origin, |m| { + if let Some(id) = id + && m.workspaces + .iter() + .any(|w| w.tabs.iter().any(|t| t.id == id)) + { + return Err(refuse(format!("tab {id} already exists"))); + } + register_pane(m, pane.clone(), live)?; + let ws = find_workspace(m, workspace)?; + let mut tab = Tab::leaf(pane.pane); + if let Some(id) = id { + tab.id = id; + } + let tab = tab; + let at = at.unwrap_or(ws.tabs.len()).min(ws.tabs.len()); + ws.tabs.insert(at, tab.clone()); + ws.active_tab = Some(tab.id); + let active = tab.id; + Ok(( + tab.clone(), + vec![ + (workspace, LayoutDelta::TabCreated { at, tab }), + (workspace, LayoutDelta::ActiveTabChanged { tab: active }), + ], + )) + }) + } + + /// Close a tab, answering the pane ids that left the tree with it (for the + /// caller to kill — see [`MachineStore::workspace_delete`]). + pub fn tab_close( + &self, + workspace: WorkspaceId, + tab: TabId, + origin: Option, + ) -> io::Result> { + self.mutate(origin, |m| { + let ws = find_workspace(m, workspace)?; + let index = ws + .tabs + .iter() + .position(|t| t.id == tab) + .ok_or_else(|| not_found(format!("workspace {workspace} has no tab {tab}")))?; + ws.tabs.remove(index); + let mut deltas = vec![(workspace, LayoutDelta::TabClosed { tab })]; + if let Some(active) = heal_active_tab(ws, index) { + deltas.push((workspace, LayoutDelta::ActiveTabChanged { tab: active })); + } + let orphans = collect_orphan_panes(m); + m.panes.retain(|p| !orphans.contains(&p.id)); + Ok((orphans, deltas)) + }) + } + + /// Set (or clear) a tab's user-chosen name. + pub fn tab_rename( + &self, + workspace: WorkspaceId, + tab: TabId, + name: Option, + origin: Option, + ) -> io::Result<()> { + self.mutate(origin, |m| { + let t = find_tab(m, workspace, tab)?; + t.name = name.clone(); + Ok(((), vec![(workspace, LayoutDelta::TabRenamed { tab, name })])) + }) + } + + /// Move a tab to position `to` (clamped). + pub fn tab_move( + &self, + workspace: WorkspaceId, + tab: TabId, + to: usize, + origin: Option, + ) -> io::Result<()> { + self.mutate(origin, |m| { + let ws = find_workspace(m, workspace)?; + let from = ws + .tabs + .iter() + .position(|t| t.id == tab) + .ok_or_else(|| not_found(format!("workspace {workspace} has no tab {tab}")))?; + let moved = ws.tabs.remove(from); + let to = to.min(ws.tabs.len()); + ws.tabs.insert(to, moved); + Ok(((), vec![(workspace, LayoutDelta::TabMoved { tab, to })])) + }) + } + + /// Record which repo group a tab belongs to in the sidebar. + pub fn tab_set_group( + &self, + workspace: WorkspaceId, + tab: TabId, + group: Option, + origin: Option, + ) -> io::Result<()> { + self.mutate(origin, |m| { + let t = find_tab(m, workspace, tab)?; + t.sidebar_group = group.clone(); + Ok(( + (), + vec![(workspace, LayoutDelta::TabRegrouped { tab, group })], + )) + }) + } + + // ----- pane operations ------------------------------------------------- + + /// Split the leaf holding `pane`: the new pane takes the `first` (upper / + /// left) or second position, at `ratio`. + pub fn pane_split( + &self, + workspace: WorkspaceId, + pane: u64, + axis: Axis, + ratio: f32, + new: PaneSeed, + first: bool, + origin: Option, + ) -> io::Result<()> { + let ratio = clamp_ratio(ratio)?; + let live = self.seed_is_live(new.pane); + self.mutate(origin, |m| { + register_pane(m, new.clone(), live)?; + let record = m + .panes + .iter() + .find(|p| p.id == new.pane) + .cloned() + .expect("registered above"); + let ws = find_workspace(m, workspace)?; + let tab = ws + .tabs + .iter_mut() + .find(|t| t.root.contains(pane)) + .ok_or_else(|| { + not_found(format!("workspace {workspace} has no pane {pane} to split")) + })?; + tab.root.split_leaf(pane, new.pane, axis, ratio, first); + let delta = LayoutDelta::TabRestructured { + tab: tab.clone(), + pane: Some(record), + }; + Ok(((), vec![(workspace, delta)])) + }) + } + + /// Remove the leaf holding `pane`. When it was the tab's last pane the tab + /// closes with it. Answers the pane ids that left the tree. + pub fn pane_close( + &self, + workspace: WorkspaceId, + pane: u64, + origin: Option, + ) -> io::Result> { + self.mutate(origin, |m| { + let ws = find_workspace(m, workspace)?; + let index = ws + .tabs + .iter() + .position(|t| t.root.contains(pane)) + .ok_or_else(|| not_found(format!("workspace {workspace} has no pane {pane}")))?; + let mut deltas = Vec::new(); + match ws.tabs[index].root.remove_leaf(pane) { + // The tab was that one leaf: the tab goes. + None => { + let closed = ws.tabs.remove(index); + deltas.push((workspace, LayoutDelta::TabClosed { tab: closed.id })); + if let Some(active) = heal_active_tab(ws, index) { + deltas.push((workspace, LayoutDelta::ActiveTabChanged { tab: active })); + } + } + Some(true) => deltas.push(( + workspace, + LayoutDelta::TabRestructured { + tab: ws.tabs[index].clone(), + pane: None, + }, + )), + Some(false) => unreachable!("the tab was chosen because it contains the pane"), + }; + let orphans = collect_orphan_panes(m); + m.panes.retain(|p| !orphans.contains(&p.id)); + Ok((orphans, deltas)) + }) + } + + /// Move a split's divider. `path` addresses the split from the tab root. + pub fn pane_set_ratio( + &self, + workspace: WorkspaceId, + tab: TabId, + path: Vec, + ratio: f32, + origin: Option, + ) -> io::Result<()> { + let ratio = clamp_ratio(ratio)?; + self.mutate(origin, |m| { + let t = find_tab(m, workspace, tab)?; + match t.root.descend_mut(&path) { + Some(PaneNode::Split { ratio: r, .. }) => *r = ratio, + _ => { + return Err(refuse(format!( + "tab {tab} has no split at that path any more" + ))); + } + } + Ok(( + (), + vec![(workspace, LayoutDelta::RatioChanged { tab, path, ratio })], + )) + }) + } + + /// Move the leaf holding `pane` next to `to`, splitting it along `axis`. + /// The tmux `move-pane`: remove from where it is (collapsing that split), + /// then re-split at the destination. + pub fn pane_move( + &self, + workspace: WorkspaceId, + pane: u64, + to: u64, + axis: Axis, + first: bool, + origin: Option, + ) -> io::Result<()> { + if pane == to { + return Err(refuse("a pane cannot be moved next to itself")); + } + self.mutate(origin, |m| { + let ws = find_workspace(m, workspace)?; + let from = ws + .tabs + .iter() + .position(|t| t.root.contains(pane)) + .ok_or_else(|| not_found(format!("workspace {workspace} has no pane {pane}")))?; + let dest = ws + .tabs + .iter() + .position(|t| t.root.contains(to)) + .ok_or_else(|| not_found(format!("workspace {workspace} has no pane {to}")))?; + + let mut deltas: Vec<(WorkspaceId, LayoutDelta)> = Vec::new(); + match ws.tabs[from].root.remove_leaf(pane) { + None => { + // The pane was a whole tab; that tab dissolves into the + // destination. + if from == dest { + return Err(refuse("a pane cannot be moved next to itself".to_string())); + } + let closed = ws.tabs.remove(from); + deltas.push((workspace, LayoutDelta::TabClosed { tab: closed.id })); + if let Some(active) = heal_active_tab(ws, from) { + deltas.push((workspace, LayoutDelta::ActiveTabChanged { tab: active })); + } + } + Some(true) => { + deltas.push(( + workspace, + LayoutDelta::TabRestructured { + tab: ws.tabs[from].clone(), + pane: None, + }, + )); + } + Some(false) => unreachable!("the tab was chosen because it contains the pane"), + } + // Indices may have shifted if a tab was removed above. + let dest_tab = ws + .tabs + .iter_mut() + .find(|t| t.root.contains(to)) + .expect("the destination tab still exists; only the source tab can close"); + dest_tab.root.split_leaf(to, pane, axis, 0.5, first); + deltas.push(( + workspace, + LayoutDelta::TabRestructured { + tab: dest_tab.clone(), + pane: None, + }, + )); + Ok(((), deltas)) + }) + } + + /// Rebind the leaf holding `old` to a freshly-spawned successor — the + /// revival op. The old record leaves the registry with its facts spent. + pub fn pane_replace( + &self, + workspace: WorkspaceId, + old: u64, + new: PaneSeed, + origin: Option, + ) -> io::Result<()> { + let live = self.seed_is_live(new.pane); + self.mutate(origin, |m| { + register_pane(m, new.clone(), live)?; + let record = m + .panes + .iter() + .find(|p| p.id == new.pane) + .cloned() + .expect("registered above"); + let ws = find_workspace(m, workspace)?; + let tab = ws + .tabs + .iter_mut() + .find(|t| t.root.contains(old)) + .ok_or_else(|| not_found(format!("workspace {workspace} has no pane {old}")))?; + tab.root.replace_leaf(old, new.pane); + let delta = LayoutDelta::TabRestructured { + tab: tab.clone(), + pane: Some(record), + }; + m.panes.retain(|p| p.id != old); + Ok(((), vec![(workspace, delta)])) + }) + } + + // ----- pane facts (the daemon's own observations) ---------------------- + + /// Record facts the daemon observed about `pane` — OSC 7 cwd, agent hook + /// events, liveness. Unknown panes are ignored (a pane + /// the tree never adopted is not the tree's business). The delta is + /// attributed to no origin: facts come from the machine, so *every* + /// client hears them. + /// + /// Called from the pane reader threads, once per prompt per pane, so the + /// write is deferred ([`Persist::Soon`]) while the delta is not: what a + /// client renders stays current, and the disk catches up on the flusher's + /// tick. + pub fn note_pane_facts(self: &Arc, pane: u64, update: impl FnOnce(&mut PaneRecord)) { + self.ensure_flusher(); + let result: io::Result<()> = self.mutate_with(None, Persist::Soon, |m| { + let Some(record) = m.panes.iter_mut().find(|p| p.id == pane) else { + return Ok(((), Vec::new())); + }; + let before = record.clone(); + update(record); + record.id = before.id; + if *record == before { + return Ok(((), Vec::new())); + } + let record = record.clone(); + let workspaces: Vec = m + .workspaces + .iter() + .filter(|w| w.tabs.iter().any(|t| t.root.contains(pane))) + .map(|w| w.id) + .collect(); + Ok(( + (), + workspaces + .into_iter() + .map(|w| { + ( + w, + LayoutDelta::PaneFacts { + pane: record.clone(), + }, + ) + }) + .collect(), + )) + }); + if let Err(e) = result { + log::warn!("could not record facts about pane {pane}: {e}"); + } + } + + // ----- attachment (runtime; never persisted) ---------------------------- + + /// Record `who` as the workspace's current session and answer whoever held + /// it before — the data half of the takeover, unchanged in meaning from + /// the old store's. + pub fn attach(&self, workspace: WorkspaceId, who: Attachment) -> Option { + let mut m = self.locked(); + let ws = m.workspaces.iter_mut().find(|w| w.id == workspace)?; + ws.attachment.replace(who) + } + + /// Who is attached to `workspace`, if anyone. + pub fn attachment(&self, workspace: WorkspaceId) -> Option { + self.locked() + .workspaces + .iter() + .find(|w| w.id == workspace) + .and_then(|w| w.attachment.clone()) + } + + /// Release `workspace`, but **only if `token` still holds it** — the guard + /// that keeps a preempted client's teardown from evicting its usurper. + pub fn detach(&self, workspace: WorkspaceId, token: &str) -> bool { + let mut m = self.locked(); + let Some(ws) = m.workspaces.iter_mut().find(|w| w.id == workspace) else { + return false; + }; + if ws.attachment.as_ref().is_some_and(|a| a.token == token) { + ws.attachment = None; + true + } else { + false + } + } + + // ----- change notification --------------------------------------------- + + /// Be told about every delta. Dropping the [`Subscription`] unsubscribes. + /// The callback runs on the writer's thread: enqueue and return. + pub fn subscribe(self: &Arc, f: Notify) -> Subscription { + let id = SubscriberId(self.next_subscriber.fetch_add(1, Ordering::Relaxed)); + self.subscribers + .lock() + .unwrap_or_else(|e| e.into_inner()) + .push((id, f)); + Subscription { + store: Arc::clone(self), + id, + } + } + + fn unsubscribe(&self, id: SubscriberId) { + self.subscribers + .lock() + .unwrap_or_else(|e| e.into_inner()) + .retain(|(sid, _)| *sid != id); + } + + // ----- internals ------------------------------------------------------- + + fn locked(&self) -> std::sync::MutexGuard<'_, Machine> { + // A poisoned lock means a panic mid-mutation. Every *fallible* path + // rolls back before releasing the lock (see `mutate`); the only + // panics inside an op are `unreachable!`/`expect`s on invariants the + // same op just established, so a poisoned tree is still the pre- or + // post-images of some operation. Carrying on beats taking the daemon + // — and every pane on the machine — down with a bookkeeping panic. + self.state.lock().unwrap_or_else(|e| e.into_inner()) + } + + /// [`mutate_with`](Self::mutate_with) at [`Persist::Now`] — every + /// structural operation. + fn mutate( + &self, + origin: Option, + op: impl FnOnce(&mut Machine) -> io::Result<(T, Vec<(WorkspaceId, LayoutDelta)>)>, + ) -> io::Result { + self.mutate_with(origin, Persist::Now, op) + } + + /// Run one operation: mutate under the lock, persist, and — only if the + /// disk said yes — deliver the deltas outside the state lock. + /// + /// A failed persist rolls the tree back to the pre-mutation clone, so the + /// in-memory state never claims something the file does not, and a change + /// nobody can re-read is a change nobody is told about. At + /// [`Persist::Soon`] there is no disk to fail: the change is flagged + /// unwritten and the flusher carries it, which is sound only because what + /// takes that path is the machine re-observable rather than the layout — + /// see the module header. + /// + /// `notify_order` is held across the whole thing — see the field — so + /// subscribers receive deltas in exactly the order the mutations landed. + fn mutate_with( + &self, + origin: Option, + persist: Persist, + op: impl FnOnce(&mut Machine) -> io::Result<(T, Vec<(WorkspaceId, LayoutDelta)>)>, + ) -> io::Result { + let _order = self.notify_order.lock().unwrap_or_else(|e| e.into_inner()); + let deltas; + let value; + { + let mut m = self.locked(); + let before = m.clone(); + match op(&mut m).and_then(|out| { + if *m != before { + match persist { + Persist::Now => self.persist(&m)?, + // Ordered with every other write by `notify_order`, + // which the flusher takes too: the file still moves + // through the states the tree moved through. + Persist::Soon => self.unwritten.store(true, Ordering::Release), + } + } + Ok(out) + }) { + Ok((v, d)) => { + value = v; + deltas = d; + } + Err(e) => { + *m = before; + return Err(e); + } + } + } + if !deltas.is_empty() { + self.notify_all(&deltas, origin); + } + Ok(value) + } + + /// Write out anything a [`Persist::Soon`] mutation left in memory. A no-op + /// when there is nothing owed, so it is cheap to call on a timer. + /// + /// Public for the daemon's shutdown path: the observations of the last two + /// seconds are worth one write on the way out. + pub fn flush(&self) { + if !self.unwritten.load(Ordering::Acquire) { + return; + } + let _order = self.notify_order.lock().unwrap_or_else(|e| e.into_inner()); + let m = self.locked(); + // Cleared before the write, not after: a fact landing *during* it is + // owed another write, and losing that flag would strand it until the + // next one. `persist` failing sets it again below. + self.unwritten.store(false, Ordering::Release); + if let Err(e) = self.persist(&m) { + log::warn!("could not write {}: {e}", self.path.display()); + self.unwritten.store(true, Ordering::Release); + } + } + + /// Start the flusher, once, on the first observation that owes a write. + /// + /// Weak, so the thread is the store's dependent rather than its owner: a + /// dropped store (every test that makes one) ends the thread at its next + /// tick instead of keeping the file — and the file's handle — alive for the + /// process's life. + fn ensure_flusher(self: &Arc) { + if self.flushing.swap(true, Ordering::AcqRel) { + return; + } + let weak = Arc::downgrade(self); + let spawned = std::thread::Builder::new() + .name("tty7-machine-flush".into()) + .spawn(move || { + loop { + std::thread::sleep(FACT_FLUSH_INTERVAL); + let Some(store) = weak.upgrade() else { return }; + store.flush(); + } + }); + if let Err(e) = spawned { + // Fall back to writing observations synchronously: the flag says + // one is owed, and clearing `flushing` lets the next one retry the + // spawn. Slow beats silently losing every cwd on the machine. + log::warn!("could not start the machine-tree flusher ({e}); writing facts inline"); + self.flushing.store(false, Ordering::Release); + self.flush(); + } + } + + /// Serialize the whole document and replace the file atomically. The + /// pretty form, so a human can read and repair it — this file is the + /// machine's memory of every workspace on it. + /// + /// Owner-only: the document names every workspace's directories, the SSH + /// user and host of every native-SSH pane, and each agent's session id. A + /// remote box running `tty7-server` is exactly where other logins are + /// likeliest, so the file must not be created world-readable and fixed up + /// afterwards — see [`write_atomic_private`](crate::core::config::write_atomic_private). + fn persist(&self, m: &Machine) -> io::Result<()> { + let bytes = serde_json::to_vec_pretty(m).map_err(io::Error::other)?; + if let Some(parent) = self.path.parent() { + std::fs::create_dir_all(parent)?; + } + crate::core::config::write_atomic_private(&self.path, &bytes) + } + + /// Fan the deltas out, skipping the subscriber that caused them. Called + /// with no lock held. + fn notify_all(&self, deltas: &[(WorkspaceId, LayoutDelta)], origin: Option) { + let subscribers: Vec<(SubscriberId, Notify)> = self + .subscribers + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone(); + for (workspace, delta) in deltas { + let key = workspace.to_string(); + for (sid, f) in &subscribers { + if Some(*sid) != origin { + f(&key, delta); + } + } + } + } +} + +/// Find a workspace or answer the `NotFound` every op shares. +fn find_workspace(m: &mut Machine, id: WorkspaceId) -> io::Result<&mut Workspace> { + m.workspaces + .iter_mut() + .find(|w| w.id == id) + .ok_or_else(|| not_found(format!("no workspace {id} on this machine"))) +} + +fn find_tab(m: &mut Machine, workspace: WorkspaceId, tab: TabId) -> io::Result<&mut Tab> { + let ws = find_workspace(m, workspace)?; + ws.tabs + .iter_mut() + .find(|t| t.id == tab) + .ok_or_else(|| not_found(format!("workspace {workspace} has no tab {tab}"))) +} + +/// Keep `active_tab` naming a real tab after the tab at `removed` left. +/// +/// The replacement is the neighbour that slid into the removed tab's place +/// (or the new last tab), which is what every tab strip does on close. +/// +/// Answers the tab that became active when the heal actually re-pointed it, +/// so the caller can broadcast the change — a client mirroring by deltas must +/// not have to re-implement this rule (see [`LayoutDelta::ActiveTabChanged`]). +fn heal_active_tab(ws: &mut Workspace, removed: usize) -> Option { + let named = ws + .active_tab + .is_some_and(|active| ws.tabs.iter().any(|t| t.id == active)); + if named || ws.tabs.is_empty() { + ws.active_tab = ws.active_tab.filter(|_| named); + return None; + } + let active = ws.tabs[removed.min(ws.tabs.len() - 1)].id; + ws.active_tab = Some(active); + Some(active) +} + +/// Adopt a seed into the registry. +/// +/// A pane already shown anywhere in the tree is **refused**: one pane has one +/// stream and one subscriber, so a second leaf on the same id would be two +/// windows silently fighting over one PTY — the exact corruption the old +/// client-side `dedupe_pane_ids` pass existed to mop up after the fact. The +/// daemon owning the tree means it can simply not happen. +/// +/// Every registry record is referenced by some leaf (the close paths collect +/// orphans), so "known pane, not in any tree" cannot arise and needs no merge +/// path. +fn register_pane(m: &mut Machine, seed: PaneSeed, live: bool) -> io::Result<()> { + let shown = m + .workspaces + .iter() + .any(|w| w.tabs.iter().any(|t| t.root.contains(seed.pane))); + if shown || m.panes.iter().any(|p| p.id == seed.pane) { + return Err(refuse(format!( + "pane {} is already part of this machine's tree", + seed.pane + ))); + } + if m.panes.len() >= MAX_PANES { + return Err(refuse(format!( + "this machine's tree already references {MAX_PANES} panes" + ))); + } + m.panes.push(seed.into_record(live)); + Ok(()) +} + +/// The pane ids no leaf references any more. Computed over the whole machine +/// because a pane id means one pane — it must not be forgotten while any +/// workspace still shows it. +fn collect_orphan_panes(m: &Machine) -> Vec { + m.panes + .iter() + .map(|p| p.id) + .filter(|id| { + !m.workspaces + .iter() + .any(|w| w.tabs.iter().any(|t| t.root.contains(*id))) + }) + .collect() +} + +fn clamp_ratio(ratio: f32) -> io::Result { + if !ratio.is_finite() { + return Err(refuse("a split ratio must be a finite number")); + } + Ok(ratio.clamp(0.05, 0.95)) +} + +/// Read the file, or start empty. A file that cannot be honoured — whether it +/// fails to parse or to *read* — is quarantined first, so the user's tree is +/// recoverable by hand rather than silently overwritten: either way the store +/// proceeds empty, and its first mutation writes the file anew. +fn load_machine(path: &Path) -> Machine { + let text = match std::fs::read_to_string(path) { + Ok(t) => t, + Err(e) if e.kind() == io::ErrorKind::NotFound => return Machine::default(), + Err(e) => { + // Same isolation as the parse failure below, by rename rather + // than copy: a copy re-reads the very file that just refused to + // be read, while a rename needs only the directory — which the + // store can evidently write, since it is about to persist there. + log::warn!("could not read {}; quarantining it: {e}", path.display()); + quarantine_by_rename(path); + return Machine::default(); + } + }; + match serde_json::from_str::(crate::core::config::strip_bom(&text)) { + Ok(mut machine) => { + // PTYs die with the daemon process, so whatever the file says, + // nothing is live in a store that was just opened. This line is + // the whole of the restart semantic: every leaf is now "awaiting + // revival" simply because its pane's record says so. + for pane in &mut machine.panes { + pane.live = false; + } + machine + } + Err(e) => { + log::warn!("{} does not parse ({e}); quarantining it", path.display()); + quarantine(path); + Machine::default() + } + } +} + +// --------------------------------------------------------------------------- +// The daemon's own observations +// --------------------------------------------------------------------------- + +/// The store the running daemon's pane server publishes its observations into. +/// +/// A process-wide slot rather than a parameter threaded through `DaemonPane`, +/// for the same reason the control dialect's event observer is one: the +/// observers (every pane's reader thread) and the owner (the control listener +/// the daemon starts) come up independently in code that long predates the +/// tree, and each of the three pane-spawn paths would otherwise have to be +/// taught to carry an `Option>` it never reads. Last install +/// wins; `None` — a process serving panes with no tree, or a unit test — +/// simply drops observations. +static OBSERVED: Mutex>> = Mutex::new(None); + +/// Install `store` as where [`observe_pane`] lands. The daemon calls this once +/// while wiring its control services. +pub fn publish_observations(store: &Arc) { + *OBSERVED.lock().unwrap_or_else(|e| e.into_inner()) = Some(Arc::clone(store)); +} + +/// Record an observation about `pane` — a cwd the shell reported, an agent the +/// sniffer identified, a death — in the installed store, if there is one. +/// +/// Facts about panes the tree never adopted are dropped by the store itself +/// (see [`MachineStore::note_pane_facts`]), so callers report unconditionally +/// and pay nothing for a pane that is nobody's business. +pub fn observe_pane(pane: u64, f: impl FnOnce(&mut PaneRecord)) { + let store = OBSERVED.lock().unwrap_or_else(|e| e.into_inner()).clone(); + if let Some(store) = store { + store.note_pane_facts(pane, f); + } +} + +/// The installed observation store, if any — for daemon-side code (the orphan +/// sweep) that wants to *read* the tree the pane server publishes into. +pub fn observed_store() -> Option> { + OBSERVED.lock().unwrap_or_else(|e| e.into_inner()).clone() +} + +/// Test-only: clear the slot again, so one test's store cannot swallow the +/// observations of unrelated tests running later in the same binary. +#[cfg(test)] +pub(crate) fn withdraw_observations() { + *OBSERVED.lock().unwrap_or_else(|e| e.into_inner()) = None; +} + +/// Copy a file we are about to stop honouring somewhere the user can find it. +fn quarantine(path: &Path) { + let aside = quarantine_path(path); + match std::fs::copy(path, &aside) { + Ok(_) => log::warn!("the previous contents were kept at {}", aside.display()), + Err(e) => log::warn!("could not keep a copy at {}: {e}", aside.display()), + } +} + +/// [`quarantine`] for a file that cannot be read: move it aside whole instead +/// of copying (a copy needs the read permission that just failed). +fn quarantine_by_rename(path: &Path) { + let aside = quarantine_path(path); + match std::fs::rename(path, &aside) { + Ok(()) => log::warn!("the previous contents were moved to {}", aside.display()), + Err(e) => log::warn!("could not move the file to {}: {e}", aside.display()), + } +} + +/// Where a file we are about to stop honouring is kept. +/// +/// `machine.json.corrupt` when that name is free, `…corrupt.1`, `…corrupt.2` … +/// when it is not: the second corruption in a machine's life must not overwrite +/// the rescue copy of the first, which is the one with the user's tree in it. +/// After [`MAX_QUARANTINED`] the oldest name is reused — an unbounded fan of +/// files nobody reads is its own kind of mess. +fn quarantine_path(path: &Path) -> PathBuf { + /// How many quarantined generations to keep before reusing the base name. + const MAX_QUARANTINED: u32 = 8; + + let base = path.with_extension("json.corrupt"); + if !base.exists() { + return base; + } + (1..MAX_QUARANTINED) + .map(|n| path.with_extension(format!("json.corrupt.{n}"))) + .find(|candidate| !candidate.exists()) + .unwrap_or(base) +} + +/// `/machine.json`. +/// +/// | Order | Directory | Why | +/// |---|---|---| +/// | 1 | `$TTY7_DATA_DIR` | Explicit wins; how tests and a second server get their own file | +/// | 2 | `$XDG_DATA_HOME/tty7` | The location the design names, spelled the way XDG spells it | +/// | 3 | `$HOME/.local/share/tty7` | No `XDG_DATA_HOME` — the literal fallback path | +/// +/// Deliberately **not** under the config dir. `views.json` there is the +/// *client's* view state, and a box that is both someone's laptop and someone +/// else's remote must keep the two files apart or one role would overwrite the +/// other's idea of which workspaces exist. +pub fn default_machine_path() -> io::Result { + Ok(data_dir()?.join(MACHINE_FILE)) +} + +fn data_dir() -> io::Result { + if let Some(explicit) = std::env::var_os(DATA_DIR_ENV).filter(|v| !v.is_empty()) { + return Ok(PathBuf::from(explicit)); + } + #[cfg(not(windows))] + let base = env_dir("XDG_DATA_HOME") + .or_else(|| env_dir("HOME").map(|h| h.join(".local").join("share"))); + #[cfg(windows)] + let base = env_dir("LOCALAPPDATA") + .or_else(|| env_dir("USERPROFILE").map(|h| h.join(".local").join("share"))); + + base.map(|b| b.join("tty7")).ok_or_else(|| { + io::Error::other(format!( + "no home directory to place {MACHINE_FILE} in; set {DATA_DIR_ENV}" + )) + }) +} + +fn env_dir(key: &str) -> Option { + std::env::var_os(key) + .filter(|v| !v.is_empty()) + .map(PathBuf::from) +} + +fn unix_now() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn store() -> (Arc, tempfile::TempDir) { + let dir = tempfile::TempDir::new().unwrap(); + (MachineStore::open(dir.path().join(MACHINE_FILE)), dir) + } + + fn seed(pane: u64, cwd: &str) -> PaneSeed { + PaneSeed { + pane, + cwd: Some(cwd.to_string()), + ssh_spec: None, + agent: None, + } + } + + /// A store, one workspace, one tab on pane 1. + fn store_with_tab() -> (Arc, tempfile::TempDir, WorkspaceId, Tab) { + let (store, dir) = store(); + let ws = store + .workspace_create(None, Some("api".into()), None) + .unwrap(); + let tab = store + .tab_create(ws.id, None, seed(1, "/work"), None, None) + .unwrap(); + (store, dir, ws.id, tab) + } + + /// Record every delta a subscriber hears, as `(workspace-key, delta)`. + fn recorded( + store: &Arc, + ) -> (Subscription, Arc>>) { + let heard = Arc::new(Mutex::new(Vec::new())); + let sink = Arc::clone(&heard); + let sub = store.subscribe(Arc::new(move |ws: &str, delta: &LayoutDelta| { + sink.lock().unwrap().push((ws.to_string(), delta.clone())); + })); + (sub, heard) + } + + // ── Client-minted identities ─────────────────────────────────────────── + + #[test] + fn a_client_minted_workspace_id_is_kept_and_a_duplicate_is_refused() { + let (store, _dir) = store(); + let id = WorkspaceId::new(); + let ws = store + .workspace_create(Some(id), Some("api".into()), None) + .unwrap(); + assert_eq!(ws.id, id, "the id the client named is the id it gets"); + + let refused = store + .workspace_create(Some(id), None, None) + .expect_err("a second create on the same id must refuse"); + assert_eq!(refused.kind(), io::ErrorKind::InvalidInput); + assert_eq!( + store.machine().workspaces.len(), + 1, + "the refusal changed nothing" + ); + } + + #[test] + fn a_client_minted_tab_id_is_kept_and_a_duplicate_is_refused_anywhere() { + let (store, _dir, ws, _tab) = store_with_tab(); + let id = TabId::new(); + let tab = store + .tab_create(ws, None, seed(2, "/b"), Some(id), None) + .unwrap(); + assert_eq!(tab.id, id); + + // Refused even from another workspace: tab ids are one namespace, so a + // delta about a tab can never be ambiguous about which tab it means. + let other = store.workspace_create(None, None, None).unwrap(); + let refused = store + .tab_create(other.id, None, seed(3, "/c"), Some(id), None) + .expect_err("a taken tab id must refuse"); + assert_eq!(refused.kind(), io::ErrorKind::InvalidInput); + assert!( + store.pane(3).is_none(), + "the refused create adopted no pane either" + ); + } + + // ── The tree survives the file ───────────────────────────────────────── + + #[test] + fn the_tree_round_trips_through_the_file() { + let (store, dir) = store(); + let ws = store + .workspace_create(None, Some("api".into()), None) + .unwrap(); + store + .tab_create(ws.id, None, seed(1, "/work"), None, None) + .unwrap(); + store + .pane_split( + ws.id, + 1, + Axis::Vertical, + 0.3, + seed(2, "/work/api"), + false, + None, + ) + .unwrap(); + + let reopened = MachineStore::open(dir.path().join(MACHINE_FILE)); + let machine = reopened.machine(); + assert_eq!(machine.workspaces.len(), 1); + let back = &machine.workspaces[0]; + assert_eq!(back.id, ws.id, "workspace identity survives a restart"); + assert_eq!(back.name.as_deref(), Some("api")); + assert_eq!(back.tabs.len(), 1); + assert_eq!(back.tabs[0].root.pane_ids(), vec![1, 2]); + match &back.tabs[0].root { + PaneNode::Split { axis, ratio, .. } => { + assert_eq!(*axis, Axis::Vertical); + assert!((ratio - 0.3).abs() < 1e-6); + } + PaneNode::Leaf { .. } => panic!("the split has to survive"), + } + assert_eq!( + machine.panes.iter().map(|p| p.id).collect::>(), + vec![1, 2], + "the pane registry rides the same file" + ); + assert_eq!(machine.panes[0].cwd.as_deref(), Some("/work")); + } + + /// **The revival contract.** After a restart every pane the tree names is + /// dead — PTYs die with the process — and the tree must say so on its own, + /// with no client-side instance stamp to consult. The leaf stays (the + /// layout is the thing being revived), the record keeps the facts a + /// successor is started from, and `live` is false because it cannot be + /// anything else in a process that spawned nothing yet. + #[test] + fn a_reopened_store_marks_every_pane_awaiting_revival() { + let (store, dir) = store(); + let ws = store.workspace_create(None, None, None).unwrap(); + store + .tab_create(ws.id, None, seed(7, "/work"), None, None) + .unwrap(); + assert!( + store.pane(7).unwrap().live, + "the pane its own client just seeded is live" + ); + + let restarted = MachineStore::open(dir.path().join(MACHINE_FILE)); + let record = restarted.pane(7).expect("the record survives the restart"); + assert!(!record.live, "a restarted daemon has no live panes"); + assert_eq!( + record.cwd.as_deref(), + Some("/work"), + "the facts a successor spawns from survive" + ); + assert_eq!( + restarted.workspace(ws.id).unwrap().tabs[0].root.pane_ids(), + vec![7], + "the leaf still names the dead pane: that is the revival slot" + ); + } + + /// The daemon's registration-time liveness check. A pane that dies + /// between its spawn and its adopting operation has its death observation + /// dropped (`note_pane_facts` ignores panes the tree does not hold), so a + /// seed filed `live: true` unconditionally would claim a live pane for + /// ever — no revival offered, nothing left to flip the flag. With the + /// probe installed, the process that owns the PTYs answers at the moment + /// the record is born. + #[test] + fn a_seed_for_an_already_dead_pane_registers_as_awaiting_revival() { + let (store, _dir) = store(); + store.set_liveness_probe(Arc::new(|id| id == 1)); + let ws = store.workspace_create(None, None, None).unwrap(); + store + .tab_create(ws.id, None, PaneSeed::bare(1), None, None) + .unwrap(); + store + .pane_split( + ws.id, + 1, + Axis::Vertical, + 0.5, + PaneSeed::bare(2), + false, + None, + ) + .unwrap(); + + assert!(store.pane(1).unwrap().live, "the probe vouched for pane 1"); + assert!( + !store.pane(2).unwrap().live, + "pane 2 died before its adopting op; its record must be born revivable" + ); + } + + /// The revival itself: a fresh pane takes the leaf over, the spent record + /// leaves the registry, and everyone else hears the whole tab. + #[test] + fn replacing_a_dead_pane_rebinds_the_leaf_and_spends_the_record() { + let (store, dir) = store(); + let ws = store.workspace_create(None, None, None).unwrap(); + store + .tab_create(ws.id, None, seed(7, "/work"), None, None) + .unwrap(); + + let restarted = MachineStore::open(dir.path().join(MACHINE_FILE)); + let (_sub, heard) = recorded(&restarted); + restarted + .pane_replace(ws.id, 7, seed(42, "/work"), None) + .unwrap(); + + assert_eq!( + restarted.workspace(ws.id).unwrap().tabs[0].root.pane_ids(), + vec![42] + ); + assert!(restarted.pane(7).is_none(), "the old record is spent"); + assert!(restarted.pane(42).unwrap().live); + let heard = heard.lock().unwrap(); + assert_eq!(heard.len(), 1); + match &heard[0].1 { + LayoutDelta::TabRestructured { tab, pane } => { + assert_eq!(tab.root.pane_ids(), vec![42]); + assert_eq!(pane.as_ref().map(|p| p.id), Some(42)); + } + other => panic!("expected TabRestructured, got {other:?}"), + } + } + + // ── Workspace ops ────────────────────────────────────────────────────── + + #[test] + fn workspace_create_rename_touch_delete_land_and_broadcast() { + let (store, _dir) = store(); + let (_sub, heard) = recorded(&store); + + let ws = store + .workspace_create(None, Some("api".into()), None) + .unwrap(); + store + .workspace_rename(ws.id, Some("web".into()), None) + .unwrap(); + store.workspace_touch(ws.id, None).unwrap(); + assert_eq!(store.workspace(ws.id).unwrap().name.as_deref(), Some("web")); + + store.workspace_delete(ws.id, None).unwrap(); + assert!(store.workspace(ws.id).is_err()); + + let heard = heard.lock().unwrap(); + let kinds: Vec<&LayoutDelta> = heard.iter().map(|(_, d)| d).collect(); + assert!(matches!(kinds[0], LayoutDelta::WorkspaceCreated { .. })); + assert!(matches!(kinds[1], LayoutDelta::WorkspaceRenamed { name: Some(n) } if n == "web")); + assert!(matches!(kinds[2], LayoutDelta::WorkspaceTouched { .. })); + assert!(matches!(kinds[3], LayoutDelta::WorkspaceDeleted)); + assert!( + heard.iter().all(|(key, _)| key == &ws.id.to_string()), + "every delta names the workspace it is about" + ); + } + + #[test] + fn deleting_a_workspace_forgets_the_panes_only_it_referenced() { + let (store, _dir, ws, _tab) = store_with_tab(); + let dropped = store.workspace_delete(ws, None).unwrap(); + assert_eq!(dropped, vec![1], "the caller is told which PTYs to kill"); + assert!(store.pane(1).is_none()); + } + + // ── Tab ops ──────────────────────────────────────────────────────────── + + #[test] + fn a_created_tab_lands_at_its_position_and_becomes_active() { + let (store, _dir, ws, first) = store_with_tab(); + let second = store + .tab_create(ws, None, seed(2, "/b"), None, None) + .unwrap(); + let between = store + .tab_create(ws, Some(1), seed(3, "/c"), None, None) + .unwrap(); + + let workspace = store.workspace(ws).unwrap(); + let order: Vec = workspace.tabs.iter().map(|t| t.id).collect(); + assert_eq!(order, vec![first.id, between.id, second.id]); + assert_eq!(workspace.active_tab, Some(between.id)); + + // An out-of-range position clamps rather than refusing: the client's + // idea of "after the last tab" can be stale by one concurrent close. + let clamped = store + .tab_create(ws, Some(99), seed(4, "/d"), None, None) + .unwrap(); + assert_eq!( + store.workspace(ws).unwrap().tabs.last().unwrap().id, + clamped.id + ); + } + + #[test] + fn closing_a_tab_forgets_its_panes_and_heals_the_active_tab() { + let (store, _dir, ws, first) = store_with_tab(); + let second = store + .tab_create(ws, None, seed(2, "/b"), None, None) + .unwrap(); + store.workspace_set_active_tab(ws, second.id, None).unwrap(); + + let (_sub, heard) = recorded(&store); + let dropped = store.tab_close(ws, second.id, None).unwrap(); + assert_eq!(dropped, vec![2]); + let workspace = store.workspace(ws).unwrap(); + assert_eq!(workspace.tabs.len(), 1); + assert_eq!( + workspace.active_tab, + Some(first.id), + "the active tab may not dangle on a closed id" + ); + // The heal is broadcast, not left for clients to re-derive: after the + // `TabClosed` comes an `ActiveTabChanged` naming the survivor. + assert!( + matches!( + heard.lock().unwrap().as_slice(), + [ + (_, LayoutDelta::TabClosed { tab }), + (_, LayoutDelta::ActiveTabChanged { tab: active }) + ] if *tab == second.id && *active == first.id + ), + "heard {:?}", + heard.lock().unwrap() + ); + + heard.lock().unwrap().clear(); + let dropped = store.tab_close(ws, first.id, None).unwrap(); + assert_eq!(dropped, vec![1]); + assert_eq!( + store.workspace(ws).unwrap().active_tab, + None, + "a workspace with no tabs has no active one — the home-page state" + ); + assert_eq!( + heard.lock().unwrap().len(), + 1, + "losing the last tab needs no ActiveTabChanged: no tabs, no active tab" + ); + } + + #[test] + fn tabs_rename_move_and_regroup_in_place() { + let (store, _dir, ws, first) = store_with_tab(); + let second = store + .tab_create(ws, None, seed(2, "/b"), None, None) + .unwrap(); + + store + .tab_rename(ws, first.id, Some("build".into()), None) + .unwrap(); + store + .tab_set_group(ws, first.id, Some("/repo/tty7".into()), None) + .unwrap(); + store.tab_move(ws, first.id, 1, None).unwrap(); + + let workspace = store.workspace(ws).unwrap(); + assert_eq!(workspace.tabs[0].id, second.id); + assert_eq!(workspace.tabs[1].name.as_deref(), Some("build")); + assert_eq!( + workspace.tabs[1].sidebar_group.as_deref(), + Some("/repo/tty7") + ); + } + + // ── Pane ops ─────────────────────────────────────────────────────────── + + #[test] + fn splitting_and_closing_panes_reshapes_the_tree() { + let (store, _dir, ws, tab) = store_with_tab(); + store + .pane_split(ws, 1, Axis::Horizontal, 0.5, seed(2, "/b"), false, None) + .unwrap(); + store + .pane_split(ws, 2, Axis::Vertical, 0.5, seed(3, "/c"), true, None) + .unwrap(); + assert_eq!( + store.workspace(ws).unwrap().tabs[0].root.pane_ids(), + vec![1, 3, 2], + "`first` puts the new pane on the a side" + ); + + // Closing a middle pane collapses its split; the sibling takes over. + let dropped = store.pane_close(ws, 3, None).unwrap(); + assert_eq!(dropped, vec![3]); + assert_eq!( + store.workspace(ws).unwrap().tabs[0].root.pane_ids(), + vec![1, 2] + ); + + // Closing down to one pane leaves a plain leaf, not a degenerate split. + store.pane_close(ws, 2, None).unwrap(); + assert!(matches!( + store.workspace(ws).unwrap().tabs[0].root, + PaneNode::Leaf { pane: 1 } + )); + + // Closing the last pane closes the tab itself. + let (_sub, heard) = recorded(&store); + store.pane_close(ws, 1, None).unwrap(); + assert!(store.workspace(ws).unwrap().tabs.is_empty()); + assert!(matches!( + heard.lock().unwrap()[0].1, + LayoutDelta::TabClosed { tab: id } if id == tab.id + )); + } + + #[test] + fn a_ratio_change_lands_on_the_split_its_path_names() { + let (store, _dir, ws, tab) = store_with_tab(); + store + .pane_split(ws, 1, Axis::Horizontal, 0.5, seed(2, "/b"), false, None) + .unwrap(); + store + .pane_split(ws, 2, Axis::Vertical, 0.5, seed(3, "/c"), false, None) + .unwrap(); + + // The nested split lives on the b side of the root. + store + .pane_set_ratio(ws, tab.id, vec![Side::B], 0.7, None) + .unwrap(); + match &store.workspace(ws).unwrap().tabs[0].root { + PaneNode::Split { a, b, ratio, .. } => { + assert!((ratio - 0.5).abs() < 1e-6, "the root ratio is untouched"); + assert!(matches!(&**a, PaneNode::Leaf { pane: 1 })); + match &**b { + PaneNode::Split { ratio, .. } => assert!((ratio - 0.7).abs() < 1e-6), + PaneNode::Leaf { .. } => panic!("the nested split is gone"), + } + } + PaneNode::Leaf { .. } => panic!("the root split is gone"), + } + + // A path that no longer names a split refuses rather than guessing — + // the client falls back to a full re-pull. + let err = store + .pane_set_ratio(ws, tab.id, vec![Side::A], 0.6, None) + .unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::InvalidInput); + + // Ratios clamp to sane bounds instead of letting a pane vanish. + store + .pane_set_ratio(ws, tab.id, vec![], 0.0001, None) + .unwrap(); + match &store.workspace(ws).unwrap().tabs[0].root { + PaneNode::Split { ratio, .. } => assert!(*ratio >= 0.05), + PaneNode::Leaf { .. } => unreachable!(), + } + } + + #[test] + fn moving_a_pane_between_tabs_dissolves_an_emptied_tab() { + let (store, _dir, ws, first) = store_with_tab(); + let second = store + .tab_create(ws, None, seed(2, "/b"), None, None) + .unwrap(); + + store + .pane_move(ws, 2, 1, Axis::Vertical, false, None) + .unwrap(); + let workspace = store.workspace(ws).unwrap(); + assert_eq!(workspace.tabs.len(), 1, "the emptied tab dissolved"); + assert_eq!(workspace.tabs[0].id, first.id); + assert_eq!(workspace.tabs[0].root.pane_ids(), vec![1, 2]); + assert!( + !workspace.tabs.iter().any(|t| t.id == second.id), + "the source tab is gone" + ); + assert!(store.pane(2).is_some(), "the pane moved; it did not die"); + + // Moving a pane next to itself is meaningless and refused. + let err = store + .pane_move(ws, 2, 2, Axis::Vertical, false, None) + .unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::InvalidInput); + } + + // ── Validation is refusal, not corruption ────────────────────────────── + + /// A refused operation leaves the tree byte-for-byte what it was and + /// tells nobody anything — a delta for a change that did not happen would + /// desynchronize every listening client at once. + #[test] + fn a_refused_operation_changes_nothing_and_notifies_nobody() { + let (store, _dir, ws, tab) = store_with_tab(); + let before = store.machine(); + let (_sub, heard) = recorded(&store); + + let missing = WorkspaceId::new(); + assert!(store.workspace_rename(missing, None, None).is_err()); + assert!( + store + .tab_create(missing, None, seed(9, "/x"), None, None) + .is_err() + ); + assert!(store.tab_close(ws, TabId::new(), None).is_err()); + assert!( + store + .pane_split(ws, 999, Axis::Vertical, 0.5, seed(9, "/x"), false, None) + .is_err() + ); + assert!(store.pane_close(ws, 999, None).is_err()); + assert!( + store + .pane_set_ratio(ws, tab.id, vec![Side::A], 0.5, None) + .is_err() + ); + assert!( + store + .pane_set_ratio(ws, tab.id, vec![], f32::NAN, None) + .is_err() + ); + assert!(store.pane_replace(ws, 999, seed(9, "/x"), None).is_err()); + + assert_eq!(store.machine(), before); + assert!(heard.lock().unwrap().is_empty()); + assert!( + store.pane(9).is_none(), + "a seed on a refused op must not leak into the registry" + ); + } + + // ── Origin exclusion ─────────────────────────────────────────────────── + + /// The writer does not hear its own echo; everyone else does. This is the + /// mechanism that lets a client apply its own edit optimistically and + /// apply everyone else's from deltas without double-applying its own. + #[test] + fn a_delta_reaches_every_subscriber_but_its_author() { + let (store, _dir, ws, _tab) = store_with_tab(); + let (author, heard_by_author) = recorded(&store); + let (_other, heard_by_other) = recorded(&store); + + store + .workspace_rename(ws, Some("renamed".into()), Some(author.id())) + .unwrap(); + assert!(heard_by_author.lock().unwrap().is_empty()); + assert_eq!(heard_by_other.lock().unwrap().len(), 1); + + // A write with no origin reaches all. + store.workspace_rename(ws, None, None).unwrap(); + assert_eq!(heard_by_author.lock().unwrap().len(), 1); + assert_eq!(heard_by_other.lock().unwrap().len(), 2); + } + + #[test] + fn dropping_a_subscription_stops_the_deltas() { + let (store, _dir, ws, _tab) = store_with_tab(); + let (sub, heard) = recorded(&store); + store.workspace_touch(ws, None).unwrap(); + assert_eq!(heard.lock().unwrap().len(), 1); + drop(sub); + store.workspace_touch(ws, None).unwrap(); + assert_eq!(heard.lock().unwrap().len(), 1); + } + + // ── Pane facts ───────────────────────────────────────────────────────── + + /// The daemon's own observations reach every client of every workspace + /// showing the pane — origin exclusion does not apply, because the machine + /// is the author and the machine is nobody's echo. + #[test] + fn pane_facts_update_the_record_and_reach_every_client() { + let (store, _dir, ws, _tab) = store_with_tab(); + let (sub, heard) = recorded(&store); + + store.note_pane_facts(1, |p| { + p.cwd = Some("/work/deeper".into()); + }); + let record = store.pane(1).unwrap(); + assert_eq!(record.cwd.as_deref(), Some("/work/deeper")); + { + let heard = heard.lock().unwrap(); + assert_eq!(heard.len(), 1); + assert_eq!(heard[0].0, ws.to_string()); + assert!(matches!(&heard[0].1, LayoutDelta::PaneFacts { pane } if pane.id == 1)); + } + + // No change, no noise; an unknown pane is nobody's business. + store.note_pane_facts(1, |_| {}); + store.note_pane_facts(999, |p| p.cwd = Some("/ghost".into())); + assert_eq!(heard.lock().unwrap().len(), 1); + drop(sub); + } + + /// One pane, one leaf. A second adoption of a pane already shown is the + /// two-windows-one-PTY corruption the old client-side dedupe pass mopped + /// up after the fact; the daemon owning the tree refuses it up front. + #[test] + fn a_pane_already_in_the_tree_cannot_be_adopted_again() { + let (store, _dir, ws, _tab) = store_with_tab(); + store.note_pane_facts(1, |p| p.cwd = Some("/observed".into())); + + let other = store.workspace_create(None, None, None).unwrap(); + let err = store + .tab_create(other.id, None, seed(1, "/stale"), None, None) + .unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::InvalidInput); + assert!( + store.workspace(other.id).unwrap().tabs.is_empty(), + "the refused tab must not half-exist" + ); + assert_eq!( + store.pane(1).unwrap().cwd.as_deref(), + Some("/observed"), + "and the stale seed must not clobber the daemon's own facts" + ); + let _ = ws; + } + + /// The pane server's side door: once a store is installed, an observation + /// lands on the record like any other fact — and before/without one, + /// observing is a quiet no-op, which is what lets the pane code report + /// unconditionally. + #[test] + fn published_observations_land_in_the_installed_store() { + observe_pane(1, |p| p.cwd = Some("/nowhere".into())); + + let (store, _dir, _ws, _tab) = store_with_tab(); + publish_observations(&store); + observe_pane(1, |p| p.cwd = Some("/observed/here".into())); + assert_eq!( + store.pane(1).unwrap().cwd.as_deref(), + Some("/observed/here") + ); + withdraw_observations(); + } + + // ── Attachment ───────────────────────────────────────────────────────── + + #[test] + fn attachments_takeover_and_are_never_persisted() { + let (store, dir, ws, _tab) = store_with_tab(); + assert_eq!(store.attachment(ws), None); + + let laptop = Attachment::new("tok-1", "laptop"); + assert_eq!(store.attach(ws, laptop.clone()), None); + let desktop = Attachment::new("tok-2", "desktop"); + assert_eq!(store.attach(ws, desktop.clone()), Some(laptop.clone())); + + // The preempted client tidying up must not evict the new owner. + assert!(!store.detach(ws, &laptop.token)); + assert_eq!(store.attachment(ws).unwrap().hostname, "desktop"); + assert!(store.detach(ws, &desktop.token)); + assert_eq!(store.attachment(ws), None); + + // Attachments describe live connections; a restarted daemon has none. + store.attach(ws, Attachment::new("secret-token", "laptop")); + // A structural op that really changes something, to force the write: + // `workspace_touch` is an observation (deferred to the flusher), and an + // op that changes nothing does not write at all. + store + .workspace_rename(ws, Some("web".into()), None) + .unwrap(); + let text = std::fs::read_to_string(dir.path().join(MACHINE_FILE)).unwrap(); + assert!(!text.contains("secret-token"), "{text}"); + assert_eq!( + MachineStore::open(dir.path().join(MACHINE_FILE)).attachment(ws), + None + ); + } + + /// An attachment is a field of its workspace, so deleting the workspace + /// takes it along — there is no table it could go stale in. The retired + /// record store kept a separate attachment list and had to clear it by + /// hand; this pins the structural guarantee that replaced that code. + #[test] + fn an_attachment_dies_with_its_workspace() { + let (store, _dir, ws, _tab) = store_with_tab(); + store.attach(ws, Attachment::new("tok", "laptop")); + assert!(store.attachment(ws).is_some()); + store.workspace_delete(ws, None).unwrap(); + assert_eq!(store.attachment(ws), None); + } + + /// The default path ends at the documented file under the data directory — + /// the resolution the retired record store defined and the tree inherited. + #[test] + fn the_default_path_ends_at_the_documented_file() { + match default_machine_path() { + Ok(path) => assert_eq!( + path.file_name().and_then(|n| n.to_str()), + Some(MACHINE_FILE) + ), + // No home at all (a bare CI container): the error names the + // escape hatch rather than being a mystery. + Err(e) => assert!(e.to_string().contains(DATA_DIR_ENV)), + } + } + + // ── Durability ───────────────────────────────────────────────────────── + + /// An observation reaches every client at once but does **not** write the + /// file: these arrive per prompt per pane from the PTY reader threads, and + /// a whole-document `fsync` each would put a disk stall in the pane's own + /// output path and serialize every other client's edit behind it. The + /// flusher (or the next structural edit, or an explicit `flush`) carries + /// it to disk. + #[test] + fn an_observation_is_broadcast_at_once_and_written_a_little_later() { + let (store, dir, ws, _tab) = store_with_tab(); + let path = dir.path().join(MACHINE_FILE); + let (_sub, heard) = recorded(&store); + + store.note_pane_facts(1, |p| p.cwd = Some("/work/deeper".into())); + assert_eq!( + heard.lock().unwrap().len(), + 1, + "the client hears the fact immediately; only the disk waits" + ); + assert!( + !std::fs::read_to_string(&path).unwrap().contains("deeper"), + "an observation must not write the document synchronously" + ); + + store.flush(); + assert!( + std::fs::read_to_string(&path).unwrap().contains("deeper"), + "…and the flush is what puts it on disk" + ); + // Nothing owed, nothing written: the flusher's tick is free on an idle + // machine. + let before = std::fs::metadata(&path).unwrap().len(); + store.flush(); + assert_eq!(std::fs::metadata(&path).unwrap().len(), before); + + // A structural edit persists the whole document, deferred facts and + // all — so an observation can never outlive the layout change after it. + // (Renamed to something it is not already called: an operation that + // changes nothing writes nothing, which every path here goes through.) + store.note_pane_facts(1, |p| p.cwd = Some("/work/deepest".into())); + store + .workspace_rename(ws, Some("web".into()), None) + .unwrap(); + assert!( + std::fs::read_to_string(&path).unwrap().contains("deepest"), + "a structural write carries whatever the facts left unwritten" + ); + } + + /// The layout itself is never deferred: a structural edit is on disk before + /// its delta goes out, so a client can never be told about a change a + /// restart would lose. + #[test] + fn a_structural_edit_is_on_disk_before_anyone_hears_about_it() { + let (store, dir) = store(); + let path = dir.path().join(MACHINE_FILE); + let seen = Arc::new(Mutex::new(Vec::new())); + let sink = Arc::clone(&seen); + let path_in_callback = path.clone(); + let _sub = store.subscribe(Arc::new(move |_ws: &str, _delta: &LayoutDelta| { + // Read from *inside* the delivery: the file has to already say + // what this delta is about. + sink.lock() + .unwrap() + .push(std::fs::read_to_string(&path_in_callback).unwrap_or_default()); + })); + + let ws = store + .workspace_create(None, Some("api".into()), None) + .unwrap(); + let _ = ws; + let seen = seen.lock().unwrap(); + assert_eq!(seen.len(), 1); + assert!( + seen[0].contains("api"), + "the delta arrived before the file said so: {}", + seen[0] + ); + } + + // ── Corruption ───────────────────────────────────────────────────────── + + #[test] + fn a_corrupt_file_is_quarantined_rather_than_overwritten() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join(MACHINE_FILE); + std::fs::write(&path, b"{ this is not json").unwrap(); + + let store = MachineStore::open(&path); + assert!(store.machine().workspaces.is_empty()); + store.workspace_create(None, None, None).unwrap(); + let aside = std::fs::read_to_string(path.with_extension("json.corrupt")).unwrap(); + assert_eq!(aside, "{ this is not json"); + + // A second corruption gets its own name. Overwriting would spend the + // rescue copy that has the user's tree in it on one that has garbage. + std::fs::write(&path, b"corrupt again").unwrap(); + let store = MachineStore::open(&path); + store.workspace_create(None, None, None).unwrap(); + assert_eq!( + std::fs::read_to_string(path.with_extension("json.corrupt")).unwrap(), + "{ this is not json", + "the first rescue copy is still the first one" + ); + assert_eq!( + std::fs::read_to_string(path.with_extension("json.corrupt.1")).unwrap(), + "corrupt again" + ); + } + + /// The document names directories, SSH users and hosts, and agent session + /// ids. On a shared box — which a `tty7-server` machine is likeliest to be + /// — that is nobody else's business, and it must be private from the first + /// instant the file exists rather than chmod-ed on the next line. + #[cfg(unix)] + #[test] + fn the_document_is_written_owner_only() { + use std::os::unix::fs::PermissionsExt as _; + + let (store, dir) = store(); + store.workspace_create(None, None, None).unwrap(); + let mode = std::fs::metadata(dir.path().join(MACHINE_FILE)) + .unwrap() + .permissions() + .mode(); + assert_eq!(mode & 0o777, 0o600, "mode was {:o}", mode & 0o777); + } + + /// An *unreadable* file gets the same isolation as an unparseable one. + /// Before this, only the parse path quarantined: a read failure logged, + /// started empty — and the first mutation then overwrote the very file + /// that could not be read. Quarantine here is by rename (a copy would + /// need the read permission that just failed), so the bytes survive. + #[cfg(unix)] + #[test] + fn an_unreadable_file_is_moved_aside_rather_than_overwritten() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join(MACHINE_FILE); + std::fs::write(&path, b"{\"workspaces\":[]}").unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000)).unwrap(); + if std::fs::read_to_string(&path).is_ok() { + // Running as root (some CI containers): the permission bits do + // not bite and the scenario cannot be staged. + return; + } + + let store = MachineStore::open(&path); + store.workspace_create(None, None, None).unwrap(); + + let aside = path.with_extension("json.corrupt"); + assert!(aside.exists(), "the unreadable original must be kept"); + std::fs::set_permissions(&aside, std::fs::Permissions::from_mode(0o600)).unwrap(); + assert_eq!( + std::fs::read_to_string(&aside).unwrap(), + "{\"workspaces\":[]}", + "moved aside byte-for-byte, ready for a hand repair" + ); + } + + /// Fields this build has never heard of survive nothing — but fields it + /// *lacks* must not fail the parse: the schema is `#[serde(default)]` + /// throughout so the daemon can keep evolving it. + #[test] + fn a_sparse_document_decodes_with_defaults() { + let machine: Machine = + serde_json::from_str(r#"{"workspaces":[{"tabs":[{"root":{"Leaf":{"pane":3}}}]}]}"#) + .expect("missing fields default rather than fail"); + assert_eq!(machine.workspaces.len(), 1); + assert_eq!(machine.workspaces[0].tabs[0].root.pane_ids(), vec![3]); + assert!(machine.panes.is_empty()); + } +} diff --git a/crates/tty7-core/src/core/mod.rs b/crates/tty7-core/src/core/mod.rs index 4172f56d..701e200b 100644 --- a/crates/tty7-core/src/core/mod.rs +++ b/crates/tty7-core/src/core/mod.rs @@ -17,6 +17,7 @@ pub mod crash; pub mod git; pub mod gitignore; pub mod logfile; +pub mod machine; // SSH connection-manager data layer (WS1). Its public API is consumed by the // daemon-session, auth, forwarding, and UI workstreams, which land separately — // so parts of it read as dead code until those merge. @@ -30,5 +31,4 @@ pub mod shells; pub mod ssh_profile; pub mod threads; pub mod window_state; -pub mod workspace_store; pub mod worktree; diff --git a/crates/tty7-core/src/core/session.rs b/crates/tty7-core/src/core/session.rs index 8efe0e6d..3b1e8b7f 100644 --- a/crates/tty7-core/src/core/session.rs +++ b/crates/tty7-core/src/core/session.rs @@ -1,14 +1,17 @@ -//! Session persistence: remember the tab / split-pane layout and each -//! terminal's working directory across restarts, plus a stack of recently -//! closed tabs for "Reopen Closed Tab". +//! The client's workspace bookkeeping: the in-memory [`Session`] shape a +//! window is built from, and the persisted [`WindowView`] entries — pure view +//! state, because the layout itself lives in each machine's daemon-owned tree +//! (`core::machine`). //! -//! The on-disk model mirrors the live `Pane` tree but stays purely -//! serializable (no GPUI entities, no `gpui::Axis` which isn't `Serialize`). -//! It lives at `~/.config/tty7/session.json`, alongside `config.json`. +//! [`Session`] / [`SessionTab`] / [`SessionPane`] mirror the live `Pane` tree +//! without GPUI types. They are **not persisted any more**: the window builder +//! consumes them, the tree hydration produces them, and the closed-tab stack +//! holds them, all in memory. //! -//! All IO and parsing is best-effort: a missing/corrupt file just means "no -//! session to restore", and write failures are logged rather than fatal — the -//! app must never crash or stall over session bookkeeping. +//! [`WindowViews`] is the file — `~/.config/tty7/views.json`, alongside +//! `config.json`. All IO is best-effort: a missing/corrupt file just means "no +//! views to restore", and write failures are logged rather than fatal — the +//! app must never crash or stall over view bookkeeping. use std::path::PathBuf; @@ -91,23 +94,31 @@ pub struct SessionTab { /// **A bare path, and that is sound.** A path alone cannot say *which* /// machine it is on, and [`HostId`](crate::host::HostId) — which could — /// is deliberately not persistable. The qualifier is not missing, it is - /// factored out: a tab always belongs to exactly one [`Workspace`], a - /// workspace names exactly one machine in [`Workspace::host`], and a + /// factored out: a tab always belongs to exactly one workspace, a + /// workspace names exactly one machine in [`WindowView::host`], and a /// window shows exactly one workspace — mixing local and remote tabs in one /// window is the thing tty7 never does. So the fully-qualified group key - /// is `(workspace.host_id(), tab.sidebar_group)`, with the host half + /// is `(view.host_id(), tab.sidebar_group)`, with the host half /// stored once per workspace instead of once per tab. Two machines whose /// repos share a root path can only collide inside one window, which the /// model does not permit. #[serde(default, skip_serializing_if = "Option::is_none")] pub sidebar_group: Option, + /// The tab's identity in the daemon's machine tree, when this session was + /// derived *from* that tree — so a window rebuilt from it addresses the + /// daemon's tabs rather than minting new ids and churning them. **Never + /// persisted**: the tree is the authority on its own ids, and a stale one + /// written to disk would collide with a tab the daemon has since reused it + /// for. `None` (every other source) mints a fresh id. + #[serde(skip)] + pub tree_id: Option, } /// 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. +/// This is the unit a single window displays — the in-memory shape a window +/// is built from and lowered into, never persisted (the machine's tree is the +/// layout's home). #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(default)] pub struct Session { @@ -146,6 +157,17 @@ impl std::fmt::Display for WorkspaceId { } } +impl std::str::FromStr for WorkspaceId { + type Err = uuid::Error; + + /// The inverse of `Display`, for the places a workspace id crosses a + /// string-keyed boundary (the control dialect's attach verbs, which + /// predate the typed tree) and has to come back out as itself. + fn from_str(s: &str) -> Result { + s.parse().map(WorkspaceId) + } +} + // --------------------------------------------------------------------------- // Remote references // --------------------------------------------------------------------------- @@ -217,7 +239,7 @@ impl RemoteTarget { /// /// The host is lowercased here *and* in [`connection_key`](Self::connection_key) /// — here so two equal targets compare equal, there so a hand-edited - /// `session.json` with `Box.Local` still derives the same id as `box.local`. + /// `views.json` with `Box.Local` still derives the same id as `box.local`. pub fn direct(user: impl Into, host: impl Into, port: u16) -> RemoteTarget { RemoteTarget::Direct { user: user.into(), @@ -318,11 +340,11 @@ impl std::fmt::Display for RemoteTarget { /// A workspace that lives on another machine: which machine, and which /// workspace over there. /// -/// The `workspace` id is the **remote's**, minted once and then used as the key -/// into that machine's `~/.local/share/tty7/workspaces.json` -/// ([`crate::core::workspace_store`]). A client-side [`Workspace`] carrying one -/// of these is a *view*, not the record: its `session` is left empty until the -/// layout is pulled from the remote, which owns it. +/// The `workspace` id is the **remote's**, minted once and then used as the +/// workspace's id in that machine's daemon-owned tree +/// ([`crate::core::machine`]). A client-side [`WindowView`] carrying one of +/// these is a *view*, not the record: the layout lives on the remote, which +/// owns it. #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct RemoteRef { /// Which machine, in terms of a configuration that already exists. @@ -342,220 +364,79 @@ impl RemoteRef { self.target.host_id() } - /// The remote store's key for this workspace — what - /// [`ControlRequest::WorkspaceGet`](crate::daemon::control::ControlRequest::WorkspaceGet) - /// and friends carry. + /// The wire key for this workspace — the form the string-keyed control + /// verbs (the attach family) and the `ControlEvent::Layout` events carry. pub fn store_key(&self) -> String { self.workspace.to_string() } } -/// 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. +/// One workspace's **view state** on this client: which workspace (and on +/// which machine), where its window last was, whether it was on screen, and +/// when it was last focused. The layout itself lives in the machine's tree — +/// this entry is deliberately only what the tree cannot know, the facts about +/// *this client's windows*. Closing a window is a *detach*: the panes keep +/// running in the daemon and the entry stays here with `open: false`. #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Workspace { +pub struct WindowView { #[serde(default)] pub id: WorkspaceId, - /// User-set name from "Rename Workspace". `None` falls back to - /// [`Workspace::display_name`], derived from the tabs' repo/cwd. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub name: Option, - #[serde(default)] - pub session: Session, /// Geometry this workspace's window last occupied, so reopening it lands /// where the user left it rather than at the shared default. `None` for a /// workspace that has never been on screen. #[serde(default, skip_serializing_if = "Option::is_none")] pub window: Option, /// Whether a window was showing this workspace at quit. Launch reopens - /// exactly the `open` ones; the rest wait in the picker. + /// exactly one of the `open` ones; the rest wait in the picker. #[serde(default)] pub open: bool, /// Unix seconds when this workspace was last focused, for "2 minutes ago" /// in the picker and for ordering it. 0 == never recorded. + /// + /// The machine's tree keeps its own recency; this copy exists because + /// launch has to order entries before any tree has been pulled. #[serde(default)] pub last_active: u64, /// The machine this workspace's panes and files live on. `None` means this - /// one, **and means it identically to every build that predates the field**: - /// a `session.json` written before this existed decodes with `None` - /// throughout, i.e. all-local, which is the behaviour it had. - /// - /// A `Some` entry is a *view* of a record that lives over there. Its - /// `session` is empty until the layout is pulled from the remote's own - /// store; `window` and `open` stay here, because they are this client's - /// view state and closing a window at the office must not hide the - /// workspace from the laptop at home. + /// one. A `Some` entry keeps its own client-side `id` (the window + /// registry's handle) while `host.workspace` names the workspace on that + /// machine — see [`RemoteRef`]. #[serde(default, skip_serializing_if = "Option::is_none")] pub host: Option, - /// Identity of the daemon *process* the pane ids in `session` refer to - /// (see `daemon::protocol::DaemonVersion::instance`). One field for the - /// whole workspace, not one per leaf, because a workspace's panes all live - /// in one daemon (one window, one machine). + /// What this workspace was *called* the last time its machine answered, and + /// the path it was about — the picker's two lines. /// - /// This is what makes a saved pane id safe to trust: daemon ids restart - /// from 1, so after a reboot every saved id points at whatever unrelated - /// shell happens to hold the number now — and restore's aliveness check - /// cannot tell a survivor from a squatter. A claim whose instance differs - /// from the daemon now serving blanks its ids instead - /// ([`Workspace::forget_stale_pane_ids`]) and takes the fresh-spawn path, - /// agent resume included, which is the correct reading of "the daemon - /// those panes lived in is gone". - /// - /// A remote workspace records its machine's `tty7-server` instance here, - /// for exactly the same reason and read by exactly the same check. The live - /// per-connection tracking on the client (`note_instance`) does not replace - /// this: that map is in memory, so it is empty on the launch where it would - /// matter most — the one after a client restart that spanned a server - /// replacement. - /// - /// `None` for records written before the field, and whenever the serving - /// process cannot be named (an older peer, a machine not connected). `None` - /// disables the check, never fails it. + /// **A render hint, never an authority.** The machine's tree owns both (it + /// derives them from the tabs' repo groups and its panes' cwds), and + /// whenever the tree answers, the tree wins. This copy exists because the + /// picker's whole job is choosing among machines that are *not* answering: + /// a laptop that has been shut since Friday still has to be listed as + /// "tty7 — ~/repo/tty7" rather than as "Untitled" with a blank subtitle, + /// which is a row nobody can act on. Stamped on every save (and on the way + /// out, when a window closes), so what is on file is the last thing the + /// user actually saw. #[serde(default, skip_serializing_if = "Option::is_none")] - pub daemon_instance: Option, + pub label: Option, + /// The subject path behind [`label`](Self::label) — see there. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub subject: Option, } -impl Default for Workspace { +impl Default for WindowView { fn default() -> Self { Self { id: WorkspaceId::new(), - name: None, - session: Session::default(), window: None, open: true, last_active: now_secs(), host: None, - daemon_instance: None, + label: None, + subject: None, } } } -impl Workspace { - /// Wrap a bare session as a brand-new open workspace. - pub fn from_session(session: Session) -> Self { - Self { - session, - ..Self::default() - } - } - - /// What to show in the picker and the window title: the user-set name if - /// any, else the repository most of its tabs live in, else the first tab's - /// directory, else a generic fallback. Derived rather than stored so a - /// workspace that `cd`s into a project stops being "Untitled" on its own. - pub fn display_name(&self) -> String { - if let Some(name) = self - .name - .as_ref() - .map(|n| n.trim()) - .filter(|n| !n.is_empty()) - { - return name.to_string(); - } - if let Some(repo) = self.dominant_repo() { - if let Some(base) = basename(&repo) { - return base; - } - } - if let Some(cwd) = self.first_cwd() { - if let Some(base) = basename(&cwd) { - return base; - } - } - "Untitled".to_string() - } - - /// The repo root the most tabs belong to — the workspace's centre of - /// gravity for naming. Ties break toward the earliest tab, matching the - /// order the user sees in the sidebar. - pub fn dominant_repo(&self) -> Option { - let mut counts: Vec<(PathBuf, usize)> = Vec::new(); - for group in self - .session - .tabs - .iter() - .filter_map(|t| t.sidebar_group.as_ref()) - { - match counts.iter_mut().find(|(path, _)| path == group) { - Some((_, n)) => *n += 1, - None => counts.push((group.clone(), 1)), - } - } - counts - .into_iter() - .max_by_key(|(_, n)| *n) - .map(|(path, _)| path) - } - - /// The first saved cwd anywhere in the tab tree, used for naming and for - /// the picker's dim subtitle line. - pub fn first_cwd(&self) -> Option { - self.session - .tabs - .iter() - .find_map(|tab| first_leaf_cwd(&tab.pane)) - } - - /// Total leaf terminals across every tab — the picker's "3 panes" count. - pub fn pane_count(&self) -> usize { - self.session.tabs.iter().map(|t| leaf_count(&t.pane)).sum() - } - - /// Every daemon pane id this workspace claims, for the cross-window - /// uniqueness check on restore (two windows attaching one pane would let - /// the second silently steal the first's stream). - pub fn pane_ids(&self) -> Vec { - let mut out = Vec::new(); - for tab in &self.session.tabs { - collect_pane_ids(&tab.pane, &mut out); - } - out - } - - /// Drop every saved pane id, keeping the layout. Answers how many were - /// dropped, so a caller with nothing to forget can skip the write. - /// - /// For the one caller that *knows* the panes are gone: ending a workspace's - /// sessions kills them and then leaves the record on file to be reopened. - /// The ids in it are ours to invalidate — we are what killed them — and a - /// leaf with no id is exactly what restore needs to see, because that is - /// the path that spawns a fresh shell in the saved cwd and hands a coding - /// agent its `--resume`. Left in place they are a promise the machine - /// cannot keep: the reattach finds nothing, and on a remote workspace it - /// used to have no way to say so. - pub fn forget_pane_ids(&mut self) -> usize { - let mut forgotten = 0; - for tab in &mut self.session.tabs { - forgotten += blank_pane_ids(&mut tab.pane); - } - forgotten - } - - /// Blank every saved pane id if it was recorded against a *different* - /// daemon process than `current` — see [`Workspace::daemon_instance`] for - /// the id-reuse failure this closes. Answers how many ids were dropped. - /// - /// Only a **known, differing** instance pair trips it. `None` on either - /// side means "cannot tell" (an old record, an old daemon), and treating - /// that as stale would respawn every pane on the first launch after an - /// upgrade — exactly the sessions persistence exists to keep. - /// - /// The agent fields stay, deliberately: unlike a *duplicate* claim (see - /// `drop_duplicate_pane_ids`), a stale-instance claim means the pane is - /// genuinely gone with its daemon, nothing else is running the - /// conversation, and the fresh shell resuming it is the feature. - pub fn forget_stale_pane_ids(&mut self, current: Option<&str>) -> usize { - let (Some(recorded), Some(current)) = (self.daemon_instance.as_deref(), current) else { - return 0; - }; - if recorded == current { - return 0; - } - self.forget_pane_ids() - } - +impl WindowView { /// Stamp this workspace as just-focused. pub fn touch(&mut self) { self.last_active = now_secs(); @@ -564,15 +445,10 @@ impl Workspace { // ----- the local / remote split ---------------------------------------- /// A client-side entry for a workspace that lives on another machine. - /// - /// The `session` is left empty on purpose: the remote's - /// `~/.local/share/tty7/workspaces.json` is the authority for the layout, - /// and it is pulled on connect. Filling it in from a stale local guess would - /// make the window flash a layout the machine has since moved on from. - pub fn on_remote(host: RemoteRef) -> Workspace { - Workspace { + pub fn on_remote(host: RemoteRef) -> WindowView { + WindowView { host: Some(host), - ..Workspace::default() + ..WindowView::default() } } @@ -595,128 +471,40 @@ impl Workspace { None => crate::host::HostId::LOCAL, } } - - /// The record the **remote** owns, as the JSON that crosses the wire in a - /// [`WorkspacePut`](crate::daemon::control::ControlRequest::WorkspacePut). - /// - /// The storage split, executable rather than aspirational: what - /// stays here is `window`, `open` and `host` — this client's view state — - /// and what goes over there is everything that is a fact about the machine. - /// [`REMOTE_OWNED_FIELDS`] pins the split, and a test fails if a new field - /// is added without a decision about which side it belongs to. - pub fn to_remote_json(&self) -> serde_json::Value { - let mut value = serde_json::to_value(self).unwrap_or(serde_json::Value::Null); - if let Some(obj) = value.as_object_mut() { - obj.retain(|k, _| REMOTE_OWNED_FIELDS.contains(&k.as_str())); - } - value - } - - /// Merge an authoritative record pulled from a remote store into this entry. - /// - /// Touches only the remote-owned fields. `id`, `host`, `window` and `open` - /// are left exactly as they were — the first two because the client's entry - /// is the thing being *pointed* by them, the last two because they are this - /// machine's view state and the remote has no opinion about them. - pub fn apply_remote_json(&mut self, value: &serde_json::Value) -> serde_json::Result<()> { - let record: RemoteRecord = serde_json::from_value(value.clone())?; - self.name = record.name; - self.session = record.session; - self.last_active = record.last_active; - Ok(()) - } } -/// The `Workspace` fields the **remote** is the authority for. -/// Everything else is client-side view state and never leaves this machine. -/// -/// A `Workspace` field that is in neither list is a bug: it would be dropped by -/// [`Workspace::to_remote_json`] and silently lost on the next pull. The test -/// `the_storage_split_covers_every_workspace_field` is what makes that a red -/// build rather than a data-loss report. -pub const REMOTE_OWNED_FIELDS: &[&str] = &["id", "name", "session", "last_active"]; - -/// The client-side view state, which stays in this machine's `session.json`. -/// `daemon_instance` is client-owned because it records **which serving process -/// this client last saw** — an observation, not a property of the workspace. Two -/// clients open on one remote workspace each keep their own, and neither may -/// overwrite the other's; a remote record that carried it would do exactly that. -pub const CLIENT_OWNED_FIELDS: &[&str] = &["window", "open", "host", "daemon_instance"]; - -/// The remote-owned half of a [`Workspace`], for reading a record back. -/// -/// Every field defaults: a record written by a *newer* client carries fields -/// this build has never heard of (serde ignores them), and one written by an -/// older client is missing fields this build expects. Neither may fail the pull -/// — a workspace that will not decode is a workspace the user cannot open. -#[derive(Deserialize)] -struct RemoteRecord { - #[serde(default)] - name: Option, - #[serde(default)] - session: Session, - #[serde(default)] - last_active: u64, -} - -/// The whole `session.json`: every workspace tty7 knows about, plus which one +/// The whole `views.json`: every workspace tty7 knows about, plus which one /// had focus at quit. #[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct Workspaces { - /// Note: deliberately *not* `#[serde(default)]` at the struct level — the - /// presence of this key is what distinguishes a new-format file from the - /// legacy flat `{active, tabs}` one. See [`Workspaces::decode`]. - pub workspaces: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] +#[serde(default)] +pub struct WindowViews { + pub views: Vec, + #[serde(skip_serializing_if = "Option::is_none")] pub active: Option, } -impl Workspaces { - /// Load every saved workspace. Returns `None` when the file is absent or +impl WindowViews { + /// Load every saved view. Returns `None` when the file is absent or /// unreadable (normal first run), and `None` with a warning when it fails /// to parse — never panics. pub fn load() -> Option { let path = Self::path()?; let text = std::fs::read_to_string(&path).ok()?; - match Self::decode(&text) { + match serde_json::from_str(crate::core::config::strip_bom(&text)) { Ok(loaded) => Some(loaded), Err(e) => { - log::warn!( - "failed to parse session at {}: {e}; ignoring", - path.display() - ); + log::warn!("failed to parse views at {}: {e}; ignoring", path.display()); None } } } - /// Parse either format. A file written by any build with multi-window - /// support has a `workspaces` array; anything else is a pre-multi-window - /// `{active, tabs}` session, which migrates to a single open workspace so - /// upgrading users keep their tabs (and their attached daemon panes). - pub fn decode(text: &str) -> Result { - let value: serde_json::Value = serde_json::from_str(crate::core::config::strip_bom(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))) + pub fn get(&self, id: WorkspaceId) -> Option<&WindowView> { + self.views.iter().find(|w| w.id == id) } - /// 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) + pub fn get_mut(&mut self, id: WorkspaceId) -> Option<&mut WindowView> { + self.views.iter_mut().find(|w| w.id == id) } /// The workspaces that had a window at the last quit, in their saved order. @@ -725,8 +513,8 @@ impl Workspaces { /// [`workspace_to_restore`](Self::workspace_to_restore). They are still the /// set that matters here, because every one of them is holding live daemon /// panes and none of them may be forgotten. - pub fn open_workspaces(&self) -> impl Iterator { - self.workspaces.iter().filter(|w| w.open) + pub fn open_views(&self) -> impl Iterator { + self.views.iter().filter(|w| w.open) } /// The one workspace launch comes up on: whichever the user was last in. @@ -748,89 +536,44 @@ impl Workspaces { .active .filter(|id| self.get(*id).is_some_and(|w| w.open)); focused.or_else(|| { - self.open_workspaces() + self.open_views() .max_by_key(|w| w.last_active) .map(|w| w.id) }) } - /// 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 *on the same - /// machine*, 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. - /// - /// **Scoped per machine, because a pane id only means anything within one - /// daemon.** Every daemon hands out 1, 2, 3…, so a laptop and a build box - /// both having a pane 1 is the normal case, not a conflict. Deduping - /// globally would make the remote workspace forfeit a claim on a pane that - /// is alive and well on its own machine — orphaning a live session over a - /// collision that never existed. - /// - /// 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)); - - // One `seen` set per machine. `HostId` is process-local, but this only - // has to be self-consistent within the single pass below. - let mut seen: std::collections::HashMap< - crate::host::HostId, - std::collections::HashSet, - > = std::collections::HashMap::new(); - let mut dropped = 0; - for (index, _) in order { - let workspace = &mut self.workspaces[index]; - let host = workspace.host_id(); - let seen_here = seen.entry(host).or_default(); - for tab in &mut workspace.session.tabs { - dropped += drop_duplicate_pane_ids(&mut tab.pane, seen_here); - } - } - 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. + /// crash or stall over view bookkeeping. pub fn save(&self) { let Some(path) = Self::path() else { return; }; if let Some(parent) = path.parent() { if let Err(e) = std::fs::create_dir_all(parent) { - log::warn!("failed to create session dir {}: {e}", parent.display()); + log::warn!("failed to create views dir {}: {e}", parent.display()); return; } } let json = match serde_json::to_string_pretty(self) { Ok(j) => j, Err(e) => { - log::warn!("failed to serialize session: {e}"); + log::warn!("failed to serialize views: {e}"); return; } }; if let Err(e) = crate::core::config::write_atomic(&path, json.as_bytes()) { - log::warn!("failed to write session to {}: {e}", path.display()); + log::warn!("failed to write views to {}: {e}", path.display()); } } - /// `~/.config/tty7/session.json`, alongside `config.json`. + /// `~/.config/tty7/views.json`, alongside `config.json`. + /// + /// A fresh name, not `session.json`: that file's document embedded whole + /// layouts, this one is pure view state, and the migration policy for the + /// tree refactor is deliberately none — an old file is simply ignored. fn path() -> Option { - crate::core::config::config_path("session.json") + crate::core::config::config_path("views.json") } } @@ -843,93 +586,11 @@ fn now_secs() -> u64 { .unwrap_or(0) } -/// Last path component as a display string, skipping a bare `/` or a path that -/// ends in `..`. -fn basename(path: &std::path::Path) -> Option { - path.file_name() - .and_then(|n| n.to_str()) - .map(|s| s.to_string()) - .filter(|s| !s.is_empty()) -} - -fn first_leaf_cwd(pane: &SessionPane) -> Option { - match pane { - SessionPane::Leaf { cwd, .. } => cwd.clone(), - SessionPane::Split { a, b, .. } => first_leaf_cwd(a).or_else(|| first_leaf_cwd(b)), - } -} - -fn leaf_count(pane: &SessionPane) -> usize { - match pane { - SessionPane::Leaf { .. } => 1, - SessionPane::Split { a, b, .. } => leaf_count(a) + leaf_count(b), - } -} - -fn collect_pane_ids(pane: &SessionPane, out: &mut Vec) { - match pane { - SessionPane::Leaf { pane_id, .. } => out.extend(pane_id), - SessionPane::Split { a, b, .. } => { - collect_pane_ids(a, out); - collect_pane_ids(b, out); - } - } -} - -/// Blank every leaf's `pane_id` under `pane`, answering how many were set. -/// See [`Workspace::forget_pane_ids`]. -pub fn blank_pane_ids(pane: &mut SessionPane) -> usize { - match pane { - SessionPane::Leaf { pane_id, .. } => usize::from(pane_id.take().is_some()), - SessionPane::Split { a, b, .. } => blank_pane_ids(a) + blank_pane_ids(b), - } -} - -/// 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. -/// -/// The agent resume fields go with it. A blanked leaf takes restore's -/// spawn-fresh path, and that path auto-types the agent's resume command — -/// but the pane this claim duplicated is still running that very agent under -/// its winning workspace, so "recovering" the loser would start a second -/// process on the same agent session id. The duplicate claim is the evidence -/// of a corrupted record, not of a lost conversation; the conversation lives -/// with the winner. -fn drop_duplicate_pane_ids( - pane: &mut SessionPane, - seen: &mut std::collections::HashSet, -) -> usize { - match pane { - SessionPane::Leaf { - pane_id, - agent_session_id, - agent_launch_argv, - .. - } => match *pane_id { - Some(id) if !seen.insert(id) => { - log::warn!( - "workspace claims pane {id} twice; dropping the duplicate claim \ - (and its agent resume, which the winning claim still owns)" - ); - *pane_id = None; - *agent_session_id = None; - *agent_launch_argv = 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 +/// Helpers for every test that touches the on-disk `views.json`. The /// config-dir pin is process-wide (`set_config_dir` is first-call-wins), so /// the file is process-wide too — any test that reads or writes it must hold /// [`lock_session_file`] across the whole read/write sequence, or parallel -/// tests clobber each other's session. +/// tests clobber each other's file. #[cfg(test)] pub(crate) mod test_support { use std::path::PathBuf; @@ -937,7 +598,7 @@ pub(crate) mod test_support { static SESSION_FILE: Mutex<()> = Mutex::new(()); - /// Serialize access to the shared `session.json`. + /// Serialize access to the shared `views.json`. pub(crate) fn lock_session_file() -> MutexGuard<'static, ()> { // A poisoned lock just means another test failed mid-sequence; every // holder rewrites the file from scratch, so the state is still sound. @@ -945,7 +606,7 @@ pub(crate) mod test_support { } /// Pin the process config dir at a shared temp location so `save`/`load` - /// (which resolve `session.json` under it) never touch the real `~/.config`. + /// (which resolve `views.json` under it) never touch the real `~/.config`. /// `set_config_dir` is first-call-wins; every caller computes the same path. pub(crate) fn pin_config_dir() -> PathBuf { let dir = std::env::temp_dir().join(format!("tty7-covtest-{}", std::process::id())); @@ -960,696 +621,59 @@ mod tests { use super::test_support::{lock_session_file, pin_config_dir}; use super::*; - #[test] - fn session_json_round_trips_nested_tree() { - let session = Session { - active: 1, - tabs: vec![ - SessionTab { - name: Some("build".into()), - sidebar_group: None, - pane: SessionPane::Leaf { - cwd: Some(PathBuf::from("/work")), - pane_id: Some(7), - ssh_spec: None, - agent: None, - agent_session_id: None, - agent_launch_argv: None, - }, - }, - SessionTab { - name: None, - sidebar_group: None, - pane: SessionPane::Split { - axis: SessionAxis::Vertical, - ratio: 0.3, - a: Box::new(SessionPane::Leaf { - cwd: None, - pane_id: None, - ssh_spec: None, - agent: None, - agent_session_id: None, - agent_launch_argv: None, - }), - b: Box::new(SessionPane::Leaf { - cwd: Some(PathBuf::from("/tmp")), - pane_id: Some(9), - ssh_spec: None, - agent: None, - agent_session_id: None, - agent_launch_argv: None, - }), - }, - }, - ], - }; - let json = serde_json::to_string(&session).unwrap(); - let back: Session = serde_json::from_str(&json).unwrap(); - assert_eq!(back.active, 1); - assert_eq!(back.tabs.len(), 2); - assert!(matches!( - back.tabs[0].pane, - SessionPane::Leaf { - pane_id: Some(7), - .. - } - )); - match &back.tabs[1].pane { - SessionPane::Split { ratio, .. } => assert!((ratio - 0.3).abs() < 1e-6), - _ => panic!("expected a split"), - } + fn view() -> WindowView { + WindowView::default() + } + + fn remote_view(alias: &str) -> WindowView { + WindowView::on_remote(RemoteRef::new( + RemoteTarget::Alias { + alias: alias.into(), + }, + WorkspaceId::new(), + )) } #[test] - fn leaf_agent_resume_fields_round_trip_and_default() { - // Round trip: the agent + native session id survive serialization. - let leaf = SessionPane::Leaf { - cwd: None, - pane_id: None, - ssh_spec: None, - agent: Some(crate::core::cli_agent::CLIAgent::Claude), - agent_session_id: Some("abc-123".into()), - agent_launch_argv: Some(vec![ - "claude".into(), - "--dangerously-skip-permissions".into(), - ]), - }; - let back: SessionPane = - serde_json::from_str(&serde_json::to_string(&leaf).unwrap()).unwrap(); - match back { - SessionPane::Leaf { - agent, - agent_session_id, - agent_launch_argv, - .. - } => { - assert_eq!(agent, Some(crate::core::cli_agent::CLIAgent::Claude)); - assert_eq!(agent_session_id.as_deref(), Some("abc-123")); - assert_eq!( - agent_launch_argv.as_deref(), - Some( - &[ - "claude".to_string(), - "--dangerously-skip-permissions".to_string() - ][..] - ) - ); - } - _ => panic!("expected leaf"), - } - // A session written before these fields existed decodes with `None`s. - let old: SessionPane = - serde_json::from_str(r#"{"Leaf":{"cwd":"/x","pane_id":3}}"#).unwrap(); - assert!(matches!( - old, - SessionPane::Leaf { - agent: None, - agent_session_id: None, - agent_launch_argv: None, - .. - } - )); - } - - #[test] - fn a_utf8_bom_does_not_discard_the_session() { - // `Session::load` treats a parse error as "no session", so a BOM on a - // hand-edited `session.json` doesn't warn — it drops every workspace - // and opens on the home page as if nothing had been saved. - // Legacy `{active, tabs}` shape, so this also covers the migration path. - let decoded = Workspaces::decode( - "\u{FEFF}{\"active\": 0, \"tabs\": [{\"pane\": {\"Leaf\": {\"cwd\": \"/work\"}}}]}", - ) - .expect("a BOM'd session still decodes"); - let tabs = &decoded - .workspaces - .first() - .expect("migrated workspace") - .session - .tabs; - assert_eq!(tabs.len(), 1); - } - - #[test] - fn session_defaults_fill_missing_fields() { - // An empty object → default (active 0, no tabs). - let s: Session = serde_json::from_str("{}").unwrap(); - assert_eq!(s.active, 0); - assert!(s.tabs.is_empty()); - - // A split without a ratio falls back to the 0.5 default, and a leaf - // without cwd/pane_id decodes with `None`s. - let pane: SessionPane = serde_json::from_str( - r#"{"Split":{"axis":"Horizontal","a":{"Leaf":{}},"b":{"Leaf":{}}}}"#, - ) - .unwrap(); - match pane { - SessionPane::Split { ratio, .. } => assert_eq!(ratio, 0.5), - _ => panic!("expected split"), - } - } - - #[test] - fn save_then_load_recovers_the_session() { + fn views_round_trip_through_their_file() { let _file = lock_session_file(); pin_config_dir(); - let session = Session { - active: 0, - tabs: vec![SessionTab { - name: Some("main".into()), - sidebar_group: None, - pane: SessionPane::Leaf { - cwd: Some(PathBuf::from("/home/u")), - pane_id: Some(1), - ssh_spec: None, - agent: None, - agent_session_id: None, - agent_launch_argv: None, - }, - }], - }; - Workspaces::single(Workspace::from_session(session)).save(); - let loaded = Workspaces::load().expect("a saved session should load back"); - let only = &loaded.workspaces[0]; - assert_eq!(only.session.tabs.len(), 1); - assert_eq!(only.session.tabs[0].name.as_deref(), Some("main")); - assert_eq!(loaded.active, Some(only.id)); - } - - // ── Workspace layer ───────────────────────────────────────────────────── - - /// Build a leaf with the given cwd + pane id; the agent/ssh fields are - /// irrelevant to every workspace-layer test. - fn leaf(cwd: Option<&str>, pane_id: Option) -> SessionPane { - SessionPane::Leaf { - cwd: cwd.map(PathBuf::from), - pane_id, - ssh_spec: None, - agent: None, - agent_session_id: None, - agent_launch_argv: None, - } - } - - fn tab(pane: SessionPane, group: Option<&str>) -> SessionTab { - SessionTab { - name: None, - sidebar_group: group.map(PathBuf::from), - pane, - } - } - - fn workspace(tabs: Vec) -> Workspace { - Workspace::from_session(Session { active: 0, tabs }) - } - - #[test] - fn legacy_flat_session_migrates_to_one_open_workspace() { - // Exactly the shape every pre-multi-window build wrote. - let legacy = r#"{"active":1,"tabs":[ - {"name":"build","pane":{"Leaf":{"cwd":"/work","pane_id":7}}}, - {"name":null,"pane":{"Leaf":{"cwd":"/tmp","pane_id":9}}} - ]}"#; - let loaded = Workspaces::decode(legacy).expect("legacy session should migrate"); - assert_eq!(loaded.workspaces.len(), 1); - let only = &loaded.workspaces[0]; - // The tabs — and crucially the pane ids, which are live daemon panes — - // survive the upgrade, so an updating user doesn't lose their shells. - assert_eq!(only.session.active, 1); - assert_eq!(only.session.tabs.len(), 2); - assert_eq!(only.pane_ids(), vec![7, 9]); - // It reopens on the next launch, matching pre-upgrade behavior. - assert!(only.open); - assert_eq!(loaded.active, Some(only.id)); - } - - #[test] - fn empty_and_absent_shapes_decode_without_losing_data() { - // `{}` is the home-page state an older build wrote: zero tabs, still valid. - let empty = Workspaces::decode("{}").expect("empty object decodes"); - assert_eq!(empty.workspaces.len(), 1); - assert!(empty.workspaces[0].session.tabs.is_empty()); - // A new-format file with no workspaces at all stays empty rather than - // being mistaken for a legacy session and gaining a phantom entry. - let none = Workspaces::decode(r#"{"workspaces":[]}"#).expect("new format decodes"); - assert!(none.workspaces.is_empty()); - } - - #[test] - fn new_format_round_trips_through_json() { - let mut ws = workspace(vec![tab(leaf(Some("/work"), Some(3)), Some("/work"))]); - ws.name = Some("api".into()); - ws.open = false; - ws.last_active = 1_700_000_000; - let id = ws.id; - let all = Workspaces { + let mut entry = remote_view("build-box"); + entry.open = false; + entry.last_active = 1_700_000_000; + let id = entry.id; + let host = entry.host.clone(); + WindowViews { 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")); + views: vec![entry], + } + .save(); + let loaded = WindowViews::load().expect("a saved views file should load back"); + let only = &loaded.views[0]; + assert_eq!(only.id, id, "identity must survive a restart"); + assert_eq!( + only.host, host, + "the remote pointer is the load-bearing half" + ); assert!(!only.open); assert_eq!(only.last_active, 1_700_000_000); - assert_eq!(back.active, Some(id)); + assert_eq!(loaded.active, Some(id)); } - /// Ending a workspace's sessions leaves the layout and drops the ids — the - /// cwds are what reopening rebuilds from, and a kept id would send restore - /// down the reattach path to a pane that no longer exists. + /// The migration policy for the tree refactor is deliberately none: an old + /// `session.json` (whatever its shape) is not read, and a `views.json` + /// missing every field still decodes rather than erroring a launch. #[test] - fn forgetting_pane_ids_keeps_the_layout_and_the_cwds() { - let mut ws = workspace(vec![ - tab( - SessionPane::Split { - axis: SessionAxis::Horizontal, - ratio: 0.5, - a: Box::new(leaf(Some("/work"), Some(1))), - b: Box::new(leaf(Some("/work/api"), Some(2))), - }, - Some("/work"), - ), - tab(leaf(Some("/tmp"), None), None), - ]); - - assert_eq!( - ws.forget_pane_ids(), - 2, - "only the claims that existed count" - ); - assert!(ws.pane_ids().is_empty()); - assert_eq!(ws.session.tabs.len(), 2, "the tabs are what survives"); - assert_eq!(ws.pane_count(), 3, "and so is the split"); - assert_eq!( - ws.first_cwd(), - Some(PathBuf::from("/work")), - "reopening respawns in the saved directory, so it must still be there" - ); - assert_eq!( - ws.forget_pane_ids(), - 0, - "a second pass has nothing to do, so the caller can skip its write" - ); + fn an_empty_or_partial_file_decodes_to_defaults() { + let empty: WindowViews = serde_json::from_str("{}").unwrap(); + assert!(empty.views.is_empty()); + assert!(empty.active.is_none()); + let partial: WindowViews = serde_json::from_str(r#"{"views":[{}]}"#).unwrap(); + assert_eq!(partial.views.len(), 1); + assert!(!partial.views[0].is_remote()); } - /// The stale-instance check: ids recorded against a *different* daemon - /// process are blanked (they now name unrelated shells at best), ids - /// recorded against the *same* one are kept, and an unknown on either side - /// changes nothing — treating "cannot tell" as stale would respawn every - /// pane on the first launch after an upgrade. - #[test] - fn stale_instance_blanks_pane_ids_and_matching_or_unknown_keeps_them() { - let fresh = |instance: Option<&str>| { - let mut ws = workspace(vec![tab(leaf(Some("/work"), Some(7)), None)]); - ws.daemon_instance = instance.map(str::to_string); - ws - }; - - let mut ws = fresh(Some("daemon-a")); - assert_eq!(ws.forget_stale_pane_ids(Some("daemon-b")), 1); - assert!(ws.pane_ids().is_empty()); - assert_eq!( - ws.first_cwd(), - Some(PathBuf::from("/work")), - "the layout survives; only the claims go" - ); - - let mut ws = fresh(Some("daemon-a")); - assert_eq!(ws.forget_stale_pane_ids(Some("daemon-a")), 0); - assert_eq!(ws.pane_ids(), vec![7], "same process, ids stay attachable"); - - let mut ws = fresh(None); - assert_eq!(ws.forget_stale_pane_ids(Some("daemon-b")), 0); - assert_eq!(ws.pane_ids(), vec![7], "an old record is not judged"); - - let mut ws = fresh(Some("daemon-a")); - assert_eq!(ws.forget_stale_pane_ids(None), 0); - assert_eq!(ws.pane_ids(), vec![7], "an unknown daemon is not judged"); - } - - /// Unlike a duplicate claim, a stale-instance claim keeps its agent resume: - /// the daemon those panes lived in is gone, nothing else runs the - /// conversation, and the fresh shell resuming it is the feature working. - #[test] - fn stale_instance_keeps_the_agent_resume() { - let mut ws = workspace(vec![tab( - SessionPane::Leaf { - cwd: Some(PathBuf::from("/work")), - pane_id: Some(7), - ssh_spec: None, - agent: Some(crate::core::cli_agent::CLIAgent::Claude), - agent_session_id: Some("sid".into()), - agent_launch_argv: None, - }, - None, - )]); - ws.daemon_instance = Some("daemon-a".into()); - assert_eq!(ws.forget_stale_pane_ids(Some("daemon-b")), 1); - match &ws.session.tabs[0].pane { - SessionPane::Leaf { - pane_id, - agent_session_id, - .. - } => { - assert!(pane_id.is_none()); - assert_eq!(agent_session_id.as_deref(), Some("sid")); - } - SessionPane::Split { .. } => panic!("leaf stays a leaf"), - } - } - - #[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")) - ); - } - - /// The duplicate claim loses its agent resume along with its pane id. - /// Restore's spawn-fresh path auto-types the agent's resume command, and - /// the winning workspace's pane is still *running* that agent — a loser - /// that kept `agent_session_id` would come back as a second process on - /// the same conversation (double `claude --resume `, both live). - #[test] - fn dedupe_pane_ids_disarms_the_duplicate_claims_agent_resume() { - let agent_leaf = |pane_id| SessionPane::Leaf { - cwd: Some(PathBuf::from("/work")), - pane_id: Some(pane_id), - ssh_spec: None, - agent: Some(crate::core::cli_agent::CLIAgent::Claude), - agent_session_id: Some("362f9261".into()), - agent_launch_argv: Some(vec!["claude".into(), "--continue".into()]), - }; - let mut stale = workspace(vec![tab(agent_leaf(5), None)]); - stale.last_active = 100; - let mut fresh = workspace(vec![tab(agent_leaf(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); - - let loser = &all.get(stale_id).unwrap().session.tabs[0].pane; - match loser { - SessionPane::Leaf { - pane_id, - cwd, - agent_session_id, - agent_launch_argv, - .. - } => { - assert!(pane_id.is_none()); - assert_eq!( - cwd.as_deref(), - Some(std::path::Path::new("/work")), - "the layout survives — only the claim and its resume go" - ); - assert!( - agent_session_id.is_none(), - "no second resume of one conversation" - ); - assert!(agent_launch_argv.is_none()); - } - SessionPane::Split { .. } => panic!("the leaf must survive as a leaf"), - } - - // The winner is untouched: its pane is the one actually running the agent. - match &all.get(fresh_id).unwrap().session.tabs[0].pane { - SessionPane::Leaf { - pane_id, - agent_session_id, - .. - } => { - assert_eq!(*pane_id, Some(5)); - assert_eq!(agent_session_id.as_deref(), Some("362f9261")); - } - SessionPane::Split { .. } => panic!("the leaf must survive as a leaf"), - } - } - - /// A pane id is only unique within one daemon, so the same number on two - /// machines is not a collision. Deduping globally would make the remote - /// workspace forfeit a claim on a pane that is alive on its own box — - /// orphaning a live session over a conflict that never existed. - #[test] - fn dedupe_pane_ids_is_scoped_to_one_machine() { - let mut local = workspace(vec![tab(leaf(Some("/local"), Some(1)), None)]); - local.last_active = 200; - let mut remote = workspace(vec![tab(leaf(Some("/remote"), Some(1)), None)]); - remote.last_active = 100; // older, so a global dedupe would drop *this* one - remote.host = Some(RemoteRef { - target: RemoteTarget::Alias { - alias: "build-box".into(), - }, - workspace: WorkspaceId::new(), - }); - let (local_id, remote_id) = (local.id, remote.id); - - let mut all = Workspaces { - active: Some(local_id), - workspaces: vec![local, remote], - }; - assert_eq!(all.dedupe_pane_ids(), 0, "different machines never collide"); - assert_eq!(all.get(local_id).unwrap().pane_ids(), vec![1]); - assert_eq!( - all.get(remote_id).unwrap().pane_ids(), - vec![1], - "the remote keeps its claim on its own daemon's pane 1" - ); - - // …and two workspaces on the *same* remote machine still dedupe. - let host = RemoteRef { - target: RemoteTarget::Alias { - alias: "build-box".into(), - }, - workspace: WorkspaceId::new(), - }; - let mut older = workspace(vec![tab(leaf(Some("/a"), Some(7)), None)]); - older.last_active = 100; - older.host = Some(host.clone()); - let mut newer = workspace(vec![tab(leaf(Some("/b"), Some(7)), None)]); - newer.last_active = 200; - newer.host = Some(host); - let (older_id, newer_id) = (older.id, newer.id); - - let mut same_box = Workspaces { - active: Some(newer_id), - workspaces: vec![older, newer], - }; - assert_eq!(same_box.dedupe_pane_ids(), 1); - assert_eq!(same_box.get(newer_id).unwrap().pane_ids(), vec![7]); - assert!(same_box.get(older_id).unwrap().pane_ids().is_empty()); - } - - #[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]); - } - - // ── Remote workspaces (M5) ────────────────────────────────────────────── - - /// A real-shaped `session.json` from before `host` existed, written by the - /// build that shipped multi-window. **The hard requirement of the whole - /// field**: every workspace in it is local, and every derived answer is - /// exactly what it was — an upgrading user's file must not acquire a - /// meaning it did not have. - const LEGACY_SESSION_JSON: &str = r#"{ - "workspaces": [ - { - "id": "6a8f2a1e-1c1b-4f7a-9d3e-2b5c8e4a7f01", - "name": "tty7", - "session": { - "active": 1, - "tabs": [ - { - "name": "build", - "pane": {"Leaf": {"cwd": "/Users/me/repo/tty7", "pane_id": 41}}, - "sidebar_group": "/Users/me/repo/tty7" - }, - { - "name": null, - "pane": {"Split": { - "axis": "Vertical", - "ratio": 0.35, - "a": {"Leaf": {"cwd": "/Users/me/repo/tty7/src", "pane_id": 42, - "agent": "Claude", "agent_session_id": "s-9"}}, - "b": {"Leaf": {"cwd": "/Users/me/repo/tty7", "pane_id": 43}} - }}, - "sidebar_group": "/Users/me/repo/tty7" - } - ] - }, - "window": {"x": 120.0, "y": 64.0, "width": 1440.0, "height": 900.0}, - "open": true, - "last_active": 1753600000 - }, - { - "id": "7b9e3b2f-2d2c-4a8b-8e4f-3c6d9f5b8a12", - "session": {"active": 0, "tabs": [ - {"pane": {"Leaf": {"cwd": "/Users/me/scratch"}}} - ]}, - "open": false, - "last_active": 1753500000 - } - ], - "active": "6a8f2a1e-1c1b-4f7a-9d3e-2b5c8e4a7f01" - }"#; - - #[test] - fn an_old_session_json_is_all_local_and_behaves_identically() { - let loaded = Workspaces::decode(LEGACY_SESSION_JSON).expect("an old session must decode"); - assert_eq!(loaded.workspaces.len(), 2); - - for ws in &loaded.workspaces { - assert!(ws.host.is_none(), "a file without `host` decodes as local"); - assert!(!ws.is_remote()); - assert_eq!( - ws.host_id(), - crate::host::HostId::LOCAL, - "no `host` must mean this machine, not a derived id" - ); - } - - // Every derived answer is what the pre-`host` build gave. - let first = &loaded.workspaces[0]; - assert_eq!(first.display_name(), "tty7"); - assert_eq!(first.pane_ids(), vec![41, 42, 43]); - assert_eq!(first.pane_count(), 3); - assert_eq!( - first.dominant_repo(), - Some(PathBuf::from("/Users/me/repo/tty7")) - ); - assert!(first.open); - assert_eq!(first.last_active, 1_753_600_000); - assert!(first.window.is_some()); - assert_eq!(loaded.workspaces[1].display_name(), "scratch"); - assert!(!loaded.workspaces[1].open); - assert_eq!(loaded.active, Some(loaded.workspaces[0].id)); - - // And writing it back does not add a `host` key: a local workspace's - // serialization is byte-for-byte what it always was, so downgrading to - // an older build is not a one-way door either. - let text = serde_json::to_string(&loaded).unwrap(); - assert!(!text.contains("\"host\""), "{text}"); - // Re-decoding the round trip changes nothing. - let again = Workspaces::decode(&text).unwrap(); - assert_eq!(again.workspaces[0].pane_ids(), vec![41, 42, 43]); - assert!(again.workspaces.iter().all(|w| !w.is_remote())); - } - - /// The legacy flat `{active, tabs}` shape — two formats older — migrates to - /// a local workspace too, not to one with a phantom host. - #[test] - fn the_pre_multi_window_migration_is_local() { - let loaded = - Workspaces::decode(r#"{"active":0,"tabs":[{"pane":{"Leaf":{"cwd":"/w"}}}]}"#).unwrap(); - assert!(!loaded.workspaces[0].is_remote()); - assert_eq!(loaded.workspaces[0].host_id(), crate::host::HostId::LOCAL); - } + // ── Remote references ─────────────────────────────────────────────────── /// The four key formats of the connection key, verbatim. These strings are a /// wire contract in all but name: change one and every workspace on that @@ -1725,8 +749,8 @@ mod tests { } /// The dev-only `--stdio` target is a *machine*, not a variation on local: - /// its key is distinct, its id is not [`HostId::LOCAL`], and two different - /// server binaries are two different machines. + /// its key is distinct, its id is not [`HostId::LOCAL`](crate::host::HostId::LOCAL), + /// and two different server binaries are two different machines. /// /// That last part matters because everything keyed by `HostId` — the /// connection pool, the git-status cache, the auth queue — would otherwise @@ -1753,14 +777,14 @@ mod tests { } /// The granularity the connection pool depends on: one box, one id, however - /// many workspaces — and never [`HostId::LOCAL`]. + /// many workspaces — and never `HostId::LOCAL`. #[test] - fn workspaces_on_one_box_share_a_host_id() { + fn views_on_one_box_share_a_host_id() { let target = RemoteTarget::Alias { alias: "devbox".into(), }; - let a = Workspace::on_remote(RemoteRef::new(target.clone(), WorkspaceId::new())); - let b = Workspace::on_remote(RemoteRef::new(target.clone(), WorkspaceId::new())); + let a = WindowView::on_remote(RemoteRef::new(target.clone(), WorkspaceId::new())); + let b = WindowView::on_remote(RemoteRef::new(target.clone(), WorkspaceId::new())); assert_ne!( a.host.as_ref().unwrap().workspace, b.host.as_ref().unwrap().workspace @@ -1769,204 +793,34 @@ mod tests { assert!(!a.host_id().is_local()); // A different machine is a different id. - let other = Workspace::on_remote(RemoteRef::new( - RemoteTarget::Alias { - alias: "other".into(), - }, - WorkspaceId::new(), - )); + let other = remote_view("other"); assert_ne!(a.host_id(), other.host_id()); - // And a remote entry starts with no layout: the remote owns it. - assert!(a.session.tabs.is_empty()); + // And the local shape answers LOCAL, with nothing derived. + assert_eq!(view().host_id(), crate::host::HostId::LOCAL); assert_eq!( a.host.as_ref().unwrap().store_key(), a.host.as_ref().unwrap().workspace.to_string() ); } - #[test] - fn a_remote_workspace_survives_a_restart() { - let remote_id = WorkspaceId::new(); - let mut ws = Workspace::on_remote(RemoteRef::new( - RemoteTarget::direct("me", "box.local", 2222), - remote_id, - )); - ws.name = Some("api".into()); - ws.open = false; - let all = Workspaces { - active: None, - workspaces: vec![ws], - }; - let text = serde_json::to_string(&all).unwrap(); - let back = Workspaces::decode(&text).unwrap(); - let only = &back.workspaces[0]; - assert!(only.is_remote()); - let host = only.host.as_ref().unwrap(); - assert_eq!(host.workspace, remote_id); - assert_eq!(host.target, RemoteTarget::direct("me", "box.local", 2222)); - assert_eq!(host.target.connection_key(), "ssh-direct:me@box.local:2222"); - } - - /// Every `Workspace` field belongs to exactly one side of the storage - /// split. A new field that is in neither list would be silently dropped by - /// `to_remote_json` and lost on the next pull, which is data loss that no - /// other test would notice. - #[test] - fn the_storage_split_covers_every_workspace_field() { - let mut ws = workspace(vec![tab(leaf(Some("/w"), Some(1)), Some("/w"))]); - ws.name = Some("named".into()); - ws.window = Some(crate::core::window_state::WindowState { - x: 0.0, - y: 0.0, - width: 800.0, - height: 600.0, - }); - ws.host = Some(RemoteRef::new( - RemoteTarget::Alias { - alias: "devbox".into(), - }, - WorkspaceId::new(), - )); - // Every skip-when-`None` field must be populated here, or it never - // serializes and this census can't see it. - ws.daemon_instance = Some("daemon-uuid".into()); - - let value = serde_json::to_value(&ws).unwrap(); - let mut present: Vec = value - .as_object() - .unwrap() - .keys() - .map(String::from) - .collect(); - present.sort(); - let mut expected: Vec = REMOTE_OWNED_FIELDS - .iter() - .chain(CLIENT_OWNED_FIELDS) - .map(|s| (*s).to_string()) - .collect(); - expected.sort(); - assert_eq!( - present, expected, - "a Workspace field is on neither side of the storage split; decide which \ - machine owns it and add it to REMOTE_OWNED_FIELDS or CLIENT_OWNED_FIELDS" - ); - } + // ── Launch ────────────────────────────────────────────────────────────── #[test] - fn the_remote_record_carries_the_layout_and_nothing_local() { - let mut ws = workspace(vec![tab(leaf(Some("/srv/app"), Some(7)), Some("/srv/app"))]); - ws.name = Some("app".into()); - ws.last_active = 1_753_600_000; - ws.open = true; - ws.window = Some(crate::core::window_state::WindowState { - x: 1.0, - y: 2.0, - width: 800.0, - height: 600.0, - }); - ws.host = Some(RemoteRef::new( - RemoteTarget::Alias { - alias: "devbox".into(), - }, - WorkspaceId::new(), - )); - - let record = ws.to_remote_json(); - let obj = record.as_object().unwrap(); - // The machine's facts go over. - assert!(obj.contains_key("session")); - assert_eq!(obj["name"], "app"); - assert_eq!(obj["last_active"], 1_753_600_000u64); - assert_eq!(obj["id"], ws.id.to_string()); - // This client's view state does not — the point of the split. - for k in CLIENT_OWNED_FIELDS { - assert!(!obj.contains_key(*k), "`{k}` must not leave this machine"); - } - - // Pulling it back onto a *different* client's entry updates the layout - // and leaves that client's own view state alone. - let mut mine = Workspace::on_remote(RemoteRef::new( - RemoteTarget::Alias { - alias: "devbox".into(), - }, - ws.id, - )); - mine.open = false; - mine.window = None; - let my_id = mine.id; - mine.apply_remote_json(&record).unwrap(); - assert_eq!(mine.session.tabs.len(), 1); - assert_eq!(mine.name.as_deref(), Some("app")); - assert_eq!(mine.last_active, 1_753_600_000); - assert_eq!( - mine.id, my_id, - "the client's own entry id is not overwritten" - ); - assert!( - !mine.open, - "the remote has no opinion about my open windows" - ); - assert!(mine.window.is_none()); - assert!(mine.is_remote(), "and it is still a remote workspace"); - } - - /// A record from a newer client carries fields this build has never seen, - /// and one from an older client is missing fields it expects. Neither may - /// fail the pull. - #[test] - fn applying_a_record_tolerates_version_skew() { - let mut ws = Workspace::default(); - ws.apply_remote_json(&serde_json::json!({ - "id": "6a8f2a1e-1c1b-4f7a-9d3e-2b5c8e4a7f01", - "session": {"active": 0, "tabs": []}, - "last_active": 5, - "something_from_2027": {"nested": true} - })) - .expect("unknown fields are ignored, not fatal"); - assert_eq!(ws.last_active, 5); - - let mut ws = Workspace { - name: Some("stale".into()), - ..Workspace::default() - }; - ws.apply_remote_json(&serde_json::json!({})) - .expect("a record missing every optional field still applies"); - assert_eq!( - ws.name, None, - "the remote's answer wins, including 'no name'" - ); - assert!(ws.session.tabs.is_empty()); - } - - #[test] - fn open_and_closed_partition_by_flag_and_recency() { - let mut open_one = workspace(vec![]); + fn open_views_partition_by_flag() { + let mut open_one = view(); 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 { + let mut closed = view(); + closed.open = false; + let open_id = open_one.id; + let all = WindowViews { active: None, - workspaces: vec![open_one, older, newer], + views: vec![open_one, closed], }; assert_eq!( - all.open_workspaces().map(|w| w.id).collect::>(), + all.open_views().map(|w| w.id).collect::>(), vec![open_id] ); - // The picker lists most-recently-active first. - assert_eq!( - all.closed_workspaces() - .iter() - .map(|w| w.id) - .collect::>(), - vec![newer_id, older_id] - ); } /// Launch restores exactly one window, and it is the one the user was in. @@ -1977,28 +831,28 @@ mod tests { /// without anybody looking at it). #[test] fn launch_restores_the_focused_workspace_not_the_most_recently_touched() { - let mut focused = workspace(vec![]); + let mut focused = view(); focused.open = true; focused.last_active = 100; - let mut busier = workspace(vec![]); + let mut busier = view(); busier.open = true; busier.last_active = 900; let (focused_id, busier_id) = (focused.id, busier.id); - let all = Workspaces { + let all = WindowViews { active: Some(focused_id), - workspaces: vec![focused, busier], + views: vec![focused, busier], }; assert_eq!(all.workspace_to_restore(), Some(focused_id)); assert_eq!( - all.open_workspaces().count(), + all.open_views().count(), 2, "the others stay open in the store — launch detaches them, this does not" ); // No focus recorded (or it named a workspace that was closed first): // recency is the fallback, not a coin toss. - let all = Workspaces { + let all = WindowViews { active: None, ..all }; @@ -2006,25 +860,25 @@ mod tests { // `active` pointing at a *detached* workspace must not resurrect it — // the user closed that window on purpose. - let mut closed = workspace(vec![]); + let mut closed = view(); closed.open = false; let closed_id = closed.id; - let mut open_one = workspace(vec![]); + let mut open_one = view(); open_one.open = true; let open_id = open_one.id; - let all = Workspaces { + let all = WindowViews { active: Some(closed_id), - workspaces: vec![closed, open_one], + views: vec![closed, open_one], }; assert_eq!(all.workspace_to_restore(), Some(open_id)); // Nothing open at all: launch has no workspace to come up on and shows // the home page instead of inventing one. - let mut none_open = workspace(vec![]); + let mut none_open = view(); none_open.open = false; - let all = Workspaces { + let all = WindowViews { active: None, - workspaces: vec![none_open], + views: vec![none_open], }; assert_eq!(all.workspace_to_restore(), None); } diff --git a/crates/tty7-core/src/core/window_state.rs b/crates/tty7-core/src/core/window_state.rs index 29b07a76..a24775dd 100644 --- a/crates/tty7-core/src/core/window_state.rs +++ b/crates/tty7-core/src/core/window_state.rs @@ -1,5 +1,5 @@ //! Persisted last-window geometry, stored at `window.json` in the config dir -//! (alongside `config.json` / `session.json`). The quit hook in `ui::app` +//! (alongside `config.json` / `views.json`). The quit hook in `ui::app` //! writes the window's final bounds here unconditionally; startup reads it //! back only when `Config::remember_window_size` is on, so toggling the //! setting off and on again still restores the most recent quit's geometry. @@ -7,9 +7,9 @@ //! reads fall back to "nothing remembered", writes are atomic. //! //! The geometry is four plain `f32`s here rather than a `gpui::Bounds` because -//! [`Workspace`](super::session::Workspace) embeds it and `session.json` has to -//! parse without gpui. Converting to and from `Bounds` is the GUI crate's job — -//! see its `core::window_state::WindowGeometry` extension trait. +//! [`WindowView`](super::session::WindowView) embeds it and `views.json` is +//! parsed in this gpui-free crate. Converting to and from `Bounds` is the GUI +//! crate's job — see its `core::window_state::WindowGeometry` extension trait. use serde::{Deserialize, Serialize}; diff --git a/crates/tty7-core/src/core/workspace_store.rs b/crates/tty7-core/src/core/workspace_store.rs deleted file mode 100644 index c4ad6f11..00000000 --- a/crates/tty7-core/src/core/workspace_store.rs +++ /dev/null @@ -1,1191 +0,0 @@ -//! The **remote** side of the storage split: the machine's own -//! `~/.local/share/tty7/workspaces.json`, and the one writer to it. -//! -//! # Which half of the split this is -//! -//! | Lives | Holds | Because | -//! |---|---|---| -//! | **Here**, on the machine the panes run on | The workspace list and names, the tab/pane tree, each pane's cwd / `pane_id` / agent, `last_active` | Connect from another laptop and you must see the same thing. This is a fact about the machine | -//! | The **client**'s `session.json` | Which host's which workspaces this client has opened, window geometry, the `open` flag | It is *this client's* view state. Closing a window at the office must not hide the workspace from the laptop at home | -//! -//! [`Workspace::to_remote_json`](crate::core::session::Workspace::to_remote_json) -//! is the client's half of that contract; this module is the server's. -//! -//! # Records are opaque on purpose -//! -//! A record is a [`serde_json::Value`], not a parsed -//! [`Workspace`](crate::core::session::Workspace). The server is a store, not a -//! participant: the client owns the schema, and a client newer than the server -//! it is talking to is the *normal* case (the server is installed once and then -//! left alone for months, auto-install notwithstanding). Parsing here -//! would mean a field the server has never heard of is dropped on the next -//! write — silent data loss whose only symptom is a setting that will not -//! stick. -//! -//! What the store does insist on is the shape it has to index by: a record is a -//! JSON object, and its `id` agrees with the key it was filed under. Those two -//! are what keep the file's array parseable as -//! [`Workspaces`](crate::core::session::Workspaces) by anything that wants the -//! typed view. -//! -//! # Concurrency -//! -//! Several control connections can be writing at once — two of the user's own -//! machines, or one machine reconnecting while the old link has not yet -//! noticed. One mutex covers the record list *and* the file write, so the -//! on-disk order is the in-memory order and no interleaving can produce a file -//! that never existed as a state. The write is atomic -//! ([`write_atomic`](crate::core::config::write_atomic)), so a crash mid-save -//! leaves the old file rather than half of the new one, and a write that fails -//! rolls the memory back rather than leaving the two out of step. -//! -//! Change notifications ([`WorkspaceStore::subscribe`]) are delivered -//! **outside** the lock, and the server's callback only enqueues — a peer that -//! has stopped reading its socket must not be able to stall another peer's -//! `WorkspacePut`. - -use std::io; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{Arc, Mutex}; - -use serde::{Deserialize, Serialize}; -use serde_json::Value; - -/// The file's name under the data directory. -pub const STORE_FILE: &str = "workspaces.json"; - -/// Overrides where the store lives. Set by tests and by a second server on a -/// shared box — the same escape hatch -/// [`CONTROL_SOCK_ENV`](crate::host::server::CONTROL_SOCK_ENV) is for the -/// socket. -pub const DATA_DIR_ENV: &str = "TTY7_DATA_DIR"; - -/// Ceiling on one record. A workspace with a hundred tabs is a few tens of -/// kilobytes; this is four megabytes, so it only ever catches a client that has -/// gone wrong. Without it a single `WorkspacePut` could pin the file — and the -/// memory holding it — at the 64 MiB frame limit. -pub const MAX_RECORD_BYTES: usize = 4 * 1024 * 1024; - -/// Ceiling on records. Same reasoning one level up: a user has tens of -/// workspaces, and a client looping on "create workspace" should hit a named -/// error rather than grow the file until the disk fills. -pub const MAX_WORKSPACES: usize = 1024; - -/// Ceiling on the whole document, which is what a single `WorkspaceList` reply -/// has to fit into. -/// -/// The per-record and per-count ceilings above are independent of each other, -/// and their product is 4 GiB — sixty-four times the frame limit. Seventeen -/// accepted `WorkspacePut`s of a maximal record are enough to put the array -/// past it, and from then on *every* `WorkspaceList` on the machine is a reply -/// that cannot be encoded: every client shows an empty workspace list, and the -/// only repair is editing the file by hand. So the total is bounded where it is -/// actually known — at the save — with room to spare under -/// [`MAX_FRAME`](crate::daemon::protocol::MAX_FRAME), since what is measured -/// here is the pretty-printed form and the wire carries the compact one. -pub const MAX_STORE_BYTES: usize = 32 * 1024 * 1024; - -/// Ceiling on a record key, which is a workspace uuid in every non-hostile -/// case. -const MAX_ID_BYTES: usize = 128; - -// --------------------------------------------------------------------------- -// Attachment bookkeeping (the data half of M6's takeover) -// --------------------------------------------------------------------------- - -/// Who is currently attached to a workspace. -/// -/// **Data only.** The takeover — push `Preempted { by }` to the old -/// session, close its streams, offer a [抢回] button — is M6's, and none of it -/// is here. What is here is the record that machinery needs to exist before it -/// can be written: the random token that tells two connections from the same -/// client apart, and the hostname that fills in "已在 <主机名> 上打开". Both -/// arrive in the [`ControlHello`](crate::daemon::control::ControlHello). -/// -/// **Never persisted.** An attachment describes a live connection; after a -/// server restart there are none, and a stale one on disk would make M6 report -/// a takeover against a client that no longer exists. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct Attachment { - /// The client's per-session random token, from `ControlHello::client_token`. - pub token: String, - /// The client machine's hostname, shown to the user in the preempted - /// window's status bar. - pub hostname: String, - /// Unix seconds when the attach happened. - pub since: u64, -} - -impl Attachment { - /// An attachment stamped now. - pub fn new(token: impl Into, hostname: impl Into) -> Attachment { - Attachment { - token: token.into(), - hostname: hostname.into(), - since: unix_now(), - } - } -} - -// --------------------------------------------------------------------------- -// Subscriptions -// --------------------------------------------------------------------------- - -/// Identifies one subscriber, so a writer can be told apart from the clients it -/// is notifying. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] -pub struct SubscriberId(pub u64); - -/// What a subscriber is told: the id of the workspace that changed. -/// -/// Deliberately not the new contents. The event is a hint to refetch, so a -/// client that missed three of them is in the same state as one that saw all -/// three — which is what makes dropping a notification safe when a peer is -/// behind. -pub type Notify = Arc; - -/// A live subscription. Dropping it unsubscribes, so a connection's teardown -/// cannot leave a callback pointing at a sink nobody is reading. -pub struct Subscription { - store: Arc, - id: SubscriberId, -} - -impl Subscription { - /// This subscriber's id — pass it as the `origin` of your own writes so you - /// are not told about changes you made yourself. - pub fn id(&self) -> SubscriberId { - self.id - } -} - -impl Drop for Subscription { - fn drop(&mut self) { - self.store.unsubscribe(self.id); - } -} - -// --------------------------------------------------------------------------- -// The store -// --------------------------------------------------------------------------- - -/// The machine's workspace records, and the file they are persisted to. -pub struct WorkspaceStore { - path: PathBuf, - state: Mutex, - /// Separate from `state` on purpose: attaching is not a change to the - /// layout, does not write the file, and must not queue behind one. - attachments: Mutex>, - subscribers: Mutex>, - next_subscriber: AtomicU64, -} - -struct State { - /// Insertion-ordered `(id, record)`. A `Vec` rather than a `HashMap` - /// because the file's array order is what a client lists, and a hash map - /// would reshuffle the picker on every save for no reason. - records: Vec<(String, Value)>, - /// `(mtime, len)` of the file as this snapshot last saw it, or `None` when - /// there was no file. - /// - /// This store is not always the only writer. The design's answer is one - /// server per machine, and `tty7-server --stdio` now starts the daemon - /// rather than serving in-process for exactly that reason — but an explicit - /// `--serve`, or a daemon that could not be started, still leaves two - /// processes over one file. `persist` writes the *whole* document, so - /// without noticing that the file moved underneath it, the second to save - /// silently drops everything the first did. - stamp: Option<(std::time::SystemTime, u64)>, -} - -/// The file's identity as far as [`State::stamp`] is concerned. -fn stamp_of(path: &Path) -> Option<(std::time::SystemTime, u64)> { - let meta = std::fs::metadata(path).ok()?; - Some((meta.modified().ok()?, meta.len())) -} - -impl WorkspaceStore { - /// Open the store at `path`, reading whatever is there. - /// - /// Infallible by design, exactly like - /// [`Workspaces::load`](crate::core::session::Workspaces::load): a machine - /// whose workspace file is missing or unreadable must still serve files and - /// panes. A file that does not parse is copied aside as - /// `workspaces.json.corrupt` before anything can overwrite it, so "the - /// store came up empty" is recoverable by hand rather than terminal. - pub fn open(path: impl Into) -> Arc { - let path = path.into(); - let records = load_records(&path); - let stamp = stamp_of(&path); - Arc::new(WorkspaceStore { - state: Mutex::new(State { records, stamp }), - path, - attachments: Mutex::new(Vec::new()), - subscribers: Mutex::new(Vec::new()), - next_subscriber: AtomicU64::new(1), - }) - } - - /// Open the store at [`default_store_path`]. - pub fn shared() -> io::Result> { - Ok(WorkspaceStore::open(default_store_path()?)) - } - - /// Where this store is persisted. - pub fn path(&self) -> &Path { - &self.path - } - - // ----- reads ----------------------------------------------------------- - - /// Every record, in file order. Answers - /// [`WorkspaceList`](crate::daemon::control::ControlRequest::WorkspaceList). - pub fn list(&self) -> Vec { - self.locked() - .records - .iter() - .map(|(_, v)| v.clone()) - .collect() - } - - /// One record. `None` means no such workspace, which the server turns into - /// a `NotFound` — distinguishable from a workspace that exists and is - /// empty, which a `null` payload would not be. - pub fn get(&self, id: &str) -> Option { - self.locked() - .records - .iter() - .find(|(k, _)| k == id) - .map(|(_, v)| v.clone()) - } - - /// How many records are on file. - pub fn len(&self) -> usize { - self.locked().records.len() - } - - pub fn is_empty(&self) -> bool { - self.len() == 0 - } - - // ----- writes ---------------------------------------------------------- - - /// File `record` under `id`, replacing any record already there, and - /// persist. - /// - /// `origin` is the subscriber that asked for the change, so it is not - /// notified of its own write; `None` notifies everyone. - /// - /// The record's `id` field, if present, must agree with `id` — a mismatch - /// would put the file's typed view at odds with the store's key, and the - /// next client to read the array would see a workspace under the wrong - /// identity. When absent it is filled in, so a client that only sent the - /// body still produces a well-formed file. - pub fn put(&self, id: &str, mut record: Value, origin: Option) -> io::Result<()> { - check_id(id)?; - let obj = record.as_object_mut().ok_or_else(|| { - io::Error::new( - io::ErrorKind::InvalidInput, - "a workspace record must be a JSON object", - ) - })?; - match obj.get("id") { - Some(Value::String(existing)) if existing == id => {} - Some(other) => { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - format!("workspace record carries id {other} but was filed under {id}"), - )); - } - None => { - obj.insert("id".to_string(), Value::String(id.to_string())); - } - } - - let encoded = serde_json::to_vec(&record).map_err(io::Error::other)?; - if encoded.len() > MAX_RECORD_BYTES { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - format!( - "workspace record is {} bytes; the limit is {MAX_RECORD_BYTES}", - encoded.len() - ), - )); - } - - { - let mut st = self.locked(); - let existing = st.records.iter().position(|(k, _)| k == id); - if existing.is_none() && st.records.len() >= MAX_WORKSPACES { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - format!("this machine already holds {MAX_WORKSPACES} workspaces"), - )); - } - - // Mutate, persist, and undo precisely if the disk said no — the - // in-memory state is what every later read answers from, so it must - // never claim something the file does not. - let undo = match existing { - Some(i) => Undo::Restore(i, std::mem::replace(&mut st.records[i].1, record)), - None => { - st.records.push((id.to_string(), record)); - Undo::Remove(st.records.len() - 1) - } - }; - if let Err(e) = self.persist(&st, true) { - match undo { - Undo::Restore(i, old) => st.records[i].1 = old, - Undo::Remove(i) => { - st.records.remove(i); - } - } - return Err(e); - } - self.restamp(&mut st); - } - - self.notify(id, origin); - Ok(()) - } - - /// Forget a workspace. `false` means there was nothing to forget, which is - /// still success: a delete that raced another client's delete has got what - /// it asked for, and reporting an error would make the client retry - /// something already done. - pub fn delete(&self, id: &str, origin: Option) -> io::Result { - check_id(id)?; - { - let mut st = self.locked(); - let Some(i) = st.records.iter().position(|(k, _)| k == id) else { - return Ok(false); - }; - let removed = st.records.remove(i); - if let Err(e) = self.persist(&st, false) { - st.records.insert(i, removed); - return Err(e); - } - self.restamp(&mut st); - } - // The attachment goes with it: nothing can be attached to a workspace - // that no longer exists, and leaving the entry would have M6 report a - // takeover against a ghost. - self.attachments_locked().retain(|(k, _)| k != id); - self.notify(id, origin); - Ok(true) - } - - // ----- attachment (M6's data, not M6's behaviour) ---------------------- - - /// Record `who` as the workspace's current session and answer whoever held - /// it before. - /// - /// The previous holder is **the thing M6 acts on**: a `Some` return is - /// exactly the takeover case, and the caller is the one that pushes - /// `Preempted { by }` and closes the old streams. This function does - /// neither — it only makes the fact available. - pub fn attach(&self, workspace: &str, who: Attachment) -> Option { - let mut slots = self.attachments_locked(); - match slots.iter_mut().find(|(k, _)| k == workspace) { - Some((_, current)) => Some(std::mem::replace(current, who)), - None => { - slots.push((workspace.to_string(), who)); - None - } - } - } - - /// Who is attached to `workspace`, if anyone. - pub fn attachment(&self, workspace: &str) -> Option { - self.attachments_locked() - .iter() - .find(|(k, _)| k == workspace) - .map(|(_, a)| a.clone()) - } - - /// Release `workspace`, but **only if `token` still holds it**. - /// - /// The token check is the whole point. A preempted client tears its - /// connection down *after* the new one has attached, and an unconditional - /// release would have that teardown evict the client that just took over — - /// leaving the workspace looking free while a live window is on it. - pub fn detach(&self, workspace: &str, token: &str) -> bool { - let mut slots = self.attachments_locked(); - let before = slots.len(); - slots.retain(|(k, a)| !(k == workspace && a.token == token)); - slots.len() != before - } - - /// Every live attachment, for diagnostics. - pub fn attachments(&self) -> Vec<(String, Attachment)> { - self.attachments_locked().clone() - } - - // ----- change notification --------------------------------------------- - - /// Be told when a record changes. Dropping the returned [`Subscription`] - /// unsubscribes. - /// - /// `f` **must not block**: it runs on the thread of whichever connection - /// made the change, so a callback that waited on a slow peer's socket would - /// let one stalled client hold up everyone else's writes. The control - /// server's callback enqueues onto a bounded channel and returns. - pub fn subscribe(self: &Arc, f: Notify) -> Subscription { - let id = SubscriberId(self.next_subscriber.fetch_add(1, Ordering::Relaxed)); - self.subscribers - .lock() - .unwrap_or_else(|e| e.into_inner()) - .push((id, f)); - Subscription { - store: Arc::clone(self), - id, - } - } - - fn unsubscribe(&self, id: SubscriberId) { - self.subscribers - .lock() - .unwrap_or_else(|e| e.into_inner()) - .retain(|(sid, _)| *sid != id); - } - - /// Fan a change out, skipping the subscriber that caused it. - /// - /// Called with no lock held: a callback is other people's code, and holding - /// the store's mutex across it would make every future write hostage to it. - fn notify(&self, id: &str, origin: Option) { - let subscribers: Vec<(SubscriberId, Notify)> = self - .subscribers - .lock() - .unwrap_or_else(|e| e.into_inner()) - .clone(); - for (sid, f) in subscribers { - if Some(sid) != origin { - f(id); - } - } - } - - // ----- internals ------------------------------------------------------- - - fn locked(&self) -> std::sync::MutexGuard<'_, State> { - // A poisoned lock means a panic between a mutation and its write. The - // in-memory state is still a valid state (the undo path restores it - // before returning) and the file is either the old or the new one, so - // carrying on is strictly better than taking the server down. - let mut st = self.state.lock().unwrap_or_else(|e| e.into_inner()); - - // Re-read when the file moved under us. Cheap — one `stat` — and it is - // what keeps a second writer's changes from being overwritten by this - // store's whole-document save, since the base we mutate is then theirs - // rather than a snapshot from before their write. It also lets a read - // see their changes at all: `notify` reaches subscribers in *this* - // process only. - let on_disk = stamp_of(&self.path); - if on_disk != st.stamp { - log::debug!( - "{} changed underneath this store; re-reading", - self.path.display() - ); - st.records = load_records(&self.path); - st.stamp = on_disk; - } - st - } - - fn attachments_locked(&self) -> std::sync::MutexGuard<'_, Vec<(String, Attachment)>> { - self.attachments.lock().unwrap_or_else(|e| e.into_inner()) - } - - /// Serialize the whole file and replace it atomically. - /// - /// The document is `{"workspaces": [...]}` — the identical shape - /// [`Workspaces`](crate::core::session::Workspaces) parses, so this file is - /// readable by the same code that reads a client's `session.json` and a - /// human can diff the two. - /// Write the whole document. - /// - /// `bounded` asks for [`MAX_STORE_BYTES`] to be enforced. Set by the paths - /// that *grow* the file and clear by the ones that shrink it: a store that - /// came up holding an over-large file — written by an older build, or by - /// hand — must still be able to delete its way back under the limit rather - /// than refusing every operation including the repair. - fn persist(&self, st: &State, bounded: bool) -> io::Result<()> { - #[derive(Serialize)] - struct Doc<'a> { - workspaces: Vec<&'a Value>, - } - let doc = Doc { - workspaces: st.records.iter().map(|(_, v)| v).collect(), - }; - let bytes = serde_json::to_vec_pretty(&doc).map_err(io::Error::other)?; - if bounded && bytes.len() > MAX_STORE_BYTES { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - format!( - "the workspace store would be {} bytes; the limit is {MAX_STORE_BYTES}, \ - which is what one WorkspaceList reply has to fit into", - bytes.len() - ), - )); - } - if let Some(parent) = self.path.parent() { - std::fs::create_dir_all(parent)?; - } - crate::core::config::write_atomic(&self.path, &bytes) - } - - /// Record the file's identity after this store wrote it, so the next - /// [`WorkspaceStore::locked`] does not mistake its own save for someone - /// else's and re-read it. - fn restamp(&self, st: &mut State) { - st.stamp = stamp_of(&self.path); - } -} - -/// How to undo a mutation whose write failed. -enum Undo { - Restore(usize, Value), - Remove(usize), -} - -/// A key has to be something that can key a JSON object and appear in a log -/// line. It is never used to build a path, so this is a sanity check rather -/// than a security boundary. -fn check_id(id: &str) -> io::Result<()> { - if id.is_empty() { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "a workspace id must not be empty", - )); - } - if id.len() > MAX_ID_BYTES { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - format!("a workspace id must be at most {MAX_ID_BYTES} bytes"), - )); - } - if id.chars().any(char::is_control) { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "a workspace id must not contain control characters", - )); - } - Ok(()) -} - -/// Read the file, keeping whatever is well-formed. -/// -/// One unparseable *record* costs that record, not the file: a client that -/// wrote something odd should not make the user's other twelve workspaces -/// disappear. An unparseable *file* is quarantined and the store comes up -/// empty. -fn load_records(path: &Path) -> Vec<(String, Value)> { - let text = match std::fs::read_to_string(path) { - Ok(t) => t, - Err(e) if e.kind() == io::ErrorKind::NotFound => return Vec::new(), - Err(e) => { - log::warn!("could not read {}: {e}; starting empty", path.display()); - return Vec::new(); - } - }; - - let value: Value = match serde_json::from_str(crate::core::config::strip_bom(&text)) { - Ok(v) => v, - Err(e) => { - log::warn!("{} does not parse ({e}); quarantining it", path.display()); - quarantine(path); - return Vec::new(); - } - }; - let Some(array) = value.get("workspaces").and_then(Value::as_array) else { - log::warn!( - "{} has no `workspaces` array; quarantining it", - path.display() - ); - quarantine(path); - return Vec::new(); - }; - - let mut records: Vec<(String, Value)> = Vec::with_capacity(array.len()); - for record in array { - let Some(id) = record.get("id").and_then(Value::as_str) else { - log::warn!("dropping a workspace record with no string `id`"); - continue; - }; - if check_id(id).is_err() { - log::warn!("dropping a workspace record with an unusable id"); - continue; - } - if records.iter().any(|(k, _)| k == id) { - log::warn!("dropping a duplicate record for workspace {id}"); - continue; - } - records.push((id.to_string(), record.clone())); - } - records -} - -/// Copy a file we are about to stop honouring somewhere the user can find it. -/// Best effort: failing to make the backup is not a reason to refuse to start. -fn quarantine(path: &Path) { - let aside = path.with_extension("json.corrupt"); - match std::fs::copy(path, &aside) { - Ok(_) => log::warn!("the previous contents were kept at {}", aside.display()), - Err(e) => log::warn!("could not keep a copy at {}: {e}", aside.display()), - } -} - -/// `/workspaces.json`. -/// -/// | Order | Directory | Why | -/// |---|---|---| -/// | 1 | `$TTY7_DATA_DIR` | Explicit wins; how tests and a second server get their own file | -/// | 2 | `$XDG_DATA_HOME/tty7` | The location the design names, spelled the way XDG spells it | -/// | 3 | `$HOME/.local/share/tty7` | No `XDG_DATA_HOME` — the literal fallback path | -/// -/// Deliberately **not** under the config dir. `session.json` there is the -/// *client's* view state, and a box that is both someone's laptop and someone -/// else's remote must keep the two files apart or one role would overwrite the -/// other's idea of which workspaces exist. -pub fn default_store_path() -> io::Result { - Ok(data_dir()?.join(STORE_FILE)) -} - -fn data_dir() -> io::Result { - if let Some(explicit) = std::env::var_os(DATA_DIR_ENV).filter(|v| !v.is_empty()) { - return Ok(PathBuf::from(explicit)); - } - #[cfg(not(windows))] - let base = env_dir("XDG_DATA_HOME") - .or_else(|| env_dir("HOME").map(|h| h.join(".local").join("share"))); - #[cfg(windows)] - let base = env_dir("LOCALAPPDATA") - .or_else(|| env_dir("USERPROFILE").map(|h| h.join(".local").join("share"))); - - base.map(|b| b.join("tty7")).ok_or_else(|| { - io::Error::other(format!( - "no home directory to place {STORE_FILE} in; set {DATA_DIR_ENV}" - )) - }) -} - -fn env_dir(key: &str) -> Option { - std::env::var_os(key) - .filter(|v| !v.is_empty()) - .map(PathBuf::from) -} - -fn unix_now() -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::core::session::{Session, Workspace, WorkspaceId, Workspaces}; - use std::sync::atomic::AtomicUsize; - - fn store() -> (Arc, tempfile::TempDir) { - let dir = tempfile::TempDir::new().unwrap(); - let store = WorkspaceStore::open(dir.path().join(STORE_FILE)); - (store, dir) - } - - fn record(id: &str, name: &str) -> Value { - serde_json::json!({ - "id": id, - "name": name, - "session": {"active": 0, "tabs": [ - {"pane": {"Leaf": {"cwd": "/home/me/proj", "pane_id": 7}}} - ]}, - "last_active": 1_753_600_000u64, - }) - } - - /// Two stores over one file — an explicit `--serve` alongside a daemon, or - /// a daemon that could not be started — must not silently undo each other. - /// - /// `persist` writes the whole document, so a store that mutates a snapshot - /// taken before the other's write puts that stale snapshot back. This is - /// how a workspace rename made on the laptop vanishes the next time the - /// desktop reorders a tab, with nothing reported to either. - #[test] - fn a_second_writer_does_not_get_overwritten_by_a_stale_snapshot() { - let dir = tempfile::TempDir::new().unwrap(); - let path = dir.path().join(STORE_FILE); - let first = WorkspaceStore::open(&path); - let second = WorkspaceStore::open(&path); - - first.put("w1", record("w1", "one"), None).unwrap(); - first.put("w2", record("w2", "two"), None).unwrap(); - - // `second` last read the file when it was empty. It has to notice. - second - .put("w2", record("w2", "two, renamed"), None) - .unwrap(); - - let names: Vec = WorkspaceStore::open(&path) - .list() - .iter() - .map(|r| r["name"].as_str().unwrap_or_default().to_string()) - .collect(); - assert_eq!( - names, - ["one", "two, renamed"], - "the second writer's save dropped what the first had written" - ); - } - - /// The same, one layer down: a read sees another process's write, because - /// `notify` only ever reaches subscribers inside this process. - #[test] - fn a_read_sees_a_change_another_store_made_to_the_file() { - let dir = tempfile::TempDir::new().unwrap(); - let path = dir.path().join(STORE_FILE); - let reader = WorkspaceStore::open(&path); - let writer = WorkspaceStore::open(&path); - - assert!(reader.get("w1").is_none()); - writer.put("w1", record("w1", "one"), None).unwrap(); - assert_eq!( - reader.get("w1").map(|r| r["name"].clone()), - Some(serde_json::json!("one")), - "a read answered from a snapshot older than the file" - ); - } - - /// The per-record and per-count ceilings do not bound their product, so the - /// document is bounded where it is known — at the save. - /// - /// Past `MAX_FRAME` the store is not merely large, it is unreadable: every - /// `WorkspaceList` becomes a reply that cannot be encoded, so every client - /// shows an empty list and the only repair is editing the file by hand. - #[test] - fn a_put_that_would_outgrow_one_reply_is_refused_and_undone() { - let (store, _dir) = store(); - // Records big enough that a handful crosses the limit, and small enough - // that the test stays quick. - let chunk = "x".repeat(2 * 1024 * 1024); - let big = |id: &str| { - let mut r = record(id, "big"); - r["padding"] = Value::String(chunk.clone()); - r - }; - - let mut accepted = 0; - let refusal = loop { - let id = format!("w{accepted}"); - match store.put(&id, big(&id), None) { - Ok(()) => accepted += 1, - Err(e) => break e, - } - assert!(accepted < 64, "the total was never bounded"); - }; - assert_eq!(refusal.kind(), io::ErrorKind::InvalidInput); - assert!( - refusal.to_string().contains("WorkspaceList"), - "the refusal has to say what the limit is for: {refusal}" - ); - - // Refused, not half-applied: the record that did not fit is not in the - // store and is not in the file. - assert_eq!(store.len(), accepted); - assert!(store.get(&format!("w{accepted}")).is_none()); - assert_eq!(WorkspaceStore::open(store.path()).len(), accepted); - - // And a delete still works, so a store that came up over the limit can - // be repaired rather than being wedged. - assert!(store.delete("w0", None).unwrap()); - } - - // ── The basics ────────────────────────────────────────────────────────── - - #[test] - fn a_missing_file_is_an_empty_store_not_an_error() { - let (store, _dir) = store(); - assert!(store.is_empty()); - assert!(store.list().is_empty()); - assert_eq!(store.get("nope"), None); - // And deleting nothing is success, not an error. - assert!(!store.delete("nope", None).unwrap()); - } - - #[test] - fn put_get_list_delete_round_trip_through_the_file() { - let (store, dir) = store(); - store.put("a", record("a", "api"), None).unwrap(); - store.put("b", record("b", "web"), None).unwrap(); - assert_eq!(store.len(), 2); - assert_eq!(store.get("a").unwrap()["name"], "api"); - - // A second store over the same path sees it: the file is the authority, - // which is the entire reason this lives on the remote. - let reopened = WorkspaceStore::open(dir.path().join(STORE_FILE)); - assert_eq!(reopened.len(), 2); - assert_eq!(reopened.get("b").unwrap()["name"], "web"); - // File order is list order. - let names: Vec = reopened - .list() - .iter() - .map(|v| v["name"].as_str().unwrap().to_string()) - .collect(); - assert_eq!(names, vec!["api", "web"]); - - assert!(store.delete("a", None).unwrap()); - assert_eq!( - WorkspaceStore::open(dir.path().join(STORE_FILE)).len(), - 1, - "a delete has to reach the disk, not just the map" - ); - } - - #[test] - fn replacing_a_record_keeps_its_place_in_the_list() { - let (store, _dir) = store(); - for id in ["a", "b", "c"] { - store.put(id, record(id, id), None).unwrap(); - } - store.put("a", record("a", "renamed"), None).unwrap(); - let ids: Vec = store - .list() - .iter() - .map(|v| v["id"].as_str().unwrap().to_string()) - .collect(); - assert_eq!(ids, vec!["a", "b", "c"], "a rename must not reshuffle"); - assert_eq!(store.get("a").unwrap()["name"], "renamed"); - } - - /// The file the store writes is the one `Workspaces` parses. That is what - /// "换台电脑连过来要看到同一份" means concretely — the record a client puts - /// comes back as the same `Workspace` on the next machine. - #[test] - fn the_file_is_a_workspaces_document() { - let (store, dir) = store(); - let mut ws = Workspace::from_session(Session::default()); - ws.name = Some("api".into()); - ws.last_active = 1_753_600_000; - let id = ws.id.to_string(); - store.put(&id, ws.to_remote_json(), None).unwrap(); - - let text = std::fs::read_to_string(dir.path().join(STORE_FILE)).unwrap(); - let parsed = Workspaces::decode(&text).expect("the store's file is a Workspaces document"); - assert_eq!(parsed.workspaces.len(), 1); - assert_eq!(parsed.workspaces[0].id, ws.id, "the identity survives"); - assert_eq!(parsed.workspaces[0].name.as_deref(), Some("api")); - // The client's view state was never sent, so the remote's copy has the - // defaults rather than another machine's window geometry. - assert!(parsed.workspaces[0].window.is_none()); - assert!(!parsed.workspaces[0].is_remote()); - } - - // ── Validation ────────────────────────────────────────────────────────── - - #[test] - fn a_record_must_be_an_object_whose_id_agrees_with_its_key() { - let (store, _dir) = store(); - let kinds = |e: io::Error| e.kind(); - assert_eq!( - store - .put("a", serde_json::json!([1, 2, 3]), None) - .map_err(kinds), - Err(io::ErrorKind::InvalidInput) - ); - assert_eq!( - store - .put("a", serde_json::json!({"id": "b"}), None) - .map_err(kinds), - Err(io::ErrorKind::InvalidInput), - "filing b's record under a would put the key and the file at odds" - ); - assert_eq!( - store.put("", record("", "x"), None).map_err(kinds), - Err(io::ErrorKind::InvalidInput) - ); - assert!(store.is_empty(), "a rejected put must not be half-applied"); - - // A body with no id is completed rather than refused: the key is the - // authority and the file still ends up well-formed. - store - .put("a", serde_json::json!({"name": "api"}), None) - .unwrap(); - assert_eq!(store.get("a").unwrap()["id"], "a"); - } - - #[test] - fn oversized_and_overnumerous_records_are_refused_by_name() { - let (store, _dir) = store(); - let huge = serde_json::json!({"name": "x".repeat(MAX_RECORD_BYTES + 16)}); - assert_eq!( - store.put("a", huge, None).unwrap_err().kind(), - io::ErrorKind::InvalidInput - ); - assert!(store.is_empty()); - } - - // ── Corruption ────────────────────────────────────────────────────────── - - #[test] - fn a_corrupt_file_is_quarantined_rather_than_overwritten() { - let dir = tempfile::TempDir::new().unwrap(); - let path = dir.path().join(STORE_FILE); - std::fs::write(&path, b"{ this is not json").unwrap(); - - let store = WorkspaceStore::open(&path); - assert!( - store.is_empty(), - "an unparseable file yields an empty store" - ); - // The user's bytes are still recoverable after the store overwrites the - // original. - store.put("a", record("a", "api"), None).unwrap(); - let aside = std::fs::read_to_string(path.with_extension("json.corrupt")).unwrap(); - assert_eq!(aside, "{ this is not json"); - } - - #[test] - fn one_bad_record_does_not_cost_the_others() { - let dir = tempfile::TempDir::new().unwrap(); - let path = dir.path().join(STORE_FILE); - std::fs::write( - &path, - br#"{"workspaces":[ - {"id":"a","name":"api"}, - {"name":"no id at all"}, - {"id":42}, - {"id":"a","name":"duplicate"}, - {"id":"b","name":"web"} - ]}"#, - ) - .unwrap(); - let store = WorkspaceStore::open(&path); - assert_eq!(store.len(), 2); - assert_eq!(store.get("a").unwrap()["name"], "api", "the first wins"); - assert_eq!(store.get("b").unwrap()["name"], "web"); - } - - #[test] - fn a_utf8_bom_does_not_empty_the_store() { - let dir = tempfile::TempDir::new().unwrap(); - let path = dir.path().join(STORE_FILE); - std::fs::write(&path, "\u{FEFF}{\"workspaces\":[{\"id\":\"a\"}]}").unwrap(); - assert_eq!(WorkspaceStore::open(&path).len(), 1); - } - - // ── Notification ──────────────────────────────────────────────────────── - - #[test] - fn a_change_notifies_every_subscriber_but_its_author() { - let (store, _dir) = store(); - let heard_by_a = Arc::new(Mutex::new(Vec::::new())); - let heard_by_b = Arc::new(Mutex::new(Vec::::new())); - let sink = |log: &Arc>>| { - let log = Arc::clone(log); - Arc::new(move |id: &str| log.lock().unwrap().push(id.to_string())) as Notify - }; - let a = store.subscribe(sink(&heard_by_a)); - let _b = store.subscribe(sink(&heard_by_b)); - - // A writes: B hears about it, A does not hear its own change. - store.put("w1", record("w1", "one"), Some(a.id())).unwrap(); - assert!(heard_by_a.lock().unwrap().is_empty()); - assert_eq!(&*heard_by_b.lock().unwrap(), &["w1".to_string()]); - - // A delete is a change too, and a write with no origin reaches all. - store.delete("w1", Some(a.id())).unwrap(); - store.put("w2", record("w2", "two"), None).unwrap(); - assert_eq!(&*heard_by_a.lock().unwrap(), &["w2".to_string()]); - assert_eq!( - &*heard_by_b.lock().unwrap(), - &["w1".to_string(), "w1".to_string(), "w2".to_string()] - ); - - // Deleting nothing changed nothing, so it says nothing. - let before = heard_by_b.lock().unwrap().len(); - assert!(!store.delete("gone", None).unwrap()); - assert_eq!(heard_by_b.lock().unwrap().len(), before); - } - - #[test] - fn dropping_a_subscription_stops_the_notifications() { - let (store, _dir) = store(); - let count = Arc::new(AtomicUsize::new(0)); - let seen = Arc::clone(&count); - let sub = store.subscribe(Arc::new(move |_| { - seen.fetch_add(1, Ordering::SeqCst); - })); - store.put("a", record("a", "x"), None).unwrap(); - assert_eq!(count.load(Ordering::SeqCst), 1); - drop(sub); - store.put("b", record("b", "y"), None).unwrap(); - assert_eq!( - count.load(Ordering::SeqCst), - 1, - "a torn-down connection must not still be written to" - ); - } - - /// A rejected put changed nothing, so it must not claim otherwise. - #[test] - fn a_failed_put_notifies_nobody() { - let (store, _dir) = store(); - let count = Arc::new(AtomicUsize::new(0)); - let seen = Arc::clone(&count); - let _sub = store.subscribe(Arc::new(move |_| { - seen.fetch_add(1, Ordering::SeqCst); - })); - store - .put("a", serde_json::json!("not an object"), None) - .ok(); - assert_eq!(count.load(Ordering::SeqCst), 0); - } - - // ── Concurrency ───────────────────────────────────────────────────────── - - /// Several connections writing at once is the normal case, not the - /// pathological one. Every write must land, and the file must end up as a - /// state that actually existed — not a half-written interleaving. - #[test] - fn concurrent_writers_all_land_and_the_file_stays_whole() { - let dir = tempfile::TempDir::new().unwrap(); - let path = dir.path().join(STORE_FILE); - let store = WorkspaceStore::open(&path); - - let threads: Vec<_> = (0..8) - .map(|t| { - let store = Arc::clone(&store); - std::thread::spawn(move || { - for i in 0..25 { - let id = format!("w{t}-{i}"); - store.put(&id, record(&id, "x"), None).unwrap(); - } - }) - }) - .collect(); - for t in threads { - t.join().unwrap(); - } - - assert_eq!(store.len(), 200); - // And the file on disk agrees, which is the part a torn write would - // fail: it would not parse at all. - let reopened = WorkspaceStore::open(&path); - assert_eq!(reopened.len(), 200); - for t in 0..8 { - assert!(reopened.get(&format!("w{t}-24")).is_some()); - } - } - - /// The same workspace written from two connections: last writer wins, and - /// the loser's record is gone rather than merged into a hybrid neither - /// client asked for. - #[test] - fn concurrent_writes_to_one_record_are_last_writer_wins() { - let (store, _dir) = store(); - let a = Arc::clone(&store); - let b = Arc::clone(&store); - let ta = std::thread::spawn(move || { - for _ in 0..200 { - a.put("w", record("w", "from-a"), None).unwrap(); - } - }); - let tb = std::thread::spawn(move || { - for _ in 0..200 { - b.put("w", record("w", "from-b"), None).unwrap(); - } - }); - ta.join().unwrap(); - tb.join().unwrap(); - assert_eq!(store.len(), 1); - let name = store.get("w").unwrap()["name"] - .as_str() - .unwrap() - .to_string(); - assert!(name == "from-a" || name == "from-b", "{name}"); - } - - // ── Attachment (M6's data) ────────────────────────────────────────────── - - #[test] - fn attaching_reports_the_session_it_displaced() { - let (store, _dir) = store(); - assert_eq!(store.attachment("w"), None); - - let laptop = Attachment::new("tok-1", "laptop"); - assert_eq!( - store.attach("w", laptop.clone()), - None, - "nothing to preempt" - ); - assert_eq!(store.attachment("w"), Some(laptop.clone())); - - // The second client's attach hands back the first — the exact fact M6's - // takeover acts on. - let desktop = Attachment::new("tok-2", "desktop"); - assert_eq!(store.attach("w", desktop.clone()), Some(laptop.clone())); - assert_eq!(store.attachment("w"), Some(desktop)); - - // The preempted client tearing down afterwards must not evict the new - // owner: its token no longer holds the workspace. - assert!(!store.detach("w", &laptop.token)); - assert_eq!(store.attachment("w").unwrap().hostname, "desktop"); - assert!(store.detach("w", "tok-2")); - assert_eq!(store.attachment("w"), None); - } - - #[test] - fn attachments_are_scoped_to_a_workspace_and_die_with_it() { - let (store, _dir) = store(); - store.put("w", record("w", "one"), None).unwrap(); - store.attach("w", Attachment::new("tok", "laptop")); - store.attach("other", Attachment::new("tok", "laptop")); - assert_eq!(store.attachments().len(), 2); - - store.delete("w", None).unwrap(); - assert_eq!(store.attachment("w"), None); - assert!(store.attachment("other").is_some()); - } - - /// Attachments describe live connections, so they must not outlive the - /// process that held them. - #[test] - fn attachments_are_never_written_to_the_file() { - let dir = tempfile::TempDir::new().unwrap(); - let path = dir.path().join(STORE_FILE); - let store = WorkspaceStore::open(&path); - store.put("w", record("w", "one"), None).unwrap(); - store.attach("w", Attachment::new("secret-token", "laptop")); - - let text = std::fs::read_to_string(&path).unwrap(); - assert!(!text.contains("secret-token"), "{text}"); - assert!(!text.contains("laptop"), "{text}"); - assert_eq!( - WorkspaceStore::open(&path).attachment("w"), - None, - "a restarted server has no attached clients" - ); - } - - // ── Path resolution ───────────────────────────────────────────────────── - - #[test] - fn the_store_path_ends_at_the_documented_file() { - // `TTY7_DATA_DIR` is process-global, so this only asserts the shape the - // resolution produces rather than setting the variable under other - // tests running beside it. - let p = WorkspaceStore::open(PathBuf::from("/srv/data/tty7").join(STORE_FILE)); - assert!(p.path().ends_with("tty7/workspaces.json")); - } - - #[test] - fn a_workspace_id_is_a_usable_store_key() { - let (store, _dir) = store(); - let id = WorkspaceId::new().to_string(); - store.put(&id, record(&id, "api"), None).unwrap(); - assert!(store.get(&id).is_some()); - } -} diff --git a/crates/tty7-core/src/daemon/control.rs b/crates/tty7-core/src/daemon/control.rs index d6e412f1..0cf54f01 100644 --- a/crates/tty7-core/src/daemon/control.rs +++ b/crates/tty7-core/src/daemon/control.rs @@ -90,11 +90,25 @@ use super::protocol::{MAX_FRAME, read_frame, write_frame}; /// comparing dialect numbers, so a capability that doesn't move the number is a /// capability the far machine never gets. A [`feature`] string is the right /// answer only for something two current servers can genuinely disagree about -/// (the workspace store, which depends on how the server was started); "this +/// (the machine tree, which depends on how the server was started); "this /// build knows the request and older ones don't" is what the number is for. /// /// ## History /// +/// - **v3** — the machine-tree migration. The workspace/tab/pane tree moved +/// into the daemon: `MachineGet` / `WorkspaceTree`, the semantic tree verbs +/// (workspace/tab/pane create, close, rename, move, split, ratio, replace), +/// and the [`ControlEvent::Layout`] / [`ControlEvent::LayoutResync`] pushes +/// — seventeen new request variants in all — while the retired opaque-record +/// verbs +/// (`workspace_list` / `workspace_get` / `workspace_put` / +/// `workspace_delete` and the `workspace_changed` event) left the dialect +/// entirely. A v2 peer meeting any of the new variants fails the whole +/// decode (no `#[serde(other)]`), and this build meeting a v2 server's +/// record verbs would answer unknown-variant errors forever. The +/// [`feature::MACHINE_TREE`] bit still exists *within* v3, because two +/// current servers can genuinely differ on it (a box with no home +/// directory serves files but no tree). /// - **v2** — [`ControlRequest::Shells`], which backs a remote window's new-tab /// dropdown. Not a `feature` string: every server from this build on answers /// it, so the only thing a capability bit would have bought is that a machine @@ -102,7 +116,7 @@ use super::protocol::{MAX_FRAME, read_frame, write_frame}; /// menu. The bump makes `RemoteProtocol::serves` refuse to adopt that server /// and install this build's instead, which is the actual fix. /// - **v1** — the dialect at the time remote workspaces landed. -pub const CONTROL_VERSION: u32 = 2; +pub const CONTROL_VERSION: u32 = 3; /// This process's identity as a control server, minted once on first use. /// @@ -239,10 +253,20 @@ pub mod feature { pub const CONTROL: &str = "control"; /// Serves [`super::ControlRequest`]'s filesystem and git methods — i.e. can /// back a remote `Host`. Distinct from [`CONTROL`] because a peer could - /// speak the dialect while exposing only the workspace store. + /// speak the dialect while exposing only the workspace tree. pub const HOST_RPC: &str = "host-rpc"; - /// Serves the `Workspace*` requests. - pub const WORKSPACE_STORE: &str = "workspace-store"; + // `"workspace-store"` is a burned name: it advertised the retired + // opaque-record scheme (verbs `workspace_list` / `workspace_get` / + // `workspace_put` / `workspace_delete`, event `workspace_changed`), all of + // which are burned with it. Never re-advertise or re-mint any of them with + // a different meaning. + /// Serves the machine-owned workspace tree: the `MachineGet` / + /// `WorkspaceTree` pulls, the semantic tree operations, and the + /// [`super::ControlEvent::Layout`] pushes. Advertised only when the server + /// actually carries a [`crate::core::machine::MachineStore`], so a client + /// learns from the handshake whether the tree verbs are worth a round + /// trip. + pub const MACHINE_TREE: &str = "machine-tree"; /// Can be launched as `--stdio` and bridge its own stdin/stdout to the /// machine-local socket (the fallback when `AllowStreamLocalForwarding` is /// off, the only option under WSL, and how the CI end-to-end test runs). @@ -264,6 +288,13 @@ pub use crate::host::{Entry, MTime, Meta, Output, SearchHit}; // the wire, not a wire-only copy of it. pub use crate::core::shells::{DetectedShell, ShellInventory}; +// And for the machine tree: the daemon's own tree types are the wire types, so +// a schema drift between the store and the dialect is a compile error rather +// than a silent mistranslation. `WorkspaceId` rides along because every tree +// verb addresses a workspace by it. +pub use crate::core::machine::{Axis, LayoutDelta, Machine, PaneSeed, Side, Tab, TabId}; +pub use crate::core::session::WorkspaceId; + // --------------------------------------------------------------------------- // Requests // --------------------------------------------------------------------------- @@ -275,7 +306,8 @@ pub use crate::core::shells::{DetectedShell, ShellInventory}; /// are routinely different operating systems. Remote paths are UTF-8 POSIX; a /// non-UTF-8 name on the server is returned lossily by `ReadDir`, matching what /// the file tree already does locally with `to_string_lossy`. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +// Not `Eq`: the machine-tree verbs carry split ratios, and `f32` has no `Eq`. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ControlRequest { // ----- liveness --------------------------------------------------------- @@ -402,18 +434,10 @@ pub enum ControlRequest { id: u64, }, - // ----- workspace store (M5; the slots exist, the server doesn't yet) ----- - WorkspaceList, - WorkspaceGet { - id: String, - }, - WorkspacePut { - id: String, - json: serde_json::Value, - }, - WorkspaceDelete { - id: String, - }, + // The opaque record store's verbs — `workspace_list` / `workspace_get` / + // `workspace_put` / `workspace_delete` — lived here until the machine tree + // below replaced them. Their serde names are burned (see `feature`); do + // not re-mint them with a different meaning. // ----- attachment (M6's takeover) --------------------------------------- /// Claim a workspace for this connection's session, taking it over from @@ -437,6 +461,126 @@ pub enum ControlRequest { WorkspaceDetach { id: String, }, + + // ----- machine tree (the daemon-owned structure) ------------------------ + // The semantic replacement for the retired opaque record verbs: instead of + // a whole-record `Put` (last-writer-wins the moment two clients write), + // each operation names its edit, the server validates it against the tree + // it owns, and everyone else hears an incremental + // [`ControlEvent::Layout`]. Positions cross as `u64` for the same reason + // `Search`'s limits do: a 32-bit server clamps rather than wraps. + /// The whole tree — every workspace, tab and pane record on the machine. + /// The full pull a client starts from before applying deltas. + MachineGet, + /// One workspace of the tree, whole. `NotFound` when the machine has no + /// such workspace; also the re-pull a client falls back to when it cannot + /// apply a delta. + WorkspaceTree { + workspace: WorkspaceId, + }, + /// Create an empty workspace; its first tab arrives as its own operation. + /// Answers the newborn [`ReplyOk::WorkspaceTree`]. `workspace` lets the + /// client mint the id — a window names its workspace before any round trip + /// completes — and `None` has the daemon mint one, as before. A taken id + /// is refused, never adopted. + WorkspaceCreate { + name: Option, + #[serde(default)] + workspace: Option, + }, + WorkspaceRename { + workspace: WorkspaceId, + name: Option, + }, + /// Forget a tree workspace and everything under it. Named `Remove` because + /// `WorkspaceDelete` was the retired record store's verb, and its serde + /// name stays burned. + WorkspaceRemove { + workspace: WorkspaceId, + }, + /// Stamp a workspace as just-focused, for pickers ordered by recency. + WorkspaceTouch { + workspace: WorkspaceId, + }, + WorkspaceSetActiveTab { + workspace: WorkspaceId, + tab: TabId, + }, + /// Create a tab holding `pane` at position `at` (clamped; `None` appends). + /// `pane` is the seed for a pane the client already spawned over the pane + /// protocol — PTYs come from there, the tree only adopts them. `tab` is + /// the client-minted identity (see `WorkspaceCreate::workspace`); `None` + /// has the daemon mint one. + TabCreate { + workspace: WorkspaceId, + at: Option, + pane: PaneSeed, + #[serde(default)] + tab: Option, + }, + /// Close a tab. Answers [`ReplyOk::Panes`]: the pane ids that left the + /// tree, for the caller to kill — the tree does bookkeeping, not process + /// teardown. + TabClose { + workspace: WorkspaceId, + tab: TabId, + }, + TabRename { + workspace: WorkspaceId, + tab: TabId, + name: Option, + }, + TabMove { + workspace: WorkspaceId, + tab: TabId, + to: u64, + }, + /// Record the tab's sidebar repo group, as resolved by the client. + TabSetGroup { + workspace: WorkspaceId, + tab: TabId, + group: Option, + }, + /// Split the leaf holding `pane`; `new` seeds the freshly-spawned second + /// pane, `first` puts it on the upper/left side. + PaneSplit { + workspace: WorkspaceId, + pane: u64, + axis: Axis, + ratio: f32, + new: PaneSeed, + first: bool, + }, + /// Close one pane, collapsing its split (or the whole tab when it was the + /// last pane). Answers [`ReplyOk::Panes`] like `TabClose`. + PaneClose { + workspace: WorkspaceId, + pane: u64, + }, + /// Move a split's divider. `path` addresses the split from the tab root, + /// and a path the tree no longer has refuses rather than guessing. + PaneSetRatio { + workspace: WorkspaceId, + tab: TabId, + path: Vec, + ratio: f32, + }, + /// tmux's `move-pane`: take `pane` out of where it is and re-split it next + /// to `to`, dissolving the source tab if that emptied it. + PaneMove { + workspace: WorkspaceId, + pane: u64, + to: u64, + axis: Axis, + first: bool, + }, + /// The revival: rebind the leaf holding dead pane `old` to freshly-spawned + /// successor `new`, spending the old registry record. + PaneReplace { + workspace: WorkspaceId, + old: u64, + new: PaneSeed, + }, } impl ControlRequest { @@ -475,13 +619,29 @@ impl ControlRequest { // spawns `wsl.exe -l -q`, which is slow enough to deserve the same // budget as git. Shells => Duration::from_secs(20), - WorkspaceList | WorkspaceGet { .. } | WorkspacePut { .. } | WorkspaceDelete { .. } => { - Duration::from_secs(10) - } // An attach is bookkeeping plus at most one push to a peer that may // be wedged — the push is `try`-shaped on the server, so this only // has to cover a slow link, not a slow client. WorkspaceAttach { .. } | WorkspaceDetach { .. } => Duration::from_secs(10), + // Tree operations are a locked mutation plus one small file write, + // so the budget covers a slow disk, not slow work. + MachineGet + | WorkspaceTree { .. } + | WorkspaceCreate { .. } + | WorkspaceRename { .. } + | WorkspaceRemove { .. } + | WorkspaceTouch { .. } + | WorkspaceSetActiveTab { .. } + | TabCreate { .. } + | TabClose { .. } + | TabRename { .. } + | TabMove { .. } + | TabSetGroup { .. } + | PaneSplit { .. } + | PaneClose { .. } + | PaneSetRatio { .. } + | PaneMove { .. } + | PaneReplace { .. } => Duration::from_secs(10), } } @@ -502,7 +662,7 @@ impl ControlRequest { // --------------------------------------------------------------------------- /// A reply to one request: the operation's value, or why it couldn't run. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub enum ControlReply { #[serde(rename = "ok")] Ok(ReplyOk), @@ -523,7 +683,7 @@ impl ControlReply { /// The successful half of a reply. One variant per result *shape*, not per /// request — several requests answer `Unit`, and `Stat` and `WriteFile` both /// answer `Meta`. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ReplyOk { Unit, @@ -542,8 +702,6 @@ pub enum ReplyOk { WatchId(u64), /// [`ControlRequest::Shells`]: what that machine can launch. Shells(ShellInventory), - /// The workspace store's payload (M5). - Json(serde_json::Value), /// [`ControlRequest::WorkspaceAttach`] succeeded. `took_over_from` names the /// machine whose session was displaced, so the client that *did* the taking /// can say so — only the notice going the other way is specified, @@ -551,6 +709,17 @@ pub enum ReplyOk { Attached { took_over_from: Option, }, + /// [`ControlRequest::MachineGet`]: the machine's whole tree. Boxed for the + /// same reason the tree replies below are: `ReplyOk` values live on the + /// dispatch stack, and the common replies must not pay for the big ones. + MachineTree(Box), + /// One workspace of the tree ([`ControlRequest::WorkspaceTree`] / + /// [`ControlRequest::WorkspaceCreate`]). + WorkspaceTree(Box), + /// The tab an operation created ([`ControlRequest::TabCreate`]). + TabTree(Box), + /// Pane ids an operation removed from the tree, for the caller to kill. + Panes(Vec), } /// An operation that could not be performed. @@ -657,7 +826,7 @@ impl WireErrorKind { // --------------------------------------------------------------------------- /// An unsolicited server push, carried on [`kind::EVENT`] with `req_id == 0`. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ControlEvent { /// Filesystem changes, coalesced and deduplicated by the server over a @@ -724,9 +893,35 @@ pub enum ControlEvent { workspace: String, by: String, }, - WorkspaceChanged { - id: String, + // `workspace_changed` was the retired record store's change notice; its + // serde name is burned along with the record verbs. + /// One incremental change to one tree workspace on this machine — the + /// push half of the machine-tree verbs. The writer never receives its own + /// operation back (origin exclusion, so an optimistically-applied edit is + /// not applied twice); every other client applies the delta to its live + /// window or, when it cannot, re-pulls the workspace with + /// [`ControlRequest::WorkspaceTree`]. + /// + /// `workspace` is the [`WorkspaceId`] rendered as a string, matching how + /// `Preempted` names its. + Layout { + workspace: String, + delta: LayoutDelta, }, + /// The server dropped at least one [`Layout`](Self::Layout) push for this + /// connection (its per-connection delta queue overflowed — see + /// [`crate::host::server::LAYOUT_EVENT_QUEUE`]): the peer's mirrors are + /// now wrong in a way no later delta repairs. So the client re-pulls the + /// machine whole and resyncs its windows — the identical recovery a delta + /// that will not apply already triggers, just server-announced instead of + /// stumbled into. Connection-wide, because drops happen at the queue, not + /// per workspace; the watch dialect's `WatchOverflow` is the precedent. + /// + /// It arrives *instead of* the deltas the queue was still holding, not + /// ahead of them: those are older than the gap and already inside the tree + /// the client is about to pull, so delivering them after the pull would + /// walk the client backwards through history it has already left behind. + LayoutResync, } /// Where control events that are nobody's *local* business end up. @@ -734,7 +929,7 @@ pub enum ControlEvent { /// [`RemoteHost`](crate::host::remote::RemoteHost) routes `Watch` and /// `WatchOverflow` into the subscription that asked for them, because those /// belong to a caller that is still holding a `WatchSub`. The rest — -/// `Preempted`, `PaneExited`, `AgentStatus`, `WorkspaceChanged` — are about a +/// `Preempted`, `PaneExited`, `AgentStatus`, `Layout` — are about a /// *window*, and the host layer has no window. /// /// A process-wide observer rather than a parameter on `connect_with` because @@ -935,7 +1130,7 @@ fn require_nonzero(req_id: u64, what: &str) -> io::Result<()> { // --------------------------------------------------------------------------- /// A control frame travelling client → server. -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq)] pub enum ControlClientMsg { Hello(ControlHello), Request { @@ -1042,7 +1237,7 @@ impl ControlClientMsg { } /// A control frame travelling server → client. -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq)] pub enum ControlServerMsg { HelloOk(ControlHelloOk), Response { @@ -1174,7 +1369,7 @@ pub const CLOSE_GRACE: Duration = Duration::from_millis(500); /// A reply as the caller receives it: the value, plus the blob if the frame /// carried one. -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq)] pub struct ControlResponse { pub reply: ReplyOk, pub blob: Vec, @@ -1981,13 +2176,6 @@ mod tests { dirs: vec!["/home/me/proj".into(), "/home/me/proj/src".into()], }, ControlRequest::WatchClose { id: 7 }, - ControlRequest::WorkspaceList, - ControlRequest::WorkspaceGet { id: "w1".into() }, - ControlRequest::WorkspacePut { - id: "w1".into(), - json: serde_json::json!({ "tabs": [1, 2, 3] }), - }, - ControlRequest::WorkspaceDelete { id: "w1".into() }, ] } @@ -2034,7 +2222,6 @@ mod tests { stderr: vec![0x00, 0xff, 0xfe, b'\n'], })), ControlReply::Ok(ReplyOk::WatchId(42)), - ControlReply::Ok(ReplyOk::Json(serde_json::json!({ "a": [1, null] }))), ControlReply::Err(WireError::new(WireErrorKind::NotFound, "no such file")), ControlReply::Err(WireError::new( WireErrorKind::PermissionDenied, @@ -2082,7 +2269,6 @@ mod tests { workspace: "w1".into(), by: "other-laptop".into(), }, - ControlEvent::WorkspaceChanged { id: "w1".into() }, ] } @@ -2747,16 +2933,6 @@ mod tests { }, s(20), ), - (R::WorkspaceList, s(10)), - (R::WorkspaceGet { id: "w".into() }, s(10)), - ( - R::WorkspacePut { - id: "w".into(), - json: serde_json::Value::Null, - }, - s(10), - ), - (R::WorkspaceDelete { id: "w".into() }, s(10)), ]; assert_eq!( cases.len(), @@ -2795,7 +2971,9 @@ mod tests { assert_eq!(ok.home, "/home/me"); assert!(ok.has_feature(feature::CONTROL)); assert!(ok.has_feature(feature::HOST_RPC)); - assert!(!ok.has_feature(feature::WORKSPACE_STORE)); + // The retired record store's bit is a burned name and must + // never come back. + assert!(!ok.has_feature("workspace-store")); } other => panic!("expected HelloOk, got {other:?}"), } diff --git a/crates/tty7-core/src/daemon/pane.rs b/crates/tty7-core/src/daemon/pane.rs index cfb1f238..8798335d 100644 --- a/crates/tty7-core/src/daemon/pane.rs +++ b/crates/tty7-core/src/daemon/pane.rs @@ -630,6 +630,12 @@ impl OutputGate { /// PTY master, writer, child) so a single `Mutex` guards everything the reader /// thread and the connection threads both touch. struct PaneState { + /// The registry id of the pane this state belongs to — [`DaemonPane::id`], + /// duplicated here so the code paths that only ever see the state (the + /// signal appliers, [`DeathReporter::report`]) can name the pane when + /// publishing an observation to the machine tree + /// ([`crate::core::machine::observe_pane`]). + id: u64, /// The replay ring: raw PTY bytes bounded to `RING_CAP`, segmented by the /// geometry they were recorded under so `attach` can replay each stretch /// at the width it was written for. Also the owner of the pane's current @@ -812,7 +818,14 @@ impl DeathReporter { } let mut st = state.lock().unwrap(); st.alive = false; + let pane = st.id; if shutting_down.load(Ordering::SeqCst) { + drop(st); + // Even a teardown the owner initiated is a death the tree must + // hear about: the record's `live == false` *is* the client-visible + // "awaiting revival" state, and it must not depend on which thread + // noticed the child go. + crate::core::machine::observe_pane(pane, |p| p.live = false); return; } let subscribed = st.subscriber.is_some(); @@ -820,6 +833,7 @@ impl DeathReporter { let _ = sub.send(DaemonMsg::Exited { code: None }); } drop(st); + crate::core::machine::observe_pane(pane, |p| p.live = false); // A subscriber's later detach reclaims the pane, so only an *unattached* // death needs `on_dead` — and it fires at most once. if subscribed { @@ -870,6 +884,7 @@ impl DaemonPane { let writer = pair.master.take_writer()?; let state = Arc::new(Mutex::new(PaneState { + id, ring: ReplayRing::new(size), subscriber: None, subscriber_epoch: 0, @@ -979,6 +994,7 @@ impl DaemonPane { }; let state = Arc::new(Mutex::new(PaneState { + id, ring: ReplayRing::new(size), subscriber: None, subscriber_epoch: 0, @@ -1240,7 +1256,24 @@ impl DaemonPane { let probed_cwd = poll_now.then(&foreground_cwd_fn).flatten(); let tr1 = trace.then(std::time::Instant::now); + // Whether this chunk carries anything that *could* + // move a fact the tree records. An ordinary output + // chunk carries none of them, and must not pay for + // two snapshots and a compare per read: a build's + // worth of stdout is thousands of chunks and no + // facts at all. + let may_change_facts = signals.cwd.is_some() + || !signals.agent_events.is_empty() + || signals.notification.is_some() + // A prompt boundary: on Windows the agent + // identity rides the `133;C` capture, and + // everywhere the OSC 7 cwd travels with it. + || !signals.shell.is_empty() + || remote.is_some() + || agent.is_some() + || probed_cwd.is_some(); let mut st = state.lock().unwrap(); + let facts_before = may_change_facts.then(|| observed_facts(&st)); st.ring.append(bytes); if let Some(sub) = &st.subscriber { // A send error just means the client is gone; ignore @@ -1265,6 +1298,47 @@ impl DaemonPane { if let Some(tr1) = tr1 { tr_disp_t += tr1.elapsed(); } + // Publish what this chunk changed to the machine + // tree — outside the state lock, because the store + // broadcasts to every client of this machine and + // this thread's stalls are the child's write + // stalls. Gated twice over: `may_change_facts` + // keeps plain output free, and the compare below + // keeps a re-reported cwd from becoming a store + // mutation. + let pane = st.id; + // Read *with* the facts, not assumed: on Windows + // the exit monitor can report the death (flipping + // `alive`) while this thread is still draining + // ConPTY's buffered output, and the death report + // is latched — a "proof of life" published here + // after it would mark a dead pane live forever. + let alive = st.alive; + let facts_after = may_change_facts.then(|| observed_facts(&st)); + drop(st); + if let (Some(before), Some(after)) = (facts_before, facts_after) + && facts_changed(&before, &after) + { + let (cwd, agent) = after; + crate::core::machine::observe_pane(pane, |p| { + // An unknown cwd never clears a seeded one: + // the spawn directory in the record is + // better revival information than nothing. + if cwd.is_some() { + p.cwd = cwd; + } + // The agent fact applies wholesale — its + // `None` means the agent left the + // foreground, and a revival must not + // resume a session that already ended. + p.agent = agent; + // Output is proof of life — but only while + // the pane still is; see `alive` above. + if alive { + p.live = true; + } + }); + } } Err(err) if err.kind() == std::io::ErrorKind::Interrupted => continue, Err(_) => break, // EIO after hangup, etc. @@ -1882,6 +1956,62 @@ fn attach_subscriber(st: &mut PaneState, subscriber: Sender) -> u64 { st.subscriber_epoch } +/// The slice of a pane's state the machine tree records about it — the cwd a +/// successor would spawn in, and the agent facts a successor would resume. +/// Captured before and after a chunk's signal application so the (rare) change +/// is published outside the state lock; see the reader loop. +/// +/// The cwd crosses as a `String` because the tree's records do (the dialect's +/// path rule); the loss, if any, happens here where it can be seen next to the +/// path that caused it. +fn observed_facts(st: &PaneState) -> (Option, Option) { + let cwd = st.cwd.as_ref().map(|p| p.to_string_lossy().into_owned()); + let agent = st.agent.map(|agent| crate::core::machine::AgentFacts { + agent, + session_id: st.agent_session.as_ref().and_then(|s| s.session_id.clone()), + // The session's own argv record wins — it survives the chip clearing — + // with the identity poll's capture as the fallback until it is stamped. + launch_argv: st + .agent_session + .as_ref() + .and_then(|s| s.launch_argv.clone()) + .or_else(|| st.agent_argv.clone()), + status: st.agent_session.as_ref().map(|s| s.status), + }); + (cwd, agent) +} + +/// Whether a chunk's facts are worth a store mutation. +/// +/// The coarse agent status is deliberately **outside** the gate: it flips on +/// every hook event (working ↔ waiting ↔ idle), each of which would otherwise +/// rewrite `machine.json` from the PTY reader thread, and it is documented +/// display-only. It still *rides along* — whenever a load-bearing fact +/// changes, the record published carries the current status too. +fn facts_changed( + before: &(Option, Option), + after: &(Option, Option), +) -> bool { + before.0 != after.0 || agent_facts_changed(before.1.as_ref(), after.1.as_ref()) +} + +/// [`facts_changed`]'s agent half: equality over every field but the status. +/// Compared field by field rather than by cloning-and-blanking, because this +/// runs on the pane's reader thread and the argv it would clone is a `Vec` of +/// `String`s. +fn agent_facts_changed( + before: Option<&crate::core::machine::AgentFacts>, + after: Option<&crate::core::machine::AgentFacts>, +) -> bool { + match (before, after) { + (None, None) => false, + (Some(a), Some(b)) => { + a.agent != b.agent || a.session_id != b.session_id || a.launch_argv != b.launch_argv + } + _ => true, + } +} + /// Apply sniffed signals to the shared state and notify the subscriber of any cwd /// / prompt change. Called with the state lock held. fn apply_signals(st: &mut PaneState, signals: SniffSignals) { @@ -3649,6 +3779,9 @@ mod tests { /// A fresh `PaneState` for the PTY-less state-machine tests. fn test_state(alive: bool) -> PaneState { PaneState { + // Unit tests publish observations nowhere (no store is installed + // in this process), so the id is never consulted. + id: 0, ring: ReplayRing::new(ws(80, 24)), subscriber: None, subscriber_epoch: 0, @@ -3662,6 +3795,49 @@ mod tests { } } + /// What the machine tree is told about a pane is exactly what a successor + /// needs: the cwd as a string, the session's own argv over the poll's + /// capture (the session record survives chip churn), and the coarse + /// status. No agent, no facts — a revival must not resume a session that + /// was never there. + #[test] + fn observed_facts_prefer_the_sessions_argv_and_carry_its_status() { + use crate::core::cli_agent::{AgentSessionState, AgentStatus, CLIAgent}; + + let mut st = test_state(true); + assert_eq!(observed_facts(&st), (None, None)); + + st.cwd = Some(PathBuf::from("/work/api")); + st.agent = Some(CLIAgent::Claude); + st.agent_argv = Some(vec!["claude".into()]); + st.agent_session = Some(AgentSessionState { + status: AgentStatus::Working, + session_id: Some("sess-1".into()), + launch_argv: Some(vec!["claude".into(), "--model".into(), "opus".into()]), + ..Default::default() + }); + + let (cwd, agent) = observed_facts(&st); + assert_eq!(cwd.as_deref(), Some("/work/api")); + let agent = agent.expect("an agent in the foreground is a fact"); + assert_eq!(agent.agent, CLIAgent::Claude); + assert_eq!(agent.session_id.as_deref(), Some("sess-1")); + assert_eq!( + agent.launch_argv.as_deref(), + Some(&["claude".to_string(), "--model".into(), "opus".into()][..]), + "the session's own argv outranks the identity poll's capture" + ); + assert_eq!(agent.status, Some(AgentStatus::Working)); + + // The poll's capture is the fallback until the session stamps its own. + st.agent_session = None; + let (_, agent) = observed_facts(&st); + assert_eq!( + agent.unwrap().launch_argv.as_deref(), + Some(&["claude".to_string()][..]) + ); + } + /// The full daemon-side rich-status path: sentinel OSC events sniffed out /// of the byte stream drive the pane's session state machine, identify the /// agent when argv detection hasn't, and stream every change to the diff --git a/crates/tty7-core/src/daemon/protocol.rs b/crates/tty7-core/src/daemon/protocol.rs index 8819dd17..0dfe6ead 100644 --- a/crates/tty7-core/src/daemon/protocol.rs +++ b/crates/tty7-core/src/daemon/protocol.rs @@ -51,6 +51,16 @@ pub const MAX_FRAME: usize = 64 * 1024 * 1024; /// /// ## History /// +/// - **v4** — the daemon serves the machine tree. `tty7 --daemon` now runs +/// the shared `run_daemon`: a control listener (carrying the daemon-owned +/// workspace tree) beside the pane listener. No pane frame changed, so by +/// the letter of the rule above this is additive — but the *service* is +/// not: a v3 daemon has no control socket at all, and a GUI from this +/// build that silently adopted one would connect its control link into the +/// void forever — every window hydrating from a tree that never answers, +/// which renders as empty windows with no error anywhere. The bump routes +/// that meeting into `ensure_running`'s existing keep-or-restart prompt, +/// where "restart the background service" is the fix. /// - **v3** — the [`control`](super::control) dialect (kinds 60-63) and /// [`DaemonVersion::features`]. By the rule above this is *additive* and /// would not earn a bump on its own: a v2 daemon meeting a control frame @@ -66,7 +76,7 @@ pub const MAX_FRAME: usize = 64 * 1024 * 1024; /// 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 = 3; +pub const PROTOCOL_VERSION: u32 = 4; /// Capability string for [`DaemonVersion::features`]: this daemon records /// which workspace each pane was spawned for and reports it in `List`'s @@ -93,13 +103,17 @@ pub struct DaemonVersion { /// every user whose daemon happens to predate it. #[serde(default)] pub features: Vec, - /// Identity of this daemon *process*, minted once at startup. Pane ids are - /// only meaningful within one daemon process — after a restart the numbers - /// start over from 1 and land on unrelated shells — so a client that - /// persists pane ids records this next to them and treats a mismatch as - /// "every saved id is stale" (see `Workspace::daemon_instance`). Empty for - /// daemons that predate the field; the remote `tty7-server` announces the - /// same identity through its control hello. + /// Identity of this daemon *process*, minted once at startup — the same + /// identity the control hello announces + /// ([`ControlHelloOk::instance`](crate::daemon::control::ControlHelloOk::instance)), + /// which is what reconnect logic actually consults to tell "the link + /// blinked" from "a different process answers now". PTYs die with the + /// process, so a changed instance means every previously live pane is + /// gone; the machine tree records the same fact per pane (`load_machine` + /// clears every `live` flag on open), and a daemon carrying a tree seeds + /// its pane ids *past* everything the tree names rather than restarting + /// from 1, so a stale id can never alias a new shell. Empty for daemons + /// that predate the field — "unknown", never "restarted". #[serde(default)] pub instance: String, } @@ -113,10 +127,11 @@ impl DaemonVersion { DaemonVersion { protocol: PROTOCOL_VERSION, build: env!("CARGO_PKG_VERSION").to_string(), - // The local session daemon speaks the pane protocol only. The - // control dialect is served by `tty7-server`, which advertises - // `control` / `host-rpc` itself; claiming them here would make the - // GUI open a control connection this process cannot answer. + // This reply describes the *pane* socket only. The control + // dialect lives on the daemon's separate control socket, whose + // own `ControlHelloOk` announces `control` / `host-rpc` / + // `machine-tree` for itself; claiming them here would say the + // pane socket speaks frames it does not. // // `pane-owner` *is* a pane-protocol capability, so every process // serving panes from this build advertises it. @@ -2630,7 +2645,7 @@ mod tests { #[test] fn the_local_daemon_does_not_claim_the_control_dialect() { let v = DaemonVersion::current(); - assert_eq!(v.protocol, 3); + assert_eq!(v.protocol, 4); assert!( !v.has_feature(crate::daemon::control::feature::CONTROL), "the session daemon must not advertise a dialect it cannot serve" diff --git a/crates/tty7-core/src/daemon/router.rs b/crates/tty7-core/src/daemon/router.rs index 862c54d6..e94fb83e 100644 --- a/crates/tty7-core/src/daemon/router.rs +++ b/crates/tty7-core/src/daemon/router.rs @@ -142,7 +142,7 @@ const REPLY_TIMEOUT: Duration = Duration::from_secs(240); #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum RouteChannel { - /// Host RPC, the workspace store, event pushes — `daemon::control`. + /// Host RPC, the machine tree, event pushes — `daemon::control`. #[default] Control, /// One pane: `Spawn`/`Attach`/`Input`/`Output` — `daemon::protocol`. diff --git a/crates/tty7-core/src/daemon/server.rs b/crates/tty7-core/src/daemon/server.rs index 2babbe75..939d8848 100644 --- a/crates/tty7-core/src/daemon/server.rs +++ b/crates/tty7-core/src/daemon/server.rs @@ -46,6 +46,36 @@ impl Registry { self.next_id.fetch_add(1, Ordering::Relaxed) } + /// Never mint an id `machine`'s tree already names — see the caller in + /// [`run`] for the aliasing failures this closes. The registry and the + /// leaves are checked both: a pane record can outlive its leaf briefly, + /// and either one aliased is one too many. + fn seed_ids_past(&self, machine: &crate::core::machine::Machine) { + let max = machine + .panes + .iter() + .map(|p| p.id) + .chain( + machine + .workspaces + .iter() + .flat_map(|w| w.tabs.iter()) + .flat_map(|t| t.root.pane_ids()), + ) + .max() + .unwrap_or(0); + // Saturating: a tree (or a hostile seed) naming u64::MAX must not + // panic the daemon at startup. The counter parking at the ceiling is + // a bounded absurdity; overflowing is a dead process. + let next = max.saturating_add(1); + // fetch_max rather than store: harmless today (this runs before any + // spawn), but a seed must never move the counter backwards. + let before = self.next_id.fetch_max(next, Ordering::Relaxed); + if next > before { + log::info!("pane ids start at {next} (the tree names panes up to {max})"); + } + } + fn insert(&self, pane: Arc) { self.panes.lock().unwrap().insert(pane.id, pane); } @@ -87,6 +117,59 @@ impl Registry { } } +/// How often the orphan sweep looks, which doubles as its grace period: a pane +/// is only reported after it has been unreferenced across two consecutive +/// looks, so a freshly-spawned pane whose adopting operation is still in +/// flight is never flagged. +const ORPHAN_SWEEP_INTERVAL: std::time::Duration = std::time::Duration::from_secs(600); + +/// Periodically report live panes the machine tree does not reference. +/// +/// **Log-only, on purpose.** An unreferenced pane is not proof of a leak: +/// a native-SSH pane opened inside a *remote* workspace's window runs in this +/// (the client's) daemon while belonging to the other machine's tree, so it is +/// unreferenced here by design — and a reclaim would kill a session the user +/// is looking at. Until the tree provably references everything legitimate, +/// the sweep's job is to make leaks observable, not to act on them; killing +/// can be layered on once the log has shown the false-positive rate is zero. +fn spawn_orphan_sweep(registry: Arc) { + let spawned = std::thread::Builder::new() + .name("tty7-orphan-sweep".into()) + .spawn(move || { + let mut previous: std::collections::HashSet = std::collections::HashSet::new(); + loop { + std::thread::sleep(ORPHAN_SWEEP_INTERVAL); + // No tree served (a pane-only daemon) means no opinion. + let Some(store) = crate::core::machine::observed_store() else { + continue; + }; + let machine = store.machine(); + let referenced: std::collections::HashSet = machine + .workspaces + .iter() + .flat_map(|w| w.tabs.iter()) + .flat_map(|t| t.root.pane_ids()) + .collect(); + let orphans: std::collections::HashSet = registry + .list() + .into_iter() + .filter(|p| p.alive && !referenced.contains(&p.pane_id)) + .map(|p| p.pane_id) + .collect(); + for id in orphans.intersection(&previous) { + log::info!( + "pane {id} is running but no workspace tree references it \ + (kept; the sweep only reports — see spawn_orphan_sweep)" + ); + } + previous = orphans; + } + }); + if let Err(e) = spawned { + log::warn!("could not start the orphan-pane sweep: {e}"); + } +} + /// Resolve a pane id to its live native-SSH connection, for the SFTP control /// handlers. Errors (as a client-facing string) when the pane is unknown or isn't /// a native-SSH pane with an established connection (a PTY / compat-`ssh` pane, or @@ -103,6 +186,82 @@ fn ssh_connection_for( }) } +/// Run the *whole* daemon — panes **and** control — until killed. The one +/// entry point behind both `tty7 --daemon` and `tty7-server --daemon`. +/// +/// Local and remote are deliberately the same shape: a machine is a machine, +/// whether the client sits on it or an ocean away, and the design's terminal +/// state is "one machine = one daemon = one workspace tree". That tree is +/// served over the control dialect, so the *local* daemon has to speak it too — +/// which is why this lives here rather than staying a `tty7-server` detail. +/// +/// Control comes up first, and on its own thread: a machine that cannot host +/// panes (no pty, a locked-down container) should still be able to back a +/// workspace's files, so a control failure is logged and stepped over rather +/// than being fatal. The pane listener then owns this thread until the process +/// is killed, exactly as [`run`] always has. +/// +/// Both platforms serve it, over the transport each one's pane socket already +/// uses: a Unix-domain socket gated by its file permissions, or a loopback +/// `TcpListener` gated by the token in a user-private marker file. The tree is +/// what a client's layout *is* now, so a platform without a control listener is +/// a platform where tabs do not come back — which is not a difference a build +/// gets to have. +pub fn run_daemon() -> anyhow::Result<()> { + // Reported on **stderr**, not only the log: a headless server's log file is + // off unless `TTY7_LOG` asks for it, and the bound path is this daemon's + // one observable answer to "where do I connect". The remote-router test + // reads this exact line back to prove the client's derivation and the + // server's bind agree, so the prefix is part of the contract. + #[cfg(any(unix, windows))] + match crate::host::server::spawn_control_listener_with( + crate::host::local::LocalHost::shared(), + control_services(), + ) { + Ok(path) => eprintln!("tty7-server: control socket at {}", path.display()), + Err(e) => eprintln!("tty7-server: control listener unavailable: {e}"), + } + #[cfg(not(any(unix, windows)))] + log::info!("no control listener on this platform; serving panes only"); + + run() +} + +/// What this machine offers over a control connection, beyond its filesystem. +/// +/// The machine tree is why a daemon serves control at all: the workspace +/// list, the tab/pane tree and each pane's facts live on **the machine the +/// panes run on**, so that every client of this machine — the GUI on it, a +/// laptop across the world — sees the same thing. Clients keep only their own +/// view state. +/// +/// A machine with no home directory to place the file in still serves files +/// and panes — it simply omits `machine-tree` from its capabilities, and +/// clients see the same "does not serve the machine tree" answer a server +/// without one has always given. +pub fn control_services() -> crate::host::server::Services { + use crate::core::machine::MachineStore; + // Reported on stderr as well as the log, like the socket line in + // [`run_daemon`]: on a headless box the log file is off by default, and + // "does this daemon actually serve the tree" is the first question a + // capability mismatch raises. + match MachineStore::shared() { + Ok(machine) => { + eprintln!("machine tree at {}", machine.path().display()); + // From here on the pane server's own observations — OSC 7 cwds, + // agent identities, deaths — land on the tree's pane records, so + // what a client revives from is what the machine saw, not what + // some client last remembered to write. + crate::core::machine::publish_observations(&machine); + crate::host::server::Services::with_machine(machine) + } + Err(e) => { + eprintln!("no machine tree ({e}); serving files and panes only"); + crate::host::server::Services::none() + } + } +} + /// Run the daemon: bind the socket and serve connections forever. Returns `Err` /// only on a fatal setup failure (bad socket path, bind error); the accept loop /// itself runs until the process is killed. @@ -144,6 +303,29 @@ pub fn run() -> anyhow::Result<()> { #[cfg(unix)] serve_sigterm(registry.clone()); + // Pane ids must never alias across restarts: the persisted tree still + // names the previous process's panes, and a fresh process minting from 1 + // would hand a new shell an id some dead leaf claims — at which point the + // record's `live` flag flips back on for the wrong pane, revival stalls on + // "pane N is already part of this machine's tree", and a window attaching + // by the stale id steals an unrelated workspace's stream. Starting past + // everything the tree knows makes the id a name, not a slot. + if let Some(store) = crate::core::machine::observed_store() { + registry.seed_ids_past(&store.machine()); + // And let the store ask *us* whether a seeded pane is still alive at + // registration time — the pane that dies between its spawn and its + // adopting operation would otherwise be filed `live: true` with its + // death observation already dropped, and nothing left to flip it. + let probe = registry.clone(); + store.set_liveness_probe(Arc::new(move |id| { + probe.get(id).is_some_and(|pane| pane.info().alive) + })); + } + + // Now that the tree has an owner filling it, the daemon can *see* panes + // nothing references any more — but it only reports them, deliberately. + spawn_orphan_sweep(registry.clone()); + for stream in listener.incoming() { match stream { Ok(stream) => { @@ -209,14 +391,31 @@ fn serve_sigterm(registry: Arc) { if unsafe { libc::sigwait(&set, &mut sig) } == 0 { log::info!("daemon shutting down on SIGTERM"); registry.drain_and_kill(); - transport::remove_stale_endpoint(); - crate::daemon::pidfile::remove(); + on_shutdown(); std::process::exit(0); } }) .ok(); } +/// What every daemon exit owes the next one. +/// +/// The tree's observations first: a pane's cwd and its agent session are +/// deferred by design (`machine::Persist::Soon`) and are exactly what the next +/// launch revives that pane from, so the last couple of seconds of them are +/// worth one write on the way out. Then the endpoint markers — **both** +/// dialects', since on Windows each listener has its own — and the pidfile, so +/// nothing left on disk points at a process that is gone. +fn on_shutdown() { + if let Some(store) = crate::core::machine::observed_store() { + store.flush(); + } + transport::remove_stale_endpoint(); + #[cfg(windows)] + crate::host::server::remove_control_endpoint(); + crate::daemon::pidfile::remove(); +} + /// Handle one connection start-to-finish. Reads the opening `ClientMsg` and /// dispatches; for the streaming variants it then runs [`stream_pane`]. fn handle_conn(stream: Stream, registry: Arc) -> anyhow::Result<()> { @@ -366,8 +565,7 @@ fn handle_conn(stream: Stream, registry: Arc) -> anyhow::Result<()> { // place the daemon terminates itself. log::info!("daemon shutting down on client request"); registry.drain_and_kill(); - transport::remove_stale_endpoint(); - crate::daemon::pidfile::remove(); + on_shutdown(); std::process::exit(0); } @@ -774,6 +972,45 @@ mod tests { assert_eq!(reg.alloc_id(), 3); } + /// Pane ids are names, not slots: a fresh process must never re-mint an id + /// the persisted tree still references, or a stale leaf aliases a new + /// shell — the tree marks the wrong pane live, revival's re-registration + /// is refused forever, and an attach by the old id steals another + /// workspace's stream. + #[test] + fn pane_ids_never_alias_what_the_persisted_tree_references() { + use crate::core::machine::{MachineStore, PaneSeed}; + let dir = tempfile::TempDir::new().unwrap(); + let store = MachineStore::open(dir.path().join("machine.json")); + let ws = store.workspace_create(None, None, None).unwrap(); + store + .tab_create(ws.id, None, PaneSeed::bare(7), None, None) + .unwrap(); + + let reg = Registry::new(); + reg.seed_ids_past(&store.machine()); + assert_eq!(reg.alloc_id(), 8, "past the highest id the tree names"); + + // A seed can only move the counter forward. + reg.seed_ids_past(&store.machine()); + assert_eq!(reg.alloc_id(), 9); + } + + /// A tree naming `u64::MAX` (a corrupted file, an absurd client seed) + /// must not panic the daemon at startup: `max + 1` overflowed in a debug + /// build, taking every pane on the machine down with a bookkeeping add. + #[test] + fn a_tree_naming_the_maximum_pane_id_does_not_panic_the_seed() { + use crate::core::machine::{Machine, PaneRecord}; + let reg = Registry::new(); + reg.seed_ids_past(&Machine { + workspaces: Vec::new(), + panes: vec![PaneRecord::new(u64::MAX)], + }); + // The counter parks at the ceiling — a bounded absurdity, not a crash. + assert_eq!(reg.alloc_id(), u64::MAX); + } + #[test] fn empty_registry_get_remove_list_are_empty() { let reg = Registry::new(); @@ -1006,11 +1243,9 @@ mod tests { let (client, server) = UnixStream::pair().unwrap(); let writer = spawn_writer(rx, server, Arc::new(crate::daemon::pane::OutputGate::new())); - // Kill the client end first, then hand the writer a message: the + // Kill the client end first, then hand the writer messages: an // encode hits a broken pipe and the thread must bail on its own. drop(client); - tx.send(DaemonMsg::Output(b"into the void".to_vec())) - .unwrap(); // Bounded poll rather than a bare `join()`: the sender stays alive // for the whole wait, so only the write-failure path can finish the @@ -1020,8 +1255,16 @@ mod tests { // running the whole suite in parallel can leave this thread // unscheduled for seconds. A tight bound turns that into a flake // that says nothing about the behaviour under test. + // + // Kept fed rather than sent one message: the first write into a + // freshly-closed socket can *succeed* (the kernel has not + // processed the peer's close yet, especially under load), and a + // writer that swallowed it would park in `recv()` for the rest of + // the deadline. Only a later write is guaranteed to see the + // broken pipe, so the loop keeps offering them. let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); while !writer.is_finished() && std::time::Instant::now() < deadline { + let _ = tx.send(DaemonMsg::Output(b"into the void".to_vec())); thread::sleep(std::time::Duration::from_millis(5)); } assert!( diff --git a/crates/tty7-core/src/daemon/spawn.rs b/crates/tty7-core/src/daemon/spawn.rs index d6becab1..60be5f31 100644 --- a/crates/tty7-core/src/daemon/spawn.rs +++ b/crates/tty7-core/src/daemon/spawn.rs @@ -73,17 +73,6 @@ pub fn take_mismatched_daemon() -> Option { /// identity of a daemon that is no longer the one answering. static LOCAL_DAEMON: std::sync::Mutex> = std::sync::Mutex::new(None); -/// The serving daemon's process identity, when it reports one. `None` means -/// "unknown" (an older daemon, or nothing running) — callers must treat that -/// as "no instance check possible", never as a mismatch. -pub fn local_daemon_instance() -> Option { - let guard = LOCAL_DAEMON.lock().ok()?; - guard - .as_ref() - .map(|v| v.instance.clone()) - .filter(|i| !i.is_empty()) -} - /// Whether the serving daemon advertises `feature` /// (e.g. [`crate::daemon::protocol::FEATURE_PANE_OWNER`]). `false` when /// nothing is known — the safe answer, because every capability gated on this diff --git a/crates/tty7-core/src/daemon/transport.rs b/crates/tty7-core/src/daemon/transport.rs index 9d94ac40..7b263c7f 100644 --- a/crates/tty7-core/src/daemon/transport.rs +++ b/crates/tty7-core/src/daemon/transport.rs @@ -23,6 +23,12 @@ //! `authenticate` rejects any connection that doesn't match — so only a process //! that could read the user-private file gets in. See [`imp_windows`]. //! +//! One daemon serves two dialects on two listeners — panes and control — which +//! on Unix are two socket files and here are two port files, each with its own +//! ephemeral port and its own token (`bind_endpoint`). The control listener's is +//! `control.port`; [`crate::host::server`] owns it, since that is where the +//! dialect lives. +//! //! All endpoint state lives under the (config-dir-aware) config directory, so //! `--config-dir` / `cargo dev` isolation reaches the daemon on every platform. @@ -376,8 +382,16 @@ mod imp_windows { /// Length of the per-daemon auth token, in bytes. 256 bits from the OS CSPRNG: /// unguessable without reading the (user-private) port file, so possessing it /// proves the connecting process runs as the same user. - const TOKEN_LEN: usize = 32; - type Token = [u8; TOKEN_LEN]; + pub const TOKEN_LEN: usize = 32; + pub type Token = [u8; TOKEN_LEN]; + + /// The pane dialect's endpoint marker. + /// + /// Named, because one daemon serves two dialects on two listeners — the + /// same shape it has on Unix, where they are two socket files — and each + /// records its own port and mints its own token. See + /// [`bind_endpoint`]. + const PANE_PORT_FILE: &str = "daemon.port"; /// This daemon's auth token, minted once at [`bind`] and checked by /// [`authenticate`] on every accepted connection. A process global because the @@ -446,12 +460,21 @@ mod imp_windows { /// "endpoint exists" marker, and — being under the user-private config dir — /// its contents (the token) are readable only by the same user. fn port_path() -> Option { - config::config_path("daemon.port") + port_path_named(PANE_PORT_FILE) + } + + /// [`port_path`] for any of this daemon's endpoints. + pub fn port_path_named(file: &str) -> Option { + config::config_path(file) } /// Read the recorded loopback port + token, if the port file exists and parses. fn read_port_file() -> Option<(u16, Token)> { - let path = port_path()?; + read_port_file_named(PANE_PORT_FILE) + } + + fn read_port_file_named(file: &str) -> Option<(u16, Token)> { + let path = port_path_named(file)?; let contents = std::fs::read_to_string(path).ok()?; parse_port_file(&contents) } @@ -491,6 +514,13 @@ mod imp_windows { authenticate_with(stream, expected) } + /// [`authenticate`] for a connection on one of this daemon's *other* + /// endpoints, whose token its listener holds rather than reading from the + /// process global. + pub fn check_endpoint_token(stream: &mut Stream, expected: &Token) -> io::Result<()> { + authenticate_with(stream, expected) + } + /// Pure core of [`authenticate`]: read a token off `reader` and compare it to /// `expected`. Split out so the handshake is testable without a live daemon or /// the process-global token. @@ -531,8 +561,27 @@ mod imp_windows { /// this daemon's freshly-minted auth token — in the port file so the GUI can /// find *and* authenticate to it. Ensures the config dir exists first. pub fn bind() -> anyhow::Result { - let path = port_path() - .ok_or_else(|| anyhow::anyhow!("could not resolve daemon port path (no config dir)"))?; + // Mint the pane dialect's token once for this daemon's lifetime; + // `authenticate` checks against the same value. + let token = *DAEMON_TOKEN.get_or_init(make_token); + let (listener, _) = bind_named(PANE_PORT_FILE, token)?; + Ok(listener) + } + + /// [`bind`] for a second dialect in this same daemon: its own ephemeral + /// port, its own token, its own marker file beside `daemon.port`. + /// + /// Answers the token as well as the listener, because a second endpoint has + /// nowhere process-global to keep it — its accept loop holds it and checks + /// each connection with [`check_endpoint_token`]. One token per endpoint, so + /// a client that learned one cannot present it to the other. + pub fn bind_endpoint(file: &str) -> anyhow::Result<(Listener, Token)> { + bind_named(file, make_token()) + } + + fn bind_named(file: &str, token: Token) -> anyhow::Result<(Listener, Token)> { + let path = port_path_named(file) + .ok_or_else(|| anyhow::anyhow!("could not resolve {file} path (no config dir)"))?; if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); } @@ -544,19 +593,43 @@ mod imp_windows { .local_addr() .map_err(|e| anyhow::anyhow!("could not read bound port: {e}"))? .port(); - // Mint the token once for this daemon's lifetime; `authenticate` checks - // against the same value. Written to the port file so a client that can - // read it (same user) can present it back. - let token = DAEMON_TOKEN.get_or_init(make_token); - let contents = format!("{port}\n{}", encode_token(token)); + // Written to the marker file so a client that can read it (same user) + // can present it back. + let contents = format!("{port}\n{}", encode_token(&token)); std::fs::write(&path, contents) .map_err(|e| anyhow::anyhow!("could not write port file {}: {e}", path.display()))?; - Ok(listener) + Ok((listener, token)) + } + + /// [`connect`] to one of the daemon's other endpoints, presenting the token + /// its marker file records. `NotFound` means nothing is listening there — the + /// same "nobody home" every caller treats as "not running". + pub fn connect_endpoint(file: &str) -> io::Result { + let (port, token) = read_port_file_named(file) + .filter(|(p, _)| *p != 0) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, format!("no {file} file")))?; + let mut stream = TcpStream::connect(loopback(port))?; + tune(&stream); + stream.write_all(&token)?; + Ok(stream) + } + + /// Remove another endpoint's marker file. Best effort, like + /// [`remove_stale_endpoint`]. + pub fn remove_endpoint(file: &str) { + if let Some(path) = port_path_named(file) { + let _ = std::fs::remove_file(path); + } } /// A human-readable description of the endpoint, for log messages. pub fn endpoint_display() -> String { - match read_port_file() { + endpoint_display_named(PANE_PORT_FILE) + } + + /// [`endpoint_display`] for another of this daemon's endpoints. + pub fn endpoint_display_named(file: &str) -> String { + match read_port_file_named(file) { Some((port, _)) => format!("127.0.0.1:{port}"), None => "127.0.0.1:".to_string(), } @@ -665,6 +738,61 @@ mod imp_windows { bad.join().unwrap(); } + /// The daemon's *second* endpoint — the control dialect's, bound by + /// [`crate::host::server`] — is a separate port with a separate token, + /// recorded in a separate file. Two listeners, two boundaries: a client + /// that learned the pane endpoint's token has not thereby been given the + /// one behind which the whole workspace tree lives. + #[test] + fn a_second_endpoint_gets_its_own_port_and_token() { + // The name `host::server` uses; spelled out rather than imported so + // the transport does not depend on the dialect above it. + const CONTROL: &str = "control.port"; + + let dir = std::env::temp_dir().join(format!("tty7-wintok-{}", std::process::id())); + std::fs::create_dir_all(&dir).ok(); + config::set_config_dir(dir); + remove_endpoint(CONTROL); + + let (listener, token) = bind_endpoint(CONTROL).expect("bind the second endpoint"); + let port = listener.local_addr().unwrap().port(); + let recorded = + std::fs::read_to_string(port_path_named(CONTROL).unwrap()).expect("marker file"); + let (file_port, file_token) = parse_port_file(&recorded).expect("marker file parses"); + assert_eq!(file_port, port, "the file records the port actually bound"); + assert_eq!( + file_token, token, + "and the token the listener will check for" + ); + + // A client that could read the file gets in — that read is the whole + // proof of same-user, which is what filesystem permissions give the + // Unix socket for free. + let good = std::thread::spawn(move || connect_endpoint(CONTROL).unwrap()); + let (mut server_side, _) = listener.accept().unwrap(); + assert!(check_endpoint_token(&mut server_side, &token).is_ok()); + let _keep = good.join().unwrap(); + + // Anything else is refused before a frame is parsed — including the + // other endpoint's token, which is why they are minted separately. + let mut foreign = token; + foreign[0] ^= 0xff; + let bad = std::thread::spawn(move || { + let mut s = TcpStream::connect(loopback(port)).unwrap(); + let _ = s.write_all(&foreign); + }); + let (mut server_side, _) = listener.accept().unwrap(); + assert_eq!( + check_endpoint_token(&mut server_side, &token) + .unwrap_err() + .kind(), + io::ErrorKind::PermissionDenied + ); + bad.join().unwrap(); + + remove_endpoint(CONTROL); + } + /// Full wiring over the real config-dir path: `bind` writes a parseable /// `\n` file and seeds the process token, and the public /// `authenticate` (which reads that process token) then accepts a client diff --git a/crates/tty7-core/src/host/remote.rs b/crates/tty7-core/src/host/remote.rs index 447afc96..289dcc2e 100644 --- a/crates/tty7-core/src/host/remote.rs +++ b/crates/tty7-core/src/host/remote.rs @@ -180,7 +180,7 @@ impl RemoteHost { } /// The underlying connection, for callers that need to speak control - /// directly (the workspace store, once it exists). + /// directly (the machine-tree verbs). pub fn client(&self) -> &Arc { &self.client } diff --git a/crates/tty7-core/src/host/server.rs b/crates/tty7-core/src/host/server.rs index 4001719e..3f53cfdc 100644 --- a/crates/tty7-core/src/host/server.rs +++ b/crates/tty7-core/src/host/server.rs @@ -46,7 +46,7 @@ use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; use std::sync::{Arc, Condvar, Mutex}; use std::time::Duration; -use crate::core::workspace_store::{Attachment, SubscriberId, Subscription, WorkspaceStore}; +use crate::core::machine::{self, Attachment, MachineStore}; use crate::daemon::control::{ CONTROL_VERSION, ControlClientMsg, ControlEvent, ControlHello, ControlHelloOk, ControlReply, ControlRequest, ControlServerMsg, GIT_STREAM_CHUNK, GIT_STREAM_CHUNK_MAX, LinkShutdown, @@ -72,15 +72,21 @@ pub const WORKER_LINGER: Duration = Duration::from_secs(10); /// would each be answered long after the client's own deadline gave up on them. pub const MAX_QUEUED: usize = 1024; -/// `WorkspaceChanged` pushes one connection will let pile up before it starts -/// dropping them. +/// `Layout` deltas one connection will let queue before it starts dropping. /// -/// Dropping is safe here in a way it is not for a watch batch: the event says -/// only "workspace `id` changed, refetch", so a client that has one queued -/// already learns everything a second one would tell it. The cap exists so a -/// peer that has stopped reading its socket cannot turn another client's -/// `WorkspacePut` into unbounded memory. -pub const WORKSPACE_EVENT_QUEUE: usize = 64; +/// A delta is *not* self-superseding — a dropped one leaves the peer's +/// picture of the tree wrong until it re-pulls. The cap is still +/// right, for the same reason as the watch caps: a peer +/// that has stopped reading its socket must not turn another client's edit +/// into unbounded server memory. What makes the drop survivable is that it is +/// *announced*: the connection is flagged lagged, and the forwarder replaces +/// the whole superseded backlog with a single +/// [`ControlEvent::LayoutResync`], so a client that lost one edit re-pulls +/// instead of silently mirroring a tree it is no longer looking at. A peer too +/// wedged to hear even that is already inside +/// [`crate::daemon::control::KEEPALIVE_DEAD_AFTER`] of losing the link, and +/// every reconnect begins with a full pull. +pub const LAYOUT_EVENT_QUEUE: usize = 1024; // --------------------------------------------------------------------------- // Entry points @@ -90,17 +96,16 @@ pub const WORKSPACE_EVENT_QUEUE: usize = 64; /// /// Separate from the `SharedHost` argument because the two are genuinely /// independent roles, and the handshake says so: a box can back a remote -/// workspace's file tree without owning any workspace records (that is every -/// server today, and it is what [`Services::default`] produces), and the -/// `workspace-store` capability bit is advertised only when this actually -/// carries a store. A client therefore learns from the handshake whether asking -/// is worth a round trip. +/// workspace's file tree without owning any workspace tree (which is what +/// [`Services::default`] produces), and the `machine-tree` capability bit is +/// advertised only when this actually carries one. A client therefore learns +/// from the handshake whether asking is worth a round trip. #[derive(Clone, Default)] pub struct Services { - /// The machine's workspace records. `None` answers every `Workspace*` - /// request with "this server does not serve the workspace store" — the same - /// answer a build from before M5 gives. - pub workspaces: Option>, + /// The machine's own workspace *tree* — the daemon-owned structure the + /// semantic operations edit. `None` answers every tree verb with "this + /// server does not serve the machine tree". + pub machine: Option>, /// Who currently holds each workspace, and how to reach them. Shared across /// every connection this server accepts — that sharing *is* the takeover: /// two connections can only displace each other if they are looking at one @@ -109,15 +114,15 @@ pub struct Services { } impl Services { - /// Host RPC only, no workspace store. + /// Host RPC only, no machine tree. pub fn none() -> Services { Services::default() } - /// Host RPC plus the workspace store. - pub fn with_workspaces(store: Arc) -> Services { + /// Host RPC plus the machine tree. + pub fn with_machine(store: Arc) -> Services { Services { - workspaces: Some(store), + machine: Some(store), attachments: Arc::new(AttachRegistry::default()), } } @@ -129,11 +134,11 @@ impl Services { /// The live half of the attachment record. /// -/// [`Attachment`](crate::core::workspace_store::Attachment) in the store is the +/// [`Attachment`](crate::core::machine::Attachment) in the machine tree is the /// *data* — token, hostname, since — and answers "who holds this workspace". /// This is the *handles*: the sink a `Preempted` push goes out on and the /// shutdown that closes the displaced session's link. They are separate because -/// the store lives in `core` and knows nothing about sockets, and because an +/// the tree lives in `core` and knows nothing about sockets, and because an /// attachment must never be written to the file (a stale one on disk would have /// the server report a takeover against a client that no longer exists). /// @@ -147,7 +152,7 @@ pub struct AttachRegistry { /// Held across *both* tables for the length of one handover. /// /// A takeover moves two things that live in different places: this - /// registry's handles, and the `WorkspaceStore`'s record. Each is + /// registry's handles, and the `MachineStore`'s record. Each is /// internally locked, and that is not enough — two clients attaching to one /// workspace at the same moment can each win a different table, after which /// the store names a session the registry has already evicted and no @@ -391,9 +396,11 @@ where } }; - // Subscribed before the first request is read, so a change another client - // makes while this one is still listing cannot slip through the gap. - let workspace_sub = subscribe_workspaces(&services, &sink); + // Subscribed before the first request is read, so an edit another client + // makes while this one is still pulling cannot slip through the gap: a + // full pull issued after this point can race a delta (the client tolerates + // that), but an edit can never fall between subscription and first read. + let machine_sub = subscribe_machine(&services, &sink); let conn = Arc::new(Conn { host, @@ -404,8 +411,8 @@ where next_watch: AtomicU64::new(1), git_streams: Arc::new(AtomicUsize::new(0)), pool: Pool::new(), - workspaces: services.workspaces.clone(), - workspace_origin: workspace_sub.as_ref().map(Subscription::id), + machine: services.machine.clone(), + machine_origin: machine_sub.as_ref().map(machine::Subscription::id), attachments: Arc::clone(&services.attachments), id: NEXT_CONN.fetch_add(1, Ordering::Relaxed), holder: Holder { @@ -431,7 +438,7 @@ where // Teardown, in the order that makes each step meaningful: stop accepting // work, drop the watches (which stops the pushes and releases the OS - // watchers), release anything this session still holds, drop the workspace + // watchers), release anything this session still holds, drop the machine // subscription (which ends its forwarder), then close the link so anything // still writing fails fast rather than blocking on a peer that is gone. conn.pool.close(); @@ -440,7 +447,7 @@ where .unwrap_or_else(|e| e.into_inner()) .clear(); conn.release_all_workspaces(); - drop(workspace_sub); + drop(machine_sub); sink.retire(); let _ = shutdown.shutdown_link(); @@ -492,17 +499,17 @@ fn handshake( }; // Advertised from what this server actually carries, not from what the - // build can do. A client that sees `workspace-store` missing knows not to + // build can do. A client that sees `machine-tree` missing knows not to // spend a round trip asking, and — the case that matters — a machine - // serving only a file tree does not claim to own workspace records it has + // serving only a file tree does not claim to own a workspace tree it has // no file for. let mut features = vec![ feature::CONTROL.to_string(), feature::HOST_RPC.to_string(), feature::STDIO_BRIDGE.to_string(), ]; - if services.workspaces.is_some() { - features.push(feature::WORKSPACE_STORE.to_string()); + if services.machine.is_some() { + features.push(feature::MACHINE_TREE.to_string()); } sink.send(&ControlServerMsg::HelloOk(ControlHelloOk { @@ -534,7 +541,7 @@ static NEXT_CONN: AtomicU64 = AtomicU64::new(1); /// The takeover, server side: claim `workspace` for this connection and /// tell whoever held it. /// -/// The order is the whole behaviour. The store's record moves first (so a +/// The order is the whole behaviour. The tree's record moves first (so a /// concurrent `attachment()` never shows the workspace as free), the registry's /// handles move under one lock, and only then is the displaced session told — /// outside every lock, because writing to a peer that has stopped reading must @@ -550,17 +557,30 @@ fn attach_workspace( workspace: &str, dedicated: bool, ) -> io::Result> { - let store = conn.workspaces()?; + // The attach verbs predate the typed tree, so the id arrives as a string. + // The data half of the attachment lives in the machine tree; a server + // without one answers the refusal a tree-less server always has. + if conn.machine.is_none() { + return Err(io::Error::other( + "this server does not serve the machine tree", + )); + } + let tree_id: Option = workspace.parse().ok(); let (displaced, evicted) = { - // Both tables move under one lock. Held only across the two moves — + // Both tables move under one lock. Held only across the moves — // the notice below goes out with nothing held, because writing to a // peer that has stopped reading must not hold up the next client's // attach. let _handover = conn.attachments.handover(); - let displaced = store.attach( - workspace, - Attachment::new(conn.holder.token.clone(), conn.holder.hostname.clone()), - ); + let attachment = Attachment::new(conn.holder.token.clone(), conn.holder.hostname.clone()); + // A workspace the tree does not list (or an id that is not a uuid) + // records no data half; the registry's live handles still move, so + // the takeover behaviour is identical either way, and the tree's + // record appears the moment the workspace does. + let displaced = match (&conn.machine, tree_id) { + (Some(machine), Some(id)) => machine.attach(id, attachment), + _ => None, + }; let evicted = conn .attachments .claim(workspace, conn.id, &conn.holder, dedicated); @@ -596,14 +616,21 @@ fn attach_workspace( /// Release `workspace` if this connection still holds it. /// -/// Token-checked in the store *and* connection-checked in the registry, which +/// Token-checked in the tree *and* connection-checked in the registry, which /// are the same guard seen from both halves: a session that was preempted and /// then tidied up must not evict the client that took over from it. fn detach_workspace(conn: &Arc, workspace: &str) -> io::Result { - let store = conn.workspaces()?; + if conn.machine.is_none() { + return Err(io::Error::other( + "this server does not serve the machine tree", + )); + } let _handover = conn.attachments.handover(); let released = conn.attachments.release(workspace, conn.id); - let forgotten = store.detach(workspace, &conn.holder.token); + let forgotten = match (&conn.machine, workspace.parse().ok()) { + (Some(machine), Some(id)) => machine.detach(id, &conn.holder.token), + _ => false, + }; Ok(released || forgotten) } @@ -837,50 +864,6 @@ fn run_request( (ReplyOk::Unit, Vec::new()) } - // ----- workspace store ----------------------------------------------- - // Records cross as opaque JSON: the client owns the schema, and a - // server that parsed them would drop any field it was too old to know - // about on the next write. See `core::workspace_store`. - ControlRequest::WorkspaceList => ( - ReplyOk::Json(serde_json::Value::Array(conn.workspaces()?.list())), - Vec::new(), - ), - ControlRequest::WorkspaceGet { id } => { - // `NotFound` rather than a `null` payload: "there is no such - // workspace" and "there is one and it is empty" are different - // answers, and a client that conflated them would helpfully - // overwrite a record it failed to read. - let record = conn.workspaces()?.get(&id).ok_or_else(|| { - io::Error::new( - io::ErrorKind::NotFound, - format!("no workspace {id} on this machine"), - ) - })?; - (ReplyOk::Json(record), Vec::new()) - } - ControlRequest::WorkspacePut { id, json } => { - conn.workspaces()?.put(&id, json, conn.workspace_origin)?; - (ReplyOk::Unit, Vec::new()) - } - ControlRequest::WorkspaceDelete { id } => { - // Deleting what is not there is success — a delete that raced - // another client's delete has got what it asked for. - let store = conn.workspaces()?; - { - // The store drops its own attachment on delete; the registry - // has to be told, and under the same lock, or the two disagree - // with no race needed at all. Left behind, the stale `Live` - // entry means the *next* client to attach a workspace with this - // id evicts a session nobody displaced — and, that entry being - // dedicated, closes its whole link, taking every other - // workspace on it down too. - let _handover = conn.attachments.handover(); - store.delete(&id, conn.workspace_origin)?; - conn.attachments.forget_workspace(&id); - } - (ReplyOk::Unit, Vec::new()) - } - // ----- attachment (D8) ----------------------------------- ControlRequest::WorkspaceAttach { id } => ( ReplyOk::Attached { @@ -895,6 +878,157 @@ fn run_request( detach_workspace(conn, &id)?; (ReplyOk::Unit, Vec::new()) } + + // ----- machine tree -------------------------------------------------- + // Each arm is a thin translation: the store validates, mutates, + // persists and broadcasts (with this connection's origin excluded), + // and its refusals cross the wire as the client-visible errors they + // already are. + ControlRequest::MachineGet => ( + ReplyOk::MachineTree(Box::new(conn.machine()?.machine())), + Vec::new(), + ), + ControlRequest::WorkspaceTree { workspace } => ( + ReplyOk::WorkspaceTree(Box::new(conn.machine()?.workspace(workspace)?)), + Vec::new(), + ), + ControlRequest::WorkspaceCreate { name, workspace } => ( + ReplyOk::WorkspaceTree(Box::new(conn.machine()?.workspace_create( + workspace, + name, + conn.machine_origin, + )?)), + Vec::new(), + ), + ControlRequest::WorkspaceRename { workspace, name } => { + conn.machine()? + .workspace_rename(workspace, name, conn.machine_origin)?; + (ReplyOk::Unit, Vec::new()) + } + ControlRequest::WorkspaceRemove { workspace } => { + let store = conn.machine()?; + let panes = { + // The tree drops its own attachment with the workspace; the + // attach registry forgets it under the handover lock, or a + // stale dedicated entry would one day close an innocent link. + let _handover = conn.attachments.handover(); + let panes = store.workspace_delete(workspace, conn.machine_origin)?; + conn.attachments.forget_workspace(&workspace.to_string()); + panes + }; + (ReplyOk::Panes(panes), Vec::new()) + } + ControlRequest::WorkspaceTouch { workspace } => { + conn.machine()? + .workspace_touch(workspace, conn.machine_origin)?; + (ReplyOk::Unit, Vec::new()) + } + ControlRequest::WorkspaceSetActiveTab { workspace, tab } => { + conn.machine()? + .workspace_set_active_tab(workspace, tab, conn.machine_origin)?; + (ReplyOk::Unit, Vec::new()) + } + ControlRequest::TabCreate { + workspace, + at, + pane, + tab, + } => ( + ReplyOk::TabTree(Box::new(conn.machine()?.tab_create( + workspace, + at.map(clamp_usize), + pane, + tab, + conn.machine_origin, + )?)), + Vec::new(), + ), + ControlRequest::TabClose { workspace, tab } => ( + ReplyOk::Panes( + conn.machine()? + .tab_close(workspace, tab, conn.machine_origin)?, + ), + Vec::new(), + ), + ControlRequest::TabRename { + workspace, + tab, + name, + } => { + conn.machine()? + .tab_rename(workspace, tab, name, conn.machine_origin)?; + (ReplyOk::Unit, Vec::new()) + } + ControlRequest::TabMove { workspace, tab, to } => { + conn.machine()? + .tab_move(workspace, tab, clamp_usize(to), conn.machine_origin)?; + (ReplyOk::Unit, Vec::new()) + } + ControlRequest::TabSetGroup { + workspace, + tab, + group, + } => { + conn.machine()? + .tab_set_group(workspace, tab, group, conn.machine_origin)?; + (ReplyOk::Unit, Vec::new()) + } + ControlRequest::PaneSplit { + workspace, + pane, + axis, + ratio, + new, + first, + } => { + conn.machine()?.pane_split( + workspace, + pane, + axis, + ratio, + new, + first, + conn.machine_origin, + )?; + (ReplyOk::Unit, Vec::new()) + } + ControlRequest::PaneClose { workspace, pane } => ( + ReplyOk::Panes( + conn.machine()? + .pane_close(workspace, pane, conn.machine_origin)?, + ), + Vec::new(), + ), + ControlRequest::PaneSetRatio { + workspace, + tab, + path, + ratio, + } => { + conn.machine()? + .pane_set_ratio(workspace, tab, path, ratio, conn.machine_origin)?; + (ReplyOk::Unit, Vec::new()) + } + ControlRequest::PaneMove { + workspace, + pane, + to, + axis, + first, + } => { + conn.machine()? + .pane_move(workspace, pane, to, axis, first, conn.machine_origin)?; + (ReplyOk::Unit, Vec::new()) + } + ControlRequest::PaneReplace { + workspace, + old, + new, + } => { + conn.machine()? + .pane_replace(workspace, old, new, conn.machine_origin)?; + (ReplyOk::Unit, Vec::new()) + } }) } @@ -939,11 +1073,11 @@ struct Conn { /// which gives its slot back on the way out — see [`StreamSlot`]. git_streams: Arc, pool: Pool, - /// The machine's workspace records, when this server serves them. - workspaces: Option>, - /// This connection's subscriber id, so its own writes do not come back to - /// it as `WorkspaceChanged` pushes. `None` when there is no store. - workspace_origin: Option, + /// The machine's workspace tree, when this server serves it. + machine: Option>, + /// This connection's tree-subscriber id — origin exclusion, so a tree + /// operation's own `Layout` delta never comes back to its writer. + machine_origin: Option, /// Shared with every other connection this server accepts — see /// [`AttachRegistry`]. attachments: Arc, @@ -955,30 +1089,27 @@ struct Conn { } impl Conn { - /// The workspace store, or the error a server without one answers. - /// - /// The message is deliberately the one the unimplemented slots gave before - /// M5: a client talking to a file-tree-only server must get the same answer - /// whether that server predates the store or simply was not given one. - fn workspaces(&self) -> io::Result<&Arc> { - self.workspaces + /// The machine tree, or the refusal a server not carrying one answers. + /// The client's cue is the `machine-tree` capability bit; this is the + /// answer for one that asked anyway. + fn machine(&self) -> io::Result<&Arc> { + self.machine .as_ref() - .ok_or_else(|| io::Error::other("this server does not serve the workspace store")) + .ok_or_else(|| io::Error::other("this server does not serve the machine tree")) } /// Give up every workspace this connection still holds, at teardown. /// /// Connection-scoped, so a workspace that was taken over from this session /// earlier is already gone from the registry and is not touched — the exact - /// case the store's token check exists for, seen from the other side. + /// case the tree's token check exists for, seen from the other side. fn release_all_workspaces(&self) { let _handover = self.attachments.handover(); let released = self.attachments.release_conn(self.id); - let Some(store) = self.workspaces.as_ref() else { - return; - }; for workspace in released { - store.detach(&workspace, &self.holder.token); + if let (Some(machine), Some(id)) = (&self.machine, workspace.parse().ok()) { + machine.detach(id, &self.holder.token); + } } } @@ -1034,7 +1165,7 @@ impl Conn { ControlServerMsg::Response { req_id, reply } }; // Encoded before anything is written, so a reply this server cannot put - // on the wire — a `SearchHit` whose path is not UTF-8, a `WorkspaceList` + // on the wire — a `SearchHit` whose path is not UTF-8, a `MachineGet` // grown past `MAX_FRAME` — becomes an error the client *receives*. // Dropping it instead leaves the client waiting out the request's whole // deadline (20s for a search, and again on the next keystroke) for a @@ -1260,45 +1391,95 @@ impl Conn { } } -/// Subscribe this connection to the workspace store's changes, if there is one. +/// Subscribe this connection to the machine tree's deltas, if there is one. /// /// Two hops rather than one, and the split is the point. The store's callback -/// runs on the thread of *whichever connection made the change*, so it does -/// nothing but enqueue; the forwarder thread is what actually writes, and a -/// peer that has stopped reading stalls only its own forwarder. Calling -/// `Sink::send` straight from the callback would have one wedged client hold up -/// every other client's `WorkspacePut`. -fn subscribe_workspaces(services: &Services, sink: &Arc) -> Option { - let store = services.workspaces.as_ref()?; - let (tx, rx) = smol::channel::bounded::(WORKSPACE_EVENT_QUEUE); - let subscription = store.subscribe(Arc::new(move |id: &str| { - // Never blocks. A full queue means this peer is already behind on a - // signal that only says "refetch", and the notice sitting in the queue - // says it just as well. - let _ = tx.try_send(id.to_string()); - })); - spawn_workspace_forwarder(rx, Arc::clone(sink)); +/// runs on the thread of *whichever connection made the change*, so it only +/// enqueues; the forwarder thread is what actually writes, and a peer that has +/// stopped reading stalls nothing but its own forwarder. Calling `Sink::send` +/// straight from the callback would have one wedged client hold up every other +/// client's edit. The queue-full case is documented on [`LAYOUT_EVENT_QUEUE`]. +fn subscribe_machine(services: &Services, sink: &Arc) -> Option { + let store = services.machine.as_ref()?; + let (tx, rx) = smol::channel::bounded::<(String, machine::LayoutDelta)>(LAYOUT_EVENT_QUEUE); + // Set on a drop, consumed by the forwarder: a dropped delta leaves the + // peer's mirror wrong forever, so it must be told to re-pull rather than + // left to mirror a tree it is no longer looking at. + let lagged = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let saw_drop = Arc::clone(&lagged); + let subscription = store.subscribe(Arc::new( + move |workspace: &str, delta: &machine::LayoutDelta| { + if tx.try_send((workspace.to_string(), delta.clone())).is_err() { + saw_drop.store(true, Ordering::Release); + log::warn!( + "dropping a layout delta for a peer {LAYOUT_EVENT_QUEUE} deltas behind; \ + it will be told to resync" + ); + } + }, + )); + spawn_layout_forwarder(rx, Arc::clone(sink), lagged); Some(subscription) } -/// Relay workspace changes to the peer as `WorkspaceChanged` pushes. +/// Relay tree deltas to the peer as `Layout` pushes — prefixed by a +/// [`ControlEvent::LayoutResync`] **in place of** everything the queue still +/// holds, whenever it dropped one. /// -/// Ends on its own when the `Subscription` is dropped: that removes the closure -/// holding the sender, which closes the channel. Same shape, and the same -/// reason, as [`spawn_watch_forwarder`]. -fn spawn_workspace_forwarder(rx: smol::channel::Receiver, sink: Arc) { +/// Announcing and then draining the backlog would be worse than not announcing +/// at all. The queue is FIFO, so a drop discards the *newest* delta and +/// everything still queued is **older** than the gap: the peer would re-pull the +/// tree on the resync and then apply a stretch of history from before it, +/// `TabRestructured` replacing whole tabs with the shapes they had — a window +/// silently reverting to a layout nobody is looking at, with mirror and window +/// in agreement so nothing triggers recovery a second time. Every queued delta +/// is by construction already in the tree the peer is about to pull, so the +/// backlog is not lost information; it is superseded information. +/// +/// The flag-then-announce order is safe by construction: `lagged` is only set +/// when the queue is full, so a delivery always follows a drop and the +/// announcement never waits on a quiet tree. +/// +/// Ends on its own when the `Subscription` is dropped: that removes the +/// closure holding the sender, which closes the channel. Same shape, and the +/// same reason, as [`spawn_watch_forwarder`]. +fn spawn_layout_forwarder( + rx: smol::channel::Receiver<(String, machine::LayoutDelta)>, + sink: Arc, + lagged: Arc, +) { let spawned = std::thread::Builder::new() - .name("tty7-control-workspace".into()) + .name("tty7-control-layout".into()) .spawn(move || { - while let Ok(id) = rx.recv_blocking() { - let event = ControlEvent::WorkspaceChanged { id }; + while let Ok((workspace, delta)) = rx.recv_blocking() { + if lagged.swap(false, Ordering::AcqRel) { + // This delta and everything behind it predate the gap. Drop + // the lot and send the one event that repairs a peer whose + // history has a hole in it. + let mut superseded = 1; + while rx.try_recv().is_ok() { + superseded += 1; + } + log::info!( + "dropping {superseded} superseded layout delta(s) and asking the peer \ + to resync" + ); + if sink + .send(&ControlServerMsg::Event(ControlEvent::LayoutResync)) + .is_err() + { + return; + } + continue; + } + let event = ControlEvent::Layout { workspace, delta }; if sink.send(&ControlServerMsg::Event(event)).is_err() { return; } } }); if let Err(e) = spawned { - log::warn!("could not start the workspace-change forwarder: {e}"); + log::warn!("could not start the layout forwarder: {e}"); } } @@ -1759,10 +1940,9 @@ mod sock { /// [`serve_listener`], with the extra services every connection gets. /// - /// One [`WorkspaceStore`](crate::core::workspace_store::WorkspaceStore) - /// shared by every connection, which is what makes a change on one visible - /// to the others: two stores over one file would each believe their own - /// copy and the last save would win silently. + /// One [`MachineStore`] shared by every connection, which is what makes a + /// change on one visible to the others: two stores over one file would + /// each believe their own copy and the last save would win silently. pub fn serve_listener_with(listener: UnixListener, host: SharedHost, services: Services) { for stream in listener.incoming() { match stream { @@ -1818,6 +1998,140 @@ pub use sock::{ spawn_control_listener, spawn_control_listener_with, }; +/// The machine-local control endpoint on Windows, which has no Unix sockets. +/// +/// The same two-listeners-one-daemon shape as [`sock`], over the transport the +/// pane dialect already uses on this platform: a loopback `TcpListener` on an +/// OS-assigned port, recorded in a user-private marker file next to +/// `daemon.port` together with a 256-bit token every connection must present. +/// Loopback is reachable by any local process, so the token is the access +/// boundary here exactly as file permissions are on Unix — see +/// [`crate::daemon::transport`], whose machinery this reuses rather than +/// re-deriving. +/// +/// Its own port and its own token, not the pane listener's: the two dialects are +/// independent services, and a client that learned one endpoint's token has not +/// thereby been granted the other. +#[cfg(windows)] +mod wsock { + use super::*; + use crate::daemon::transport; + use std::net::TcpListener; + + /// Where the control listener records its port and token, beside the pane + /// dialect's `daemon.port`. + pub const CONTROL_PORT_FILE: &str = "control.port"; + + /// The marker file's path — the Windows answer to `control_socket_path`, + /// and what a log line naming the endpoint should print. + pub fn control_endpoint_path() -> io::Result { + transport::port_path_named(CONTROL_PORT_FILE).ok_or_else(|| { + io::Error::other("no config directory to record the control endpoint in") + }) + } + + /// Bind the control endpoint and serve it on a background thread, one thread + /// per connection. Returns the marker file it recorded itself in. + /// + /// Every accepted connection is authenticated *before* a frame is parsed: + /// what is behind this endpoint is `ReadFile` / `WriteFile` / `Git` on + /// arbitrary paths as this user, plus the machine's workspace tree. + pub fn spawn_control_listener_with( + host: SharedHost, + services: Services, + ) -> io::Result { + let path = control_endpoint_path()?; + // Refuse when one is already listening, exactly as + // [`bind_control_socket`](sock::bind_control_socket) does — and for a + // reason that bites harder here. Binding is what *writes* the marker + // file, so a second daemon that goes on to lose the pane-socket race + // (`run` bails with "already running") would have pointed every client + // on the machine at a listener that is about to exit. A marker left by a + // crashed daemon looks exactly like a live one, so the only way to tell + // is to connect: the same probe, with the same rare false positive if an + // unrelated process has since taken that ephemeral port. + if let Ok(live) = transport::connect_endpoint(CONTROL_PORT_FILE) { + drop(live); + return Err(io::Error::new( + io::ErrorKind::AddrInUse, + format!( + "a control server is already listening at {}", + transport::endpoint_display_named(CONTROL_PORT_FILE) + ), + )); + } + let (listener, token) = + transport::bind_endpoint(CONTROL_PORT_FILE).map_err(io::Error::other)?; + std::thread::Builder::new() + .name("tty7-control-listener".into()) + .spawn(move || serve_listener_with(listener, token, host, services))?; + Ok(path) + } + + /// [`spawn_control_listener_with`] with no extra services — host RPC only. + pub fn spawn_control_listener(host: SharedHost) -> io::Result { + spawn_control_listener_with(host, Services::none()) + } + + /// Serve control connections on `listener` until it fails, one thread per + /// connection, rejecting any that cannot present `token`. + pub fn serve_listener_with( + listener: TcpListener, + token: transport::Token, + host: SharedHost, + services: Services, + ) { + for stream in listener.incoming() { + match stream { + Ok(mut stream) => { + transport::tune(&stream); + let host = Arc::clone(&host); + let services = services.clone(); + let spawned = std::thread::Builder::new() + .name("tty7-control-conn".into()) + .spawn(move || { + // Before anything else: an unauthenticated peer is + // some other process on this machine, and it gets + // no dialect at all. + if let Err(e) = transport::check_endpoint_token(&mut stream, &token) { + log::warn!("control connection rejected: {e}"); + return; + } + if let Err(e) = serve_with(stream, host, services) { + log::warn!("control connection failed: {e}"); + } + }); + if let Err(e) = spawned { + log::warn!("could not start a control connection thread: {e}"); + } + } + // One bad accept must not take the server down; the daemon's own + // listener has behaved this way since it was written. + Err(e) => log::warn!("control accept failed: {e}"), + } + } + } + + /// Dial this machine's control endpoint, presenting the token from its + /// marker file. The client half of the boundary above; the GUI's local link + /// is its only caller. + pub fn connect_control() -> io::Result { + transport::connect_endpoint(CONTROL_PORT_FILE) + } + + /// Forget the endpoint marker — the daemon's shutdown path, so a stale file + /// does not send the next GUI at a port nobody is listening on. + pub fn remove_control_endpoint() { + transport::remove_endpoint(CONTROL_PORT_FILE); + } +} + +#[cfg(windows)] +pub use wsock::{ + CONTROL_PORT_FILE, connect_control, control_endpoint_path, remove_control_endpoint, + spawn_control_listener, spawn_control_listener_with, +}; + /// The pool is plain threads and channels, so unlike the rest of this file's /// tests — which need a Unix socket pair — these hold on every platform. #[cfg(test)] @@ -2091,10 +2405,27 @@ mod tests { } } + /// Services carrying a fresh machine tree — the shape every attach and + /// takeover test runs against, because the tree is where the attachment's + /// data half lives. fn workspace_services() -> (Services, tempfile::TempDir) { let dir = tempfile::TempDir::new().unwrap(); - let store = WorkspaceStore::open(dir.path().join("workspaces.json")); - (Services::with_workspaces(store), dir) + let store = MachineStore::open(dir.path().join(machine::MACHINE_FILE)); + (Services::with_machine(store), dir) + } + + /// A workspace created in `services`' tree, as the string id the attach + /// verbs carry. The tree only records an attachment for a workspace it + /// lists, so the takeover tests attach to a real one. + fn tree_workspace(services: &Services) -> String { + services + .machine + .as_ref() + .expect("workspace_services always carries a tree") + .workspace_create(None, None, None) + .expect("an empty tree accepts a workspace") + .id + .to_string() } // ----------------------------------------------------------------------- @@ -2575,18 +2906,17 @@ mod tests { } } - /// The workspace store's request slots exist on the wire (so M5 is additive) - /// but this server does not serve them, and says so instead of answering - /// with something that looks like an empty store. + /// A server not carrying the machine tree says so instead of answering + /// with something that looks like an empty machine. #[test] - fn the_workspace_store_is_declined_not_faked() { + fn the_machine_tree_is_declined_not_faked() { let p = pair(); let err = p .host .client() - .call(ControlRequest::WorkspaceList) + .call(ControlRequest::MachineGet) .unwrap_err(); - assert!(err.to_string().contains("workspace store"), "{err}"); + assert!(err.to_string().contains("machine tree"), "{err}"); } // ----------------------------------------------------------------------- @@ -3021,27 +3351,9 @@ mod tests { } // ----------------------------------------------------------------------- - // The workspace store + // Raw-wire helpers // ----------------------------------------------------------------------- - /// A store on a temp file, plus the directory keeping it alive. - fn temp_store() -> (Arc, tempfile::TempDir) { - let dir = tempfile::TempDir::new().unwrap(); - let store = WorkspaceStore::open(dir.path().join("workspaces.json")); - (store, dir) - } - - fn ws_record(id: &str, name: &str) -> serde_json::Value { - serde_json::json!({ - "id": id, - "name": name, - "session": {"active": 0, "tabs": [ - {"pane": {"Leaf": {"cwd": "/home/me/proj", "pane_id": 3}}} - ]}, - "last_active": 1_753_600_000u64, - }) - } - /// A stream slot is given back on every exit, so the per-connection ceiling /// is a limit on *concurrency* and never drifts into a permanent refusal. /// @@ -3161,7 +3473,7 @@ mod tests { } /// Issue one request and return its reply, ignoring any pushes that arrive - /// first — a `WorkspaceChanged` from another connection can legitimately + /// first — a `Layout` delta from another connection can legitimately /// interleave with this one's reply. fn ask(client: &mut UnixStream, req_id: u64, req: ControlRequest) -> ControlReply { ControlClientMsg::Request { req_id, req } @@ -3179,256 +3491,72 @@ mod tests { } } - fn ok_json(reply: ControlReply) -> serde_json::Value { - match reply { - ControlReply::Ok(ReplyOk::Json(v)) => v, - other => panic!("expected a Json reply, got {other:?}"), - } - } - - /// A server with no store answers the four slots the way it always has, and - /// says so in the handshake so a client need not ask to find out. - #[test] - fn a_server_without_a_store_advertises_nothing_and_refuses_politely() { - let (mut client, peer) = raw(); - assert!(!peer.has_feature(feature::WORKSPACE_STORE)); - assert!(peer.has_feature(feature::HOST_RPC)); - - match ask(&mut client, 1, ControlRequest::WorkspaceList) { - ControlReply::Err(e) => { - assert_eq!(e.kind, WireErrorKind::Other); - assert!( - e.msg.contains("does not serve the workspace store"), - "{e:?}" - ); - } - other => panic!("expected an error, got {other:?}"), - } - // And it is still a perfectly good file server afterwards: an - // unsupported request must not poison the connection. - assert!(matches!( - ask(&mut client, 2, ControlRequest::Ping), - ControlReply::Ok(ReplyOk::Pong) - )); - } - - /// The four RPCs, end to end over the wire, against a real file. - #[test] - fn the_four_workspace_rpcs_round_trip_over_the_wire() { - let (store, dir) = temp_store(); - let (mut client, peer) = raw_with(Services::with_workspaces(Arc::clone(&store))); - assert!(peer.has_feature(feature::WORKSPACE_STORE)); - - // Empty to begin with. - assert_eq!( - ok_json(ask(&mut client, 1, ControlRequest::WorkspaceList)), - serde_json::json!([]) - ); - - // Put two. - for (i, (id, name)) in [("w-a", "api"), ("w-b", "web")].iter().enumerate() { - assert!(matches!( - ask( - &mut client, - 10 + i as u64, - ControlRequest::WorkspacePut { - id: (*id).to_string(), - json: ws_record(id, name), - }, - ), - ControlReply::Ok(ReplyOk::Unit) - )); - } - - // Get one back, exactly as it was written. - let got = ok_json(ask( - &mut client, - 20, - ControlRequest::WorkspaceGet { - id: "w-a".to_string(), - }, - )); - assert_eq!(got, ws_record("w-a", "api")); - - // List answers an array in file order. - let listed = ok_json(ask(&mut client, 21, ControlRequest::WorkspaceList)); - let ids: Vec<&str> = listed - .as_array() - .unwrap() - .iter() - .map(|v| v["id"].as_str().unwrap()) - .collect(); - assert_eq!(ids, vec!["w-a", "w-b"]); - - // A missing id is `NotFound`, not an empty payload. - match ask( - &mut client, - 22, - ControlRequest::WorkspaceGet { - id: "nope".to_string(), - }, - ) { - ControlReply::Err(e) => assert_eq!(e.kind, WireErrorKind::NotFound), - other => panic!("expected NotFound, got {other:?}"), - } - - // A record whose id disagrees with its key is refused. - match ask( - &mut client, - 23, - ControlRequest::WorkspacePut { - id: "w-a".to_string(), - json: ws_record("w-b", "confused"), - }, - ) { - ControlReply::Err(e) => assert_eq!(e.kind, WireErrorKind::InvalidInput), - other => panic!("expected InvalidInput, got {other:?}"), - } - - // Delete, twice — the second is still success. - for req_id in [30, 31] { - assert!(matches!( - ask( - &mut client, - req_id, - ControlRequest::WorkspaceDelete { - id: "w-a".to_string(), - }, - ), - ControlReply::Ok(ReplyOk::Unit) - )); - } - - // The file on the server's disk is the authority, and it agrees. - let text = std::fs::read_to_string(dir.path().join("workspaces.json")).unwrap(); - assert!(text.contains("w-b"), "{text}"); - assert!(!text.contains("w-a"), "{text}"); - assert_eq!(store.len(), 1); - } - - /// **What the event exists for.** Two clients on one machine: a change made - /// by one has to reach the other, and must not come back to its author as - /// news it already has. - #[test] - fn a_change_reaches_the_other_client_and_not_its_author() { - let (store, _dir) = temp_store(); - let services = Services::with_workspaces(Arc::clone(&store)); - let (mut writer, _) = raw_with(services.clone()); - let (mut listener, _) = raw_with(services); - - assert!(matches!( - ask( - &mut writer, - 1, - ControlRequest::WorkspacePut { - id: "w".to_string(), - json: ws_record("w", "api"), - }, - ), - ControlReply::Ok(ReplyOk::Unit) - )); - - // The listener is told which workspace to refetch. - listener - .set_read_timeout(Some(Duration::from_secs(5))) - .unwrap(); - match ControlServerMsg::read(&mut listener).unwrap() { - ControlServerMsg::Event(ControlEvent::WorkspaceChanged { id }) => { - assert_eq!(id, "w"); - } - other => panic!("expected a WorkspaceChanged push, got {other:?}"), - } - - // A delete is a change too. - assert!(matches!( - ask( - &mut writer, - 2, - ControlRequest::WorkspaceDelete { - id: "w".to_string(), - }, - ), - ControlReply::Ok(ReplyOk::Unit) - )); - match ControlServerMsg::read(&mut listener).unwrap() { - ControlServerMsg::Event(ControlEvent::WorkspaceChanged { id }) => assert_eq!(id, "w"), - other => panic!("expected a WorkspaceChanged push, got {other:?}"), - } - - // The author heard nothing about either of its own writes: its next - // frame is the reply to a fresh request, not a backlog of echoes. - writer - .set_read_timeout(Some(Duration::from_secs(5))) - .unwrap(); - ControlClientMsg::Request { - req_id: 3, - req: ControlRequest::Ping, - } - .encode(&mut writer) - .unwrap(); - writer.flush().unwrap(); - match ControlServerMsg::read(&mut writer).unwrap() { - ControlServerMsg::Response { req_id: 3, reply } => { - assert!(matches!(reply, ControlReply::Ok(ReplyOk::Pong))); - } - other => panic!("the author was pushed its own change: {other:?}"), - } - } + // ----------------------------------------------------------------------- + // The machine tree, over the wire + // ----------------------------------------------------------------------- /// A subscription is a connection's resource like any other: when the - /// connection ends, the store must stop holding a callback into its sink. + /// connection ends, the tree must stop holding a callback into its sink. + /// + /// (A leaked subscriber shows up as a `BrokenPipe` log rather than a + /// failure, so the assertion is that a later operation succeeds and lands — + /// with the tree's own `Drop`-based unsubscribe doing the work.) #[test] fn a_closed_connection_stops_being_a_subscriber() { - let (store, _dir) = temp_store(); - let (server, client) = UnixStream::pair().unwrap(); + let dir = tempfile::TempDir::new().unwrap(); + let store = MachineStore::open(dir.path().join(machine::MACHINE_FILE)); let served = { let store = Arc::clone(&store); - std::thread::spawn(move || { - let _ = serve_with(server, LocalHost::new(), Services::with_workspaces(store)); - }) + let (server, client) = UnixStream::pair().unwrap(); + let handle = std::thread::spawn(move || { + let _ = serve_with(server, LocalHost::new(), Services::with_machine(store)); + }); + // Handshake, then hang up. + let mut client = client; + ControlClientMsg::Hello(ControlHello::host_rpc("t", "h")) + .encode(&mut client) + .unwrap(); + client.flush().unwrap(); + let _ = ControlServerMsg::read(&mut client).unwrap(); + drop(client); + handle }; - // Handshake, then hang up. - let mut client = client; - ControlClientMsg::Hello(ControlHello::host_rpc("t", "h")) - .encode(&mut client) - .unwrap(); - client.flush().unwrap(); - let _ = ControlServerMsg::read(&mut client).unwrap(); - drop(client); served.join().unwrap(); - // The store still works, and writing to it does not try to reach a sink - // that is gone. (A leaked subscriber would show up as a `BrokenPipe` - // log rather than a failure, so the assertion is that the put succeeds - // and the record lands.) - store.put("w", ws_record("w", "api"), None).unwrap(); - assert_eq!(store.len(), 1); + let ws = store + .workspace_create(None, Some("api".into()), None) + .unwrap(); + assert_eq!(store.machine().workspaces.len(), 1); + assert_eq!(store.workspace(ws.id).unwrap().name.as_deref(), Some("api")); } - /// A store shared by many connections writing at once: the server has to be - /// as safe as the store is, and no request may be lost or answered twice. + /// One tree shared by many connections writing at once: the server has to be + /// as safe as the store is, and no operation may be lost or answered twice. + /// + /// Six connections × ten workspaces, which is also the shape the design is + /// *for* — several clients editing one machine — rather than the single + /// writer the retired record store assumed. #[test] - fn concurrent_connections_can_all_write_the_store() { - let (store, _dir) = temp_store(); - let services = Services::with_workspaces(Arc::clone(&store)); + fn concurrent_connections_can_all_write_the_tree() { + let (services, _dir) = workspace_services(); + let store = Arc::clone(services.machine.as_ref().unwrap()); let writers: Vec<_> = (0..6) - .map(|c| { + .map(|_| { let services = services.clone(); std::thread::spawn(move || { let (mut client, _) = raw_with(services); for i in 0..10 { - let id = format!("c{c}-{i}"); let reply = ask( &mut client, i as u64 + 1, - ControlRequest::WorkspacePut { - id: id.clone(), - json: ws_record(&id, "x"), + ControlRequest::WorkspaceCreate { + name: Some(format!("w-{i}")), + workspace: None, }, ); assert!( - matches!(reply, ControlReply::Ok(ReplyOk::Unit)), + matches!(reply, ControlReply::Ok(ReplyOk::WorkspaceTree(_))), "{reply:?}" ); } @@ -3438,7 +3566,64 @@ mod tests { for w in writers { w.join().unwrap(); } - assert_eq!(store.len(), 60); + assert_eq!( + store.machine().workspaces.len(), + 60, + "every operation landed exactly once" + ); + } + + // ----------------------------------------------------------------------- + // Layout delta forwarding + // ----------------------------------------------------------------------- + + /// A connection whose delta queue dropped something is *told* — and told + /// **instead of** being handed the backlog. + /// + /// Before the announcement existed, the drop was a server-side log line and + /// the client mirrored a tree it was no longer looking at, indefinitely. + /// Announcing and *then* draining is the subtler version of the same bug: + /// the queue is FIFO, so everything in it is older than the gap, and a + /// client that re-pulled on the notice would then be walked back through + /// history it had already left — `TabRestructured` restoring the shape a tab + /// used to have, with the window and its mirror agreeing on the stale + /// answer, so nothing recovers a second time. + #[test] + fn a_lagged_connection_hears_a_resync_instead_of_the_superseded_backlog() { + let (server_end, mut client_end) = UnixStream::pair().unwrap(); + let sink = Arc::new(Sink::new(server_end)); + let (tx, rx) = smol::channel::bounded::<(String, machine::LayoutDelta)>(8); + let lagged = Arc::new(AtomicBool::new(false)); + + // A backlog, then the drop that makes every bit of it stale. Queued + // before the forwarder starts so nothing can be delivered early. + for _ in 0..4 { + tx.send_blocking(("ws-1".to_string(), machine::LayoutDelta::WorkspaceDeleted)) + .unwrap(); + } + lagged.store(true, Ordering::Release); + spawn_layout_forwarder(rx, sink, Arc::clone(&lagged)); + + assert_eq!( + ControlServerMsg::read(&mut client_end).unwrap(), + ControlServerMsg::Event(ControlEvent::LayoutResync) + ); + assert!( + !lagged.load(Ordering::Acquire), + "the flag is consumed: one gap, one resync" + ); + + // The next frame is the *next* edit, not the four that were queued + // behind the gap. + tx.send_blocking(("ws-2".to_string(), machine::LayoutDelta::WorkspaceDeleted)) + .unwrap(); + match ControlServerMsg::read(&mut client_end).unwrap() { + ControlServerMsg::Event(ControlEvent::Layout { workspace, delta }) => { + assert_eq!(workspace, "ws-2", "the stale backlog was delivered anyway"); + assert_eq!(delta, machine::LayoutDelta::WorkspaceDeleted); + } + other => panic!("expected the post-resync delta, got {other:?}"), + } } // ----------------------------------------------------------------------- @@ -3456,13 +3641,14 @@ mod tests { fn a_second_client_takes_the_workspace_and_the_first_is_told() { let (services, _dir) = workspace_services(); let registry = Arc::clone(&services.attachments); + let w = tree_workspace(&services); let ((mut laptop, _), _laptop_served) = - raw_hello(services.clone(), hello_for("w", "tok-laptop", "laptop")); - await_holder(®istry, "w", "laptop"); + raw_hello(services.clone(), hello_for(&w, "tok-laptop", "laptop")); + await_holder(®istry, &w, "laptop"); let ((mut desktop, _), _desktop_served) = - raw_hello(services.clone(), hello_for("w", "tok-desktop", "desktop")); + raw_hello(services.clone(), hello_for(&w, "tok-desktop", "desktop")); // The displaced session hears who took it, and which workspace: one // connection can carry several, so a push without the id would leave the @@ -3470,21 +3656,21 @@ mod tests { assert_eq!( await_preempted(&mut laptop), Some(ControlEvent::Preempted { - workspace: "w".to_string(), + workspace: w.clone(), by: "desktop".to_string(), }) ); - assert_eq!(registry.holder("w").map(|(_, h)| h), Some("desktop".into())); + assert_eq!(registry.holder(&w).map(|(_, h)| h), Some("desktop".into())); assert_eq!( services - .workspaces + .machine .as_ref() .unwrap() - .attachment("w") + .attachment(w.parse().unwrap()) .unwrap() .hostname, "desktop", - "the store's record moves with the live handles" + "the tree's record moves with the live handles" ); // And the newcomer is told what it took over from — a takeover the new @@ -3492,7 +3678,7 @@ mod tests { let (reply, _) = round_trip( &mut desktop, 1, - ControlRequest::WorkspaceAttach { id: "w".into() }, + ControlRequest::WorkspaceAttach { id: w.clone() }, ); assert_eq!( reply, @@ -3508,11 +3694,12 @@ mod tests { #[test] fn a_dedicated_connection_is_closed_when_its_workspace_is_taken() { let (services, _dir) = workspace_services(); + let w = tree_workspace(&services); let ((mut laptop, _), _l) = - raw_hello(services.clone(), hello_for("w", "tok-laptop", "laptop")); - await_holder(&services.attachments, "w", "laptop"); + raw_hello(services.clone(), hello_for(&w, "tok-laptop", "laptop")); + await_holder(&services.attachments, &w, "laptop"); let ((_desktop, _), _d) = - raw_hello(services.clone(), hello_for("w", "tok-desktop", "desktop")); + raw_hello(services.clone(), hello_for(&w, "tok-desktop", "desktop")); assert!(await_preempted(&mut laptop).is_some()); // The push comes first and the close after: the notice is useless if it @@ -3525,59 +3712,51 @@ mod tests { ); } - /// Deleting a workspace clears it from *both* tables. + /// Removing a workspace clears it from *both* tables. /// - /// The store drops its own attachment on delete. If the registry keeps its - /// handle, the two disagree with no race needed, and the next client to - /// attach that id evicts a session nobody displaced — closing its whole - /// link, since a dedicated entry takes every other workspace on that - /// connection down with it. + /// The tree drops its own attachment with the workspace. If the registry + /// keeps its handle, the two disagree with no race needed, and the next + /// client to attach that id evicts a session nobody displaced — closing + /// its whole link, since a dedicated entry takes every other workspace on + /// that connection down with it. #[test] - fn deleting_a_workspace_clears_both_attachment_tables() { + fn removing_a_workspace_clears_both_attachment_tables() { let (services, _dir) = workspace_services(); let registry = Arc::clone(&services.attachments); - let store = services.workspaces.clone().unwrap(); + let machine = services.machine.clone().unwrap(); + let w = tree_workspace(&services); + let id: crate::core::session::WorkspaceId = w.parse().unwrap(); let ((mut laptop, _), _l) = - raw_hello(services.clone(), hello_for("w", "tok-laptop", "laptop")); - await_holder(®istry, "w", "laptop"); - assert!(store.attachment("w").is_some()); + raw_hello(services.clone(), hello_for(&w, "tok-laptop", "laptop")); + await_holder(®istry, &w, "laptop"); + assert!(machine.attachment(id).is_some()); - ask( - &mut laptop, - 1, - ControlRequest::WorkspacePut { - id: "w".to_string(), - json: ws_record("w", "the workspace"), - }, - ); let reply = ask( &mut laptop, - 2, - ControlRequest::WorkspaceDelete { - id: "w".to_string(), - }, + 1, + ControlRequest::WorkspaceRemove { workspace: id }, ); assert!( - matches!(reply, ControlReply::Ok(ReplyOk::Unit)), + matches!(reply, ControlReply::Ok(ReplyOk::Panes(_))), "{reply:?}" ); assert!( - store.attachment("w").is_none(), - "the store still names a holder for a workspace that is gone" + machine.attachment(id).is_none(), + "the tree still names a holder for a workspace that is gone" ); assert!( - registry.holder("w").is_none(), + registry.holder(&w).is_none(), "the registry still holds a workspace that is gone" ); } - /// The store's record and the registry's handle move under **one** lock. + /// The tree's record and the registry's handle move under **one** lock. /// /// They are separate tables with separate locks, and taking them one after /// the other is not enough: two clients attaching the same workspace at the - /// same instant can each win a different one, after which the store names a + /// same instant can each win a different one, after which the tree names a /// session the registry has already evicted. No `detach` can clear it — its /// token no longer matches — so from then on the workspace reports a /// takeover against a client that disconnected hours ago. @@ -3590,28 +3769,30 @@ mod tests { fn an_attach_moves_both_tables_under_one_lock() { let (services, _dir) = workspace_services(); let registry = Arc::clone(&services.attachments); - let store = services.workspaces.clone().unwrap(); + let machine = services.machine.clone().unwrap(); + let w = tree_workspace(&services); + let id: crate::core::session::WorkspaceId = w.parse().unwrap(); let held = registry.handover(); // The handshake replies before the attach, so this returns rather than // blocking on the lock we are holding. let ((_laptop, _ok), _served) = - raw_hello(services.clone(), hello_for("w", "tok-laptop", "laptop")); + raw_hello(services.clone(), hello_for(&w, "tok-laptop", "laptop")); std::thread::sleep(Duration::from_millis(150)); assert!( - registry.holder("w").is_none(), + registry.holder(&w).is_none(), "the registry was moved while a handover was in flight" ); assert!( - store.attachment("w").is_none(), - "the store was moved while a handover was in flight" + machine.attachment(id).is_none(), + "the tree was moved while a handover was in flight" ); drop(held); - await_holder(®istry, "w", "laptop"); + await_holder(®istry, &w, "laptop"); assert_eq!( - store.attachment("w").map(|a| a.token).as_deref(), + machine.attachment(id).map(|a| a.token).as_deref(), Some("tok-laptop"), "both tables have to name the same session once the handover is done" ); @@ -3624,16 +3805,18 @@ mod tests { fn a_shared_connection_survives_losing_one_of_its_workspaces() { let (services, _dir) = workspace_services(); let registry = Arc::clone(&services.attachments); + let w1 = tree_workspace(&services); + let w2 = tree_workspace(&services); let ((mut laptop, _), _l) = raw_hello( services.clone(), ControlHello::host_rpc("tok-laptop", "laptop"), ); - for (i, id) in ["w1", "w2"].iter().enumerate() { + for (i, id) in [&w1, &w2].iter().enumerate() { let (reply, _) = round_trip( &mut laptop, i as u64 + 1, - ControlRequest::WorkspaceAttach { id: (*id).into() }, + ControlRequest::WorkspaceAttach { id: (*id).clone() }, ); assert_eq!( reply, @@ -3645,11 +3828,11 @@ mod tests { assert_eq!(registry.len(), 2); let ((_desktop, _), _d) = - raw_hello(services.clone(), hello_for("w1", "tok-desktop", "desktop")); + raw_hello(services.clone(), hello_for(&w1, "tok-desktop", "desktop")); assert_eq!( await_preempted(&mut laptop), Some(ControlEvent::Preempted { - workspace: "w1".to_string(), + workspace: w1.clone(), by: "desktop".to_string(), }) ); @@ -3658,11 +3841,11 @@ mod tests { let (reply, _) = round_trip(&mut laptop, 9, ControlRequest::Ping); assert_eq!(reply, ControlReply::Ok(ReplyOk::Pong)); assert_eq!( - registry.holder("w2").map(|(t, _)| t), + registry.holder(&w2).map(|(t, _)| t), Some("tok-laptop".into()) ); assert_eq!( - registry.holder("w1").map(|(t, _)| t), + registry.holder(&w1).map(|(t, _)| t), Some("tok-desktop".into()) ); } @@ -3674,7 +3857,9 @@ mod tests { fn a_displaced_session_tidying_up_does_not_evict_the_new_owner() { let (services, _dir) = workspace_services(); let registry = Arc::clone(&services.attachments); - let store = Arc::clone(services.workspaces.as_ref().unwrap()); + let machine = Arc::clone(services.machine.as_ref().unwrap()); + let w = tree_workspace(&services); + let other = tree_workspace(&services); let ((mut laptop, _), _l) = raw_hello( services.clone(), @@ -3682,30 +3867,33 @@ mod tests { ); // Two workspaces on one link, which is what a client with two windows // on one machine has — and what keeps this link up once `w` is taken. - for (i, id) in ["w", "other"].iter().enumerate() { + for (i, id) in [&w, &other].iter().enumerate() { round_trip( &mut laptop, i as u64 + 1, - ControlRequest::WorkspaceAttach { id: (*id).into() }, + ControlRequest::WorkspaceAttach { id: (*id).clone() }, ); } let ((_desktop, _), _d) = - raw_hello(services.clone(), hello_for("w", "tok-desktop", "desktop")); + raw_hello(services.clone(), hello_for(&w, "tok-desktop", "desktop")); assert!(await_preempted(&mut laptop).is_some()); // The laptop, which no longer holds anything, tidies up. let (reply, _) = round_trip( &mut laptop, 3, - ControlRequest::WorkspaceDetach { id: "w".into() }, + ControlRequest::WorkspaceDetach { id: w.clone() }, ); assert_eq!(reply, ControlReply::Ok(ReplyOk::Unit)); assert_eq!( - registry.holder("w").map(|(t, _)| t), + registry.holder(&w).map(|(t, _)| t), Some("tok-desktop".into()), "the displaced session must not release what it no longer holds" ); - assert_eq!(store.attachment("w").unwrap().token, "tok-desktop"); + assert_eq!( + machine.attachment(w.parse().unwrap()).unwrap().token, + "tok-desktop" + ); } /// A connection ending gives its workspaces back, so the next client does @@ -3714,22 +3902,22 @@ mod tests { fn a_closed_connection_releases_what_it_held() { let (services, _dir) = workspace_services(); let registry = Arc::clone(&services.attachments); - let store = Arc::clone(services.workspaces.as_ref().unwrap()); + let machine = Arc::clone(services.machine.as_ref().unwrap()); + let w = tree_workspace(&services); { - let ((client, _), served) = - raw_hello(services.clone(), hello_for("w", "tok", "laptop")); - await_holder(®istry, "w", "laptop"); + let ((client, _), served) = raw_hello(services.clone(), hello_for(&w, "tok", "laptop")); + await_holder(®istry, &w, "laptop"); drop(client); served.join().unwrap(); } assert!(registry.is_empty(), "the registry outlived the connection"); - assert_eq!(store.attachment("w"), None); + assert_eq!(machine.attachment(w.parse().unwrap()), None); } - /// A server with no workspace store has no workspaces to attach to, and + /// A server with no machine tree has no workspaces to attach to, and /// says so rather than pretending the claim succeeded. #[test] - fn attaching_to_a_server_without_a_store_is_an_error() { + fn attaching_to_a_server_without_a_tree_is_an_error() { let (mut client, _) = raw_with(Services::none()); let (reply, _) = round_trip( &mut client, diff --git a/crates/tty7-server/Cargo.toml b/crates/tty7-server/Cargo.toml index 11f2fa13..d675dd0a 100644 --- a/crates/tty7-server/Cargo.toml +++ b/crates/tty7-server/Cargo.toml @@ -26,10 +26,5 @@ tty7-core = { path = "../tty7-core" } # Sandboxes for the suite: an empty directory per case, removed on drop. The # server is on this machine, so a local temp dir is a path in its namespace. tempfile = "3" -# Workspace records cross the control wire as opaque JSON, so -# `tests/workspace_store.rs` has to build and read one. Dev-only: the binary -# itself still depends on nothing but `tty7-core`. -serde_json.workspace = true - [lints] workspace = true diff --git a/crates/tty7-server/src/main.rs b/crates/tty7-server/src/main.rs index 4c2194e6..f9a729ad 100644 --- a/crates/tty7-server/src/main.rs +++ b/crates/tty7-server/src/main.rs @@ -144,21 +144,13 @@ fn main() -> ExitCode { } /// Serve panes and control connections until killed. +/// +/// The whole of it lives in [`tty7_core::daemon::server::run_daemon`], shared +/// verbatim with `tty7 --daemon`: local and remote machines run the identical +/// daemon, which is what makes "one machine = one daemon = one workspace tree" +/// a fact rather than a convention. fn run_daemon() -> ExitCode { - // Control first, and on its own thread: a machine that cannot host panes - // (no pty, a locked-down container) should still be able to back a remote - // workspace's files, so a control failure is reported and stepped over - // rather than being fatal. - #[cfg(unix)] - match tty7_core::host::server::spawn_control_listener_with( - tty7_core::host::local::LocalHost::shared(), - control_services(), - ) { - Ok(path) => eprintln!("tty7-server: control socket at {}", path.display()), - Err(e) => eprintln!("tty7-server: control listener unavailable: {e}"), - } - - if let Err(e) = tty7_core::daemon::server::run() { + if let Err(e) = tty7_core::daemon::server::run_daemon() { eprintln!("tty7-server: daemon exited with error: {e}"); return ExitCode::FAILURE; } @@ -226,7 +218,7 @@ fn run_stdio(args: &[String]) -> io::Result<()> { // the same rule `bridge_panes` follows one dialect over, // and for the same reason. Two `--stdio` sessions both // falling through to serving in-process would each hold - // their own `WorkspaceStore` over the one file, and + // their own `MachineStore` over the one file, and // `persist` writes the whole document: the second to save // silently drops the first's changes. Their attachment // registries would be separate too, which makes design @@ -266,7 +258,11 @@ fn run_stdio(args: &[String]) -> io::Result<()> { // Takes stdin/stdout away from the rest of the process before a // single frame is written — see `StdioDuplex::take`. let link = StdioDuplex::take()?; - server::serve_with(link, LocalHost::shared(), control_services()) + server::serve_with( + link, + LocalHost::shared(), + tty7_core::daemon::server::control_services(), + ) } } } @@ -377,37 +373,6 @@ fn bridge(upstream: std::os::unix::net::UnixStream) -> io::Result<()> { Ok(()) } -/// What this machine offers over a control connection, beyond its filesystem. -/// -/// The workspace store is the reason this binary exists on a remote box at all: -/// the workspace list, the tab/pane tree and each pane's cwd live on -/// **the machine the panes run on**, so that connecting from a different laptop -/// shows the same thing. The client's `session.json` keeps only its own view -/// state. -/// -/// A machine with no home directory to place the file in still serves files and -/// panes — it simply says `workspace-store` is not among its capabilities, and -/// clients see the same "does not serve the workspace store" answer a -/// pre-M5 server gives. -fn control_services() -> tty7_core::host::server::Services { - use tty7_core::core::workspace_store::WorkspaceStore; - match WorkspaceStore::shared() { - Ok(store) => { - log_stderr(format_args!( - "workspace store at {}", - store.path().display() - )); - tty7_core::host::server::Services::with_workspaces(store) - } - Err(e) => { - log_stderr(format_args!( - "no workspace store ({e}); serving files and panes only" - )); - tty7_core::host::server::Services::none() - } - } -} - /// `--flag ` or `--flag=`, first occurrence wins. /// Whether a failed control probe may start the machine's daemon. /// diff --git a/crates/tty7-server/tests/machine_tree.rs b/crates/tty7-server/tests/machine_tree.rs new file mode 100644 index 00000000..91b091e0 --- /dev/null +++ b/crates/tty7-server/tests/machine_tree.rs @@ -0,0 +1,522 @@ +//! The machine-owned workspace tree, end to end against a real `tty7-server` +//! child process. +//! +//! The client is the shipped `ControlClient`, the wire is the control dialect +//! over real pipes, and the server is the shipped binary owning its tree in a +//! file. What the process boundary buys here specifically: +//! +//! | | Why an in-process store would not do | +//! |---|---| +//! | The tree is on **the server's** disk | The whole design is "the daemon owns the structure"; a store in the test's address space proves the data type, not the ownership | +//! | `machine-tree` is advertised only when served | The capability bit is built from what the *binary* wires up | +//! | A delta reaches the **other** connection, never the writer | Origin exclusion is the contract that lets a client apply its own edit from the reply and everyone else's from the push | +//! +//! Every case gets its own `$TTY7_DATA_DIR`, so no case can be explained by +//! another's leftovers and nothing here can touch a developer's real tree. + +// Unix-only: the server under test is a `--stdio` child, and the two-client +// case stands up a control socket. +#![cfg(unix)] + +use std::io; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use tty7_core::core::machine::{Axis, LayoutDelta, MACHINE_FILE, PaneNode, PaneSeed}; +use tty7_core::daemon::control::{ + ControlClient, ControlEvent, ControlHello, ControlRequest, LinkShutdown, ReplyOk, WorkspaceId, + feature, +}; + +/// The child, and the only way to end it — a process-backed link is reaped by +/// its `LinkShutdown`, exactly as in `stdio_conformance.rs`. +struct ServerProcess { + child: Mutex>, +} + +impl LinkShutdown for ServerProcess { + fn shutdown_link(&self) -> io::Result<()> { + let Some(mut child) = self.child.lock().unwrap_or_else(|e| e.into_inner()).take() else { + return Ok(()); + }; + let _ = child.kill(); + let _ = child.wait(); + Ok(()) + } +} + +/// One connected client: the RPC channel, plus everything the server pushed. +struct Client { + control: ControlClient, + events: Arc>>, + peer_features: Vec, +} + +impl Client { + /// Wait for a `Layout` delta about `workspace` matching `want`, or fail + /// saying what did arrive. Polled because a push and the reply that caused + /// it race by construction. + fn expect_delta(&self, workspace: WorkspaceId, want: impl Fn(&LayoutDelta) -> bool) { + let key = workspace.to_string(); + let deadline = Instant::now() + Duration::from_secs(10); + loop { + let seen = self + .events + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone(); + if seen.iter().any(|e| { + matches!(e, ControlEvent::Layout { workspace: w, delta } if *w == key && want(delta)) + }) { + return; + } + assert!( + Instant::now() < deadline, + "no matching Layout delta for {key}; saw {seen:?}" + ); + std::thread::sleep(Duration::from_millis(20)); + } + } + + fn delta_count(&self) -> usize { + self.events + .lock() + .unwrap_or_else(|e| e.into_inner()) + .iter() + .filter(|e| matches!(e, ControlEvent::Layout { .. })) + .count() + } +} + +/// Start a `tty7-server --stdio --serve` whose tree lives in `data_dir`, and +/// connect a client to it. `--serve` for the same reason as everywhere else in +/// these tests: a developer's real daemon must never be bridged into. +fn connect(data_dir: &Path, token: &str) -> Client { + let mut child = Command::new(env!("CARGO_BIN_EXE_tty7-server")) + .args(["--stdio", "--serve"]) + .env("TTY7_DATA_DIR", data_dir) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("could not start tty7-server --stdio"); + + let stdout = child.stdout.take().expect("piped"); + let stdin = child.stdin.take().expect("piped"); + let closer: Arc = Arc::new(ServerProcess { + child: Mutex::new(Some(child)), + }); + + let events: Arc>> = Arc::new(Mutex::new(Vec::new())); + let sink = Arc::clone(&events); + let control = ControlClient::connect_with( + stdout, + stdin, + Some(closer), + &ControlHello::host_rpc(token, "test-client"), + Box::new(move |event| sink.lock().unwrap_or_else(|e| e.into_inner()).push(event)), + ) + .expect("handshake with tty7-server --stdio"); + + let peer_features = control.hello().features.clone(); + Client { + control, + events, + peer_features, + } +} + +fn data_dir() -> tempfile::TempDir { + tempfile::TempDir::new().unwrap() +} + +fn machine_file(dir: &tempfile::TempDir) -> PathBuf { + dir.path().join(MACHINE_FILE) +} + +fn seed(pane: u64, cwd: &str) -> PaneSeed { + PaneSeed { + pane, + cwd: Some(cwd.to_string()), + ssh_spec: None, + agent: None, + } +} + +// --------------------------------------------------------------------------- + +/// The capability bit is the client's cue that the tree verbs are worth a +/// round trip, and it has to reflect what the shipped binary wired up. +#[test] +fn the_server_advertises_the_machine_tree() { + let dir = data_dir(); + let client = connect(dir.path(), "cap"); + assert!( + client + .peer_features + .iter() + .any(|f| f == feature::MACHINE_TREE), + "features were {:?}", + client.peer_features + ); +} + +/// The semantic operations against a real server, and the tree ends up in a +/// file that server owns. This is "the daemon owns the structure" as a +/// syscall someone else made, not as a diagram. +#[test] +fn the_tree_is_built_by_operations_and_lives_in_the_servers_file() { + let dir = data_dir(); + let client = connect(dir.path(), "ops"); + + // Build: a workspace, a tab, a split. + let ws = match client + .control + .call(ControlRequest::WorkspaceCreate { + name: Some("api".into()), + workspace: None, + }) + .expect("create workspace") + { + ReplyOk::WorkspaceTree(ws) => *ws, + other => panic!("expected WorkspaceTree, got {other:?}"), + }; + let tab = match client + .control + .call(ControlRequest::TabCreate { + workspace: ws.id, + at: None, + pane: seed(1, "/home/me/proj"), + tab: None, + }) + .expect("create tab") + { + ReplyOk::TabTree(tab) => *tab, + other => panic!("expected TabTree, got {other:?}"), + }; + client + .control + .call(ControlRequest::PaneSplit { + workspace: ws.id, + pane: 1, + axis: Axis::Vertical, + ratio: 0.3, + new: seed(2, "/home/me/proj/sub"), + first: false, + }) + .expect("split"); + + // Read back through the wire. + let machine = match client.control.call(ControlRequest::MachineGet).unwrap() { + ReplyOk::MachineTree(m) => *m, + other => panic!("expected MachineTree, got {other:?}"), + }; + assert_eq!(machine.workspaces.len(), 1); + assert_eq!(machine.workspaces[0].tabs[0].id, tab.id); + assert_eq!(machine.workspaces[0].tabs[0].root.pane_ids(), vec![1, 2]); + assert_eq!(machine.panes.len(), 2); + assert!( + machine.panes.iter().all(|p| p.live), + "panes this server was told about in its own lifetime are live" + ); + + // The file is the server's: the test process never wrote it. + let text = std::fs::read_to_string(machine_file(&dir)).expect("the server wrote its tree"); + assert!(text.contains(&ws.id.to_string()), "{text}"); + + // A refusal is a client-visible error, not a dropped reply. + let missing = client + .control + .call(ControlRequest::WorkspaceTree { + workspace: WorkspaceId::new(), + }) + .unwrap_err(); + assert_eq!(missing.kind(), io::ErrorKind::NotFound); +} + +/// **The revival contract, across a real restart.** A second server process +/// reads the first one's tree; every pane in it is dead (`live == false`), the +/// leaves still name them, and `PaneReplace` rebinds a leaf to a successor. +#[test] +fn a_new_server_process_reports_the_old_panes_dead_and_accepts_their_successors() { + let dir = data_dir(); + let ws = { + let first = connect(dir.path(), "first"); + let ws = match first + .control + .call(ControlRequest::WorkspaceCreate { + name: None, + workspace: None, + }) + .unwrap() + { + ReplyOk::WorkspaceTree(ws) => *ws, + other => panic!("{other:?}"), + }; + first + .control + .call(ControlRequest::TabCreate { + workspace: ws.id, + at: None, + pane: seed(7, "/home/me/proj"), + tab: None, + }) + .unwrap(); + first.control.close(); + ws + }; + + // A brand-new server process over the same file. + let second = connect(dir.path(), "second"); + let machine = match second.control.call(ControlRequest::MachineGet).unwrap() { + ReplyOk::MachineTree(m) => *m, + other => panic!("{other:?}"), + }; + let record = machine + .panes + .iter() + .find(|p| p.id == 7) + .expect("the pane record survives the restart"); + assert!(!record.live, "a restarted server has no live panes"); + assert_eq!( + record.cwd.as_deref(), + Some("/home/me/proj"), + "the facts a successor spawns from survive" + ); + assert_eq!( + machine.workspaces[0].tabs[0].root, + PaneNode::Leaf { pane: 7 }, + "the leaf still names the dead pane — the revival slot" + ); + + // Revive: a fresh pane takes the leaf, the spent record goes. + second + .control + .call(ControlRequest::PaneReplace { + workspace: ws.id, + old: 7, + new: seed(1, "/home/me/proj"), + }) + .expect("replace"); + let machine = match second.control.call(ControlRequest::MachineGet).unwrap() { + ReplyOk::MachineTree(m) => *m, + other => panic!("{other:?}"), + }; + assert_eq!( + machine.workspaces[0].tabs[0].root, + PaneNode::Leaf { pane: 1 } + ); + assert!(machine.panes.iter().all(|p| p.id != 7)); +} + +/// Two clients on one server. An operation by one reaches the other as a +/// `Layout` delta and never comes back to its author — the mechanism that +/// replaces whole-record last-writer-wins with edits that all land. +#[test] +fn an_operation_from_one_client_reaches_the_other_as_a_delta() { + use tty7_core::host::local::LocalHost; + use tty7_core::host::server; + + let dir = data_dir(); + let machine = tty7_core::core::machine::MachineStore::open(machine_file(&dir)); + let sock = dir.path().join("control.sock"); + let listener = server::bind_control_socket(&sock).unwrap(); + { + let machine = Arc::clone(&machine); + std::thread::spawn(move || { + server::serve_listener_with( + listener, + LocalHost::new(), + server::Services::with_machine(machine), + ) + }); + } + + let writer = bridged(&sock, "writer"); + let watcher = bridged(&sock, "watcher"); + assert!( + writer + .peer_features + .iter() + .any(|f| f == feature::MACHINE_TREE) + ); + // Make sure the watcher's subscription is up (its server thread subscribes + // before answering its first request). + watcher.control.call(ControlRequest::Ping).unwrap(); + + let ws = match writer + .control + .call(ControlRequest::WorkspaceCreate { + name: Some("shared".into()), + workspace: None, + }) + .unwrap() + { + ReplyOk::WorkspaceTree(ws) => *ws, + other => panic!("{other:?}"), + }; + let tab = match writer + .control + .call(ControlRequest::TabCreate { + workspace: ws.id, + at: None, + pane: seed(3, "/srv"), + tab: None, + }) + .unwrap() + { + ReplyOk::TabTree(tab) => *tab, + other => panic!("{other:?}"), + }; + + watcher.expect_delta( + ws.id, + |d| matches!(d, LayoutDelta::WorkspaceCreated { workspace } if workspace.id == ws.id), + ); + watcher.expect_delta( + ws.id, + |d| matches!(d, LayoutDelta::TabCreated { tab: t, .. } if t.id == tab.id), + ); + // The created tab became active, and the *change of active tab* is its own + // delta — implicit activation must not be something a client re-derives. + watcher.expect_delta( + ws.id, + |d| matches!(d, LayoutDelta::ActiveTabChanged { tab: t } if *t == tab.id), + ); + assert_eq!( + writer.delta_count(), + 0, + "a client must not be pushed its own operation" + ); + + // …and the rule holds in the other direction. + watcher + .control + .call(ControlRequest::TabRename { + workspace: ws.id, + tab: tab.id, + name: Some("build".into()), + }) + .unwrap(); + writer.expect_delta( + ws.id, + |d| matches!(d, LayoutDelta::TabRenamed { name: Some(n), .. } if n == "build"), + ); + assert_eq!(watcher.delta_count(), 3, "still only the writer's own ops"); +} + +/// Takeover semantics on the new tree, with **no record store served at +/// all**: the attach verbs predate the tree, and their contract — newcomer +/// wins, the displaced session is told, a stale detach cannot evict the +/// usurper — must survive the record store's retirement. +#[test] +fn attachment_rides_the_tree_when_no_record_store_is_served() { + use tty7_core::host::local::LocalHost; + use tty7_core::host::server; + + let dir = data_dir(); + let machine = tty7_core::core::machine::MachineStore::open(machine_file(&dir)); + let sock = dir.path().join("control.sock"); + let listener = server::bind_control_socket(&sock).unwrap(); + { + let machine = Arc::clone(&machine); + std::thread::spawn(move || { + server::serve_listener_with( + listener, + LocalHost::new(), + server::Services::with_machine(machine), + ) + }); + } + let ws = machine + .workspace_create(None, Some("shared".into()), None) + .unwrap(); + + let laptop = bridged(&sock, "laptop"); + let desktop = bridged(&sock, "desktop"); + + let attach = |client: &Client| { + client.control.call(ControlRequest::WorkspaceAttach { + id: ws.id.to_string(), + }) + }; + match attach(&laptop).expect("first attach") { + ReplyOk::Attached { took_over_from } => assert_eq!(took_over_from, None), + other => panic!("{other:?}"), + } + assert_eq!( + machine.attachment(ws.id).map(|a| a.hostname), + Some("laptop".into()), + "the tree's own record says who holds the workspace" + ); + + // The newcomer wins, learns whom it displaced, and the displaced session + // is pushed a Preempted notice. + match attach(&desktop).expect("takeover") { + ReplyOk::Attached { took_over_from } => { + assert_eq!(took_over_from.as_deref(), Some("laptop")); + } + other => panic!("{other:?}"), + } + let deadline = Instant::now() + Duration::from_secs(10); + loop { + let seen = laptop.events.lock().unwrap().clone(); + if seen.iter().any(|e| { + matches!(e, ControlEvent::Preempted { workspace, by } + if *workspace == ws.id.to_string() && by == "desktop") + }) { + break; + } + assert!(Instant::now() < deadline, "no Preempted push; saw {seen:?}"); + std::thread::sleep(Duration::from_millis(20)); + } + + // The preempted session tidying up must not evict the usurper. + laptop + .control + .call(ControlRequest::WorkspaceDetach { + id: ws.id.to_string(), + }) + .expect("a stale detach is success, not eviction"); + assert_eq!( + machine.attachment(ws.id).map(|a| a.hostname), + Some("desktop".into()) + ); +} + +/// A `--stdio --bridge` child connected to an already-listening control +/// socket — the two-hop shape a real multi-client machine has. +fn bridged(sock: &Path, token: &str) -> Client { + let hello = ControlHello::host_rpc(token, token); + let mut child = Command::new(env!("CARGO_BIN_EXE_tty7-server")) + .args(["--stdio", "--bridge", "--control-sock"]) + .arg(sock) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("could not start the bridging client"); + let stdout = child.stdout.take().expect("piped"); + let stdin = child.stdin.take().expect("piped"); + let closer: Arc = Arc::new(ServerProcess { + child: Mutex::new(Some(child)), + }); + let events: Arc>> = Arc::new(Mutex::new(Vec::new())); + let sink = Arc::clone(&events); + let control = ControlClient::connect_with( + stdout, + stdin, + Some(closer), + &hello, + Box::new(move |e| sink.lock().unwrap_or_else(|e| e.into_inner()).push(e)), + ) + .expect("bridge handshake"); + let peer_features = control.hello().features.clone(); + Client { + control, + events, + peer_features, + } +} diff --git a/crates/tty7-server/tests/stdio_conformance.rs b/crates/tty7-server/tests/stdio_conformance.rs index 2dc774d0..622826ee 100644 --- a/crates/tty7-server/tests/stdio_conformance.rs +++ b/crates/tty7-server/tests/stdio_conformance.rs @@ -93,7 +93,7 @@ fn stdio_host() -> (SharedHost, TempSandbox) { // bridge to *that* would be testing their machine's state instead of // this build. .args(["--stdio", "--serve"]) - // The server opens its workspace store at startup. None of these cases + // The server opens its machine tree at startup. None of these cases // touch it, but pointing it at the sandbox keeps forty-six child // processes off the developer's real `~/.local/share/tty7`. .env("TTY7_DATA_DIR", sandbox.path()) diff --git a/crates/tty7-server/tests/workspace_store.rs b/crates/tty7-server/tests/workspace_store.rs deleted file mode 100644 index df750aa1..00000000 --- a/crates/tty7-server/tests/workspace_store.rs +++ /dev/null @@ -1,543 +0,0 @@ -//! The workspace store, end to end against a real `tty7-server` child process. -//! -//! Same shape and the same reasoning as [`stdio_conformance`]: the client is -//! the shipped `ControlClient`, the wire is the control dialect over real -//! pipes, and the server is the shipped binary keeping its records in a file it -//! owns. What this file adds is the half the conformance suite cannot reach — -//! the store is not a `Host` method, so no amount of `read_dir` parity proves -//! that `WorkspacePut` reached a disk or that another client heard about it. -//! -//! The three things worth a process boundary: -//! -//! | | Why an in-process socket pair would not do | -//! |---|---| -//! | The record is on **the server's** disk | The whole storage split is "the machine is the authority". A store in the test's own address space proves nothing about that | -//! | `workspace-store` is advertised only when served | The capability bit is built from what the *binary* wires up, and that wiring lives in `main.rs` | -//! | A change reaches the **other** connection | Two clients, one server process, one file — the configuration the user actually has when their laptop and their desktop are both connected | -//! -//! Every case gets its own `$TTY7_DATA_DIR`, so no case can be explained by -//! another's leftovers and nothing here can touch the developer's real -//! `~/.local/share/tty7/workspaces.json`. - -// Unix-only, for the same reason as `stdio_conformance.rs`: the server under -// test is a `--stdio` child, and two of the cases stand up a control socket. -#![cfg(unix)] - -use std::io; -use std::path::{Path, PathBuf}; -use std::process::{Child, Command, Stdio}; -use std::sync::{Arc, Mutex}; -use std::time::{Duration, Instant}; - -use tty7_core::core::workspace_store::{STORE_FILE, WorkspaceStore}; -use tty7_core::daemon::control::{ - ControlClient, ControlEvent, ControlHello, ControlRequest, LinkShutdown, ReplyOk, feature, -}; -use tty7_core::host::local::LocalHost; -use tty7_core::host::server; - -/// The child, and the only way to end it — see `stdio_conformance.rs` for why a -/// `LinkShutdown` is what reaps a process-backed link. -struct ServerProcess { - child: Mutex>, -} - -impl LinkShutdown for ServerProcess { - fn shutdown_link(&self) -> io::Result<()> { - let Some(mut child) = self.child.lock().unwrap_or_else(|e| e.into_inner()).take() else { - return Ok(()); - }; - let _ = child.kill(); - let _ = child.wait(); - Ok(()) - } -} - -/// One connected client: the RPC channel, plus everything the server pushed to -/// it. -struct Client { - control: ControlClient, - events: Arc>>, - peer_features: Vec, -} - -impl Client { - /// Wait for a `WorkspaceChanged` naming `id`, or fail saying what did - /// arrive. Polled rather than blocked on a channel because the event and - /// the reply that caused it race by construction. - fn expect_changed(&self, id: &str) { - let deadline = Instant::now() + Duration::from_secs(10); - loop { - let seen = self - .events - .lock() - .unwrap_or_else(|e| e.into_inner()) - .clone(); - if seen - .iter() - .any(|e| matches!(e, ControlEvent::WorkspaceChanged { id: got } if got == id)) - { - return; - } - assert!( - Instant::now() < deadline, - "no WorkspaceChanged for {id}; saw {seen:?}" - ); - std::thread::sleep(Duration::from_millis(20)); - } - } - - /// Wait for the takeover notice naming `workspace` and `by`. - fn expect_preempted(&self, workspace: &str, by: &str) { - let deadline = Instant::now() + Duration::from_secs(10); - loop { - let seen = self - .events - .lock() - .unwrap_or_else(|e| e.into_inner()) - .clone(); - if seen.iter().any(|e| { - matches!(e, ControlEvent::Preempted { workspace: w, by: b } - if w == workspace && b == by) - }) { - return; - } - assert!( - Instant::now() < deadline, - "no Preempted for {workspace} by {by}; saw {seen:?}" - ); - std::thread::sleep(Duration::from_millis(20)); - } - } - - fn changed_count(&self) -> usize { - self.events - .lock() - .unwrap_or_else(|e| e.into_inner()) - .iter() - .filter(|e| matches!(e, ControlEvent::WorkspaceChanged { .. })) - .count() - } -} - -/// Start a `tty7-server --stdio --serve` whose store lives in `data_dir`, and -/// connect a client to it. -/// -/// `--serve` rather than letting the mode be probed: a developer running these -/// tests may well have a real `tty7-server --daemon` up, and bridging to *that* -/// would be testing their machine's state — and, here, writing to their real -/// workspace file. -fn connect(data_dir: &Path, token: &str) -> Client { - let mut child = Command::new(env!("CARGO_BIN_EXE_tty7-server")) - .args(["--stdio", "--serve"]) - .env("TTY7_DATA_DIR", data_dir) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .spawn() - .expect("could not start tty7-server --stdio"); - - let stdout = child.stdout.take().expect("piped"); - let stdin = child.stdin.take().expect("piped"); - let closer: Arc = Arc::new(ServerProcess { - child: Mutex::new(Some(child)), - }); - - let events: Arc>> = Arc::new(Mutex::new(Vec::new())); - let sink = Arc::clone(&events); - let control = ControlClient::connect_with( - stdout, - stdin, - Some(closer), - &ControlHello::host_rpc(token, "test-client"), - Box::new(move |event| sink.lock().unwrap_or_else(|e| e.into_inner()).push(event)), - ) - .expect("handshake with tty7-server --stdio"); - - let peer_features = control.hello().features.clone(); - Client { - control, - events, - peer_features, - } -} - -fn data_dir() -> tempfile::TempDir { - tempfile::TempDir::new().unwrap() -} - -fn store_file(dir: &tempfile::TempDir) -> PathBuf { - dir.path().join(STORE_FILE) -} - -fn record(id: &str, name: &str) -> serde_json::Value { - serde_json::json!({ - "id": id, - "name": name, - "session": {"active": 0, "tabs": [ - {"pane": {"Leaf": {"cwd": "/home/me/proj", "pane_id": 11}}, - "sidebar_group": "/home/me/proj"} - ]}, - "last_active": 1_753_600_000u64, - }) -} - -fn json(reply: ReplyOk) -> serde_json::Value { - match reply { - ReplyOk::Json(v) => v, - other => panic!("expected a Json reply, got {other:?}"), - } -} - -// --------------------------------------------------------------------------- - -/// The capability bit is the client's cue that asking is worth a round trip, so -/// it has to reflect what the shipped binary actually wired up. -#[test] -fn the_server_advertises_the_workspace_store() { - let dir = data_dir(); - let client = connect(dir.path(), "cap"); - assert!( - client - .peer_features - .iter() - .any(|f| f == feature::WORKSPACE_STORE), - "features were {:?}", - client.peer_features - ); -} - -/// **The milestone's proof for M5.** The four RPCs against a real server, and -/// the record ends up in a file that server owns — the storage split is not a -/// diagram, it is this file on that machine. -#[test] -fn records_survive_in_a_file_the_server_owns() { - let dir = data_dir(); - let client = connect(dir.path(), "rpc"); - - assert_eq!( - json(client.control.call(ControlRequest::WorkspaceList).unwrap()), - serde_json::json!([]) - ); - - for (id, name) in [("w-api", "api"), ("w-web", "web")] { - client - .control - .call(ControlRequest::WorkspacePut { - id: id.to_string(), - json: record(id, name), - }) - .expect("put"); - } - - // The file is on this machine only because the "remote" is this machine; - // the point is that the *test process* never wrote it. Reading it with - // plain `std::fs` is how we know the bytes went out through a pipe and came - // back as a syscall someone else made. - let text = std::fs::read_to_string(store_file(&dir)).expect("the server wrote its store"); - assert!(text.contains("w-api"), "{text}"); - assert!(text.contains("w-web"), "{text}"); - - // Get answers exactly what was put. - let got = json( - client - .control - .call(ControlRequest::WorkspaceGet { - id: "w-api".to_string(), - }) - .unwrap(), - ); - assert_eq!(got, record("w-api", "api")); - - // List answers both, in the order they were written. - let listed = json(client.control.call(ControlRequest::WorkspaceList).unwrap()); - let ids: Vec<&str> = listed - .as_array() - .unwrap() - .iter() - .map(|v| v["id"].as_str().unwrap()) - .collect(); - assert_eq!(ids, vec!["w-api", "w-web"]); - - // A missing id is an error the client can tell from an empty record. - let missing = client - .control - .call(ControlRequest::WorkspaceGet { - id: "not-a-workspace".to_string(), - }) - .unwrap_err(); - assert_eq!(missing.kind(), io::ErrorKind::NotFound); - - // Delete reaches the disk, and deleting again is still success. - for _ in 0..2 { - client - .control - .call(ControlRequest::WorkspaceDelete { - id: "w-api".to_string(), - }) - .expect("delete"); - } - let text = std::fs::read_to_string(store_file(&dir)).unwrap(); - assert!(!text.contains("w-api"), "{text}"); - assert!(text.contains("w-web"), "{text}"); -} - -/// A second connection to the same server sees the first one's records — that -/// is what "换台电脑连过来要看到同一份" means once the machine is fixed and the -/// client is not. -#[test] -fn a_later_client_sees_what_an_earlier_one_wrote() { - let dir = data_dir(); - { - let first = connect(dir.path(), "first"); - first - .control - .call(ControlRequest::WorkspacePut { - id: "w".to_string(), - json: record("w", "api"), - }) - .expect("put"); - first.control.close(); - } - - // A brand-new server process, reading the file the previous one left. - let second = connect(dir.path(), "second"); - let got = json( - second - .control - .call(ControlRequest::WorkspaceGet { - id: "w".to_string(), - }) - .unwrap(), - ); - assert_eq!(got["name"], "api"); - assert_eq!(got["session"]["tabs"][0]["pane"]["Leaf"]["pane_id"], 11); -} - -/// Two clients on one machine at once. A change by one has to reach the other, -/// and must not come back to its author. -/// -/// The store lives behind a listener, as it does under `--daemon`, and both -/// clients reach it as `--stdio --bridge` children — the same two-hop shape -/// `cli.rs` uses, and the configuration a user has when their laptop and their -/// desktop are both connected. A store per connection would pass every other -/// test in this file and fail this one. -#[test] -fn a_change_from_one_client_reaches_the_other() { - let dir = data_dir(); - let store = WorkspaceStore::open(store_file(&dir)); - let sock = dir.path().join("control.sock"); - let listener = server::bind_control_socket(&sock).unwrap(); - { - let store = Arc::clone(&store); - std::thread::spawn(move || { - server::serve_listener_with( - listener, - LocalHost::new(), - server::Services::with_workspaces(store), - ) - }); - } - - let writer = bridged(&sock, "writer"); - let watcher = bridged(&sock, "watcher"); - assert!( - writer - .peer_features - .iter() - .any(|f| f == feature::WORKSPACE_STORE) - ); - - writer - .control - .call(ControlRequest::WorkspacePut { - id: "shared".to_string(), - json: record("shared", "api"), - }) - .expect("put"); - - watcher.expect_changed("shared"); - assert_eq!( - writer.changed_count(), - 0, - "a client must not be pushed its own change" - ); - - // The watcher is looking at the same store, not at a copy. - let got = json( - watcher - .control - .call(ControlRequest::WorkspaceGet { - id: "shared".to_string(), - }) - .unwrap(), - ); - assert_eq!(got["name"], "api"); - - // A delete is a change too — and the watcher's own delete comes back to the - // writer, which is the same rule seen from the other side. - watcher - .control - .call(ControlRequest::WorkspaceDelete { - id: "shared".to_string(), - }) - .expect("delete"); - writer.expect_changed("shared"); - assert_eq!(watcher.changed_count(), 1, "still only the writer's put"); - assert_eq!(store.len(), 0); -} - -/// **The takeover, across two real processes.** -/// -/// The same two-client shape as the change-notification test, and for the same -/// reason: a takeover is by definition something one connection does to -/// *another*, so an in-process registry with two handles into it would prove -/// only that the data structure works. What has to hold is that the notice -/// crosses a pipe into a different program and that the displaced link actually -/// closes. -/// -/// D8 is the assertion in the middle: the newcomer holds the workspace -/// afterwards. Rejecting the second client would satisfy "only one at a time" -/// just as well and is the decision this test exists to rule out. -#[test] -fn a_later_client_takes_the_workspace_and_the_first_is_cut_off() { - let dir = data_dir(); - let store = WorkspaceStore::open(store_file(&dir)); - let sock = dir.path().join("control.sock"); - let listener = server::bind_control_socket(&sock).unwrap(); - { - let store = Arc::clone(&store); - std::thread::spawn(move || { - server::serve_listener_with( - listener, - LocalHost::new(), - server::Services::with_workspaces(store), - ) - }); - } - - let laptop = bridged_for(&sock, "tok-laptop", "laptop", Some("w")); - // The attach runs on the server thread after the handshake reply, so the - // record is what says it happened — not the fact that we got a `HELLO_OK`. - await_attachment(&store, "w", "laptop"); - assert!(laptop.control.call(ControlRequest::Ping).is_ok()); - - let desktop = bridged_for(&sock, "tok-desktop", "desktop", Some("w")); - - // The displaced client is told which workspace it lost and to whom. - laptop.expect_preempted("w", "desktop"); - // …and then its link is closed, because this connection existed for that - // workspace: the server closes its stream. - let deadline = Instant::now() + Duration::from_secs(10); - while laptop.control.is_connected() { - assert!( - Instant::now() < deadline, - "the displaced session's link stayed open" - ); - std::thread::sleep(Duration::from_millis(20)); - } - assert_eq!( - laptop - .control - .call(ControlRequest::Ping) - .unwrap_err() - .kind(), - io::ErrorKind::ConnectionReset - ); - - // D8: the newcomer is the one holding it, and it can still work. - await_attachment(&store, "w", "desktop"); - assert!(desktop.control.call(ControlRequest::Ping).is_ok()); - assert_eq!( - desktop.changed_count(), - 0, - "taking over is not a workspace change" - ); - - // Taking it back is the same operation in the other direction — that is all - // the [Take Back] button is. - let back = bridged_for(&sock, "tok-laptop-2", "laptop", None); - let reply = back - .control - .call(ControlRequest::WorkspaceAttach { id: "w".into() }) - .expect("attach"); - assert_eq!( - reply, - ReplyOk::Attached { - took_over_from: Some("desktop".to_string()) - } - ); - desktop.expect_preempted("w", "laptop"); - await_attachment(&store, "w", "laptop"); - - // The link that just did the taking was not opened *for* the workspace, so - // it is a plain machine connection and keeps working — that is the shape the - // GUI has, one link per machine. - assert!(back.control.is_connected()); - assert!(back.control.call(ControlRequest::Ping).is_ok()); -} - -/// Poll until `hostname` holds `workspace`, or fail saying who does. -fn await_attachment(store: &Arc, workspace: &str, hostname: &str) { - let deadline = Instant::now() + Duration::from_secs(10); - loop { - let who = store.attachment(workspace); - if who.as_ref().map(|a| a.hostname.as_str()) == Some(hostname) { - return; - } - assert!( - Instant::now() < deadline, - "{workspace} is held by {who:?}, not {hostname}" - ); - std::thread::sleep(Duration::from_millis(20)); - } -} - -/// A `--stdio --bridge` child connected to an already-listening control socket. -fn bridged(sock: &Path, token: &str) -> Client { - bridged_for(sock, token, "test-client", None) -} - -/// [`bridged`], naming the client machine and, optionally, the workspace this -/// connection is opened *for* — the hello field the takeover keys on. -fn bridged_for(sock: &Path, token: &str, hostname: &str, workspace: Option<&str>) -> Client { - let hello = ControlHello { - control_version: tty7_core::daemon::control::CONTROL_VERSION, - workspace: workspace.map(str::to_string), - client_token: token.to_string(), - client_hostname: hostname.to_string(), - }; - bridged_with(sock, hello) -} - -fn bridged_with(sock: &Path, hello: ControlHello) -> Client { - let mut child = Command::new(env!("CARGO_BIN_EXE_tty7-server")) - .args(["--stdio", "--bridge", "--control-sock"]) - .arg(sock) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .spawn() - .expect("could not start the bridging client"); - let stdout = child.stdout.take().expect("piped"); - let stdin = child.stdin.take().expect("piped"); - let closer: Arc = Arc::new(ServerProcess { - child: Mutex::new(Some(child)), - }); - let events: Arc>> = Arc::new(Mutex::new(Vec::new())); - let sink = Arc::clone(&events); - let control = ControlClient::connect_with( - stdout, - stdin, - Some(closer), - &hello, - Box::new(move |e| sink.lock().unwrap_or_else(|e| e.into_inner()).push(e)), - ) - .expect("bridge handshake"); - let peer_features = control.hello().features.clone(); - Client { - control, - events, - peer_features, - } -} diff --git a/src/core/session.rs b/src/core/session.rs index f15adee4..c466a6cf 100644 --- a/src/core/session.rs +++ b/src/core/session.rs @@ -1,56 +1,52 @@ -//! The gpui-facing half of session persistence. +//! The gpui-facing half of view-state persistence. //! -//! The on-disk model — [`SessionPane`], [`SessionTab`], [`Session`], -//! [`Workspace`], [`Workspaces`] and all the `session.json` IO — lives in -//! `tty7-core`: it is pure serde, and the remote server has to read and write -//! the identical file. What is left here is [`WorkspaceStore`], which is a gpui -//! `Global` and threads every mutation through `&mut App`. +//! The on-disk model — [`WindowView`], [`WindowViews`] and the `views.json` +//! IO — lives in `tty7-core` beside the in-memory [`Session`] shapes. What is +//! left here is [`WorkspaceStore`], which is a gpui `Global` and threads every +//! mutation through `&mut App`. +//! +//! The store holds **no layout**. A workspace's tabs and panes live in its +//! machine's daemon-owned tree; this file remembers only what that tree cannot +//! — which workspaces this client knows, which machine each is on, window +//! geometry, the open flag, and focus recency. pub use tty7_core::core::session::{ - RemoteRef, RemoteTarget, Session, SessionAxis, SessionPane, SessionTab, Workspace, WorkspaceId, - Workspaces, + RemoteRef, RemoteTarget, Session, SessionAxis, SessionPane, SessionTab, WindowView, + WindowViews, WorkspaceId, }; pub use tty7_core::host::HostId; -/// App-level owner of `session.json`, and the single writer to it. +/// App-level owner of `views.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 +/// Windows never touch the file themselves. Each one pushes *its* view state +/// in and the store persists the merged whole — without that, two windows /// doing read-modify-write on the shared file would have the last writer -/// clobber the other's tabs. It also means a window that is closing can record -/// its final state after its own entity is already being torn down. +/// clobber the other's entries. It also means a window that is closing can +/// record its final state after its own entity is already being torn down. pub struct WorkspaceStore { - workspaces: Workspaces, + views: WindowViews, } 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. + /// Read `views.json` and install the result as the app global. Call once, + /// before the first window is built. pub fn init(cx: &mut gpui::App) { - let 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 }); + let views = WindowViews::load().unwrap_or_default(); + cx.set_global(Self { views }); } - /// Install a store holding exactly `workspaces`. + /// Install a store holding exactly `views`. /// /// Tests only, and it exists because [`init`](Self::init) reads the - /// developer's real `session.json`: a test that needs a workspace to be on + /// developer's real `views.json`: a test that needs a workspace to be on /// file must neither depend on what happens to be there nor risk writing to /// it. Every mutating helper already no-ops without the global, so this is /// the one thing a test cannot do for itself. #[cfg(test)] - pub fn install_for_test(cx: &mut gpui::App, workspaces: Workspaces) { - cx.set_global(Self { workspaces }); + pub fn install_for_test(cx: &mut gpui::App, views: WindowViews) { + cx.set_global(Self { views }); } /// Every known workspace. Read-only — mutations go through the helpers so @@ -60,82 +56,79 @@ impl WorkspaceStore { /// test harness, which builds windows directly rather than through /// `ui::windows::open`; "no saved workspaces" is the correct reading there, /// and it keeps a missing global from panicking a render. - pub fn all(cx: &gpui::App) -> &Workspaces { - static EMPTY: std::sync::OnceLock = std::sync::OnceLock::new(); + pub fn all(cx: &gpui::App) -> &WindowViews { + static EMPTY: std::sync::OnceLock = std::sync::OnceLock::new(); match cx.try_global::() { - Some(store) => &store.workspaces, - None => EMPTY.get_or_init(Workspaces::default), + Some(store) => &store.views, + None => EMPTY.get_or_init(WindowViews::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`. + /// panic — and, importantly, so tests never write to a real `views.json`. fn try_store(cx: &mut gpui::App) -> Option<&mut Self> { cx.has_global::().then(|| cx.global_mut::()) } /// Take over an existing workspace to show in a window, or mint a fresh one - /// when `id` is `None` / no longer on file (the "New Workspace" path). Marks it - /// open and returns its id plus the tabs the window should rebuild. - pub fn claim(cx: &mut gpui::App, id: Option) -> (WorkspaceId, Session) { - // Read before the store is borrowed: whether the layout may be rebuilt - // depends on another global (the connection table), and a remote - // workspace whose machine is unreachable must open empty. See - // [`claimable_session`]. - let reachable = id.is_none_or(|id| Self::machine_is_connected(cx, id)); - let instance = Self::serving_instance(cx, id); + /// when `id` is `None` / no longer on file (the "New Workspace" path). + /// Marks it open and returns its id. The layout is not this store's to + /// hand out — the window opens empty and the tree hydration fills it. + pub fn claim(cx: &mut gpui::App, id: Option) -> WorkspaceId { 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()); + return WorkspaceId::new(); }; - 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"), + let id = id.filter(|id| store.views.get(*id).is_some()); + let view = match id { + Some(id) => store.views.get_mut(id).expect("filtered above"), None => { - store.workspaces.workspaces.push(Workspace::default()); - store.workspaces.workspaces.last_mut().expect("just pushed") + store.views.views.push(WindowView::default()); + store.views.views.last_mut().expect("just pushed") } }; - workspace.open = true; - workspace.touch(); - let claimed = ( - workspace.id, - claimable_session(workspace, reachable, instance.as_deref()), - ); - store.workspaces.active = Some(claimed.0); - store.workspaces.save(); + view.open = true; + view.touch(); + let claimed = view.id; + store.views.active = Some(claimed); + store.views.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( + /// Record a window's geometry and persist. Called on every structural + /// change (the same funnel the tree sync rides), so reopening the + /// workspace lands where the user left it. + /// + /// The display hint rides along for the same reason the geometry does: it is + /// what the picker needs about a workspace whose machine is *not* answering, + /// and the moment to capture it is while it still is. Read before the store + /// is borrowed — the answer comes from another global. + pub fn record_geometry( cx: &mut gpui::App, id: WorkspaceId, - session: Session, - window: Option, + window: crate::core::window_state::WindowState, ) { - // Same reason as in [`claim`]: read the connection table before the - // store is borrowed. A window whose machine is unreachable is not - // describing that machine's layout, so it does not get to overwrite the - // copy we have of it — see [`record_session`]. - let reachable = Self::machine_is_connected(cx, id); - let instance = Self::serving_instance(cx, Some(id)); + let hint = Self::all(cx) + .get(id) + .and_then(|view| crate::ui::machine_mirror::display_hint(cx, view)); let Some(store) = Self::try_store(cx) else { return; }; - let Some(workspace) = store.workspaces.get_mut(id) else { + let Some(view) = store.views.get_mut(id) else { // The workspace was closed out from under us (its window is // tearing down); nothing to record. return; }; - record_session(workspace, session, reachable, instance); - if let Some(window) = window { - workspace.window = Some(window); + view.window = Some(window); + // Only ever replaced by something better: a machine that has gone quiet + // must not blank the label it gave us while it was up. + if let Some((label, subject)) = hint { + view.label = Some(label); + view.subject = subject; } - store.workspaces.save(); + store.views.save(); } /// Mark the focused workspace, so the next launch restores focus to the @@ -144,23 +137,16 @@ impl WorkspaceStore { let Some(store) = Self::try_store(cx) else { return; }; - if let Some(workspace) = store.workspaces.get_mut(id) { - workspace.touch(); + if let Some(view) = store.views.get_mut(id) { + view.touch(); } - store.workspaces.active = Some(id); - store.workspaces.save(); - } - - /// Set (or clear, with `None`) a workspace's user-chosen name. Clearing - /// falls back to the derived repo/cwd name — see [`Workspace::display_name`]. - pub fn rename(cx: &mut gpui::App, id: WorkspaceId, name: Option) { - let Some(store) = Self::try_store(cx) else { - return; - }; - if let Some(workspace) = store.workspaces.get_mut(id) { - workspace.name = name; - } - store.workspaces.save(); + store.views.active = Some(id); + store.views.save(); + // The machine's tree keeps its own recency (its pickers order by it), + // so the focus is a fact to report there too. + crate::ui::tree_sync::fire_workspace_op(cx, id, |ws| { + tty7_core::daemon::control::ControlRequest::WorkspaceTouch { workspace: ws } + }); } /// Pick the one workspace launch will show, and detach every other one that @@ -177,16 +163,16 @@ impl WorkspaceStore { /// the home page". pub fn restore_one(cx: &mut gpui::App) -> Option { let store = Self::try_store(cx)?; - let keep = store.workspaces.workspace_to_restore()?; + let keep = store.views.workspace_to_restore()?; let mut detached = 0usize; - for workspace in &mut store.workspaces.workspaces { - if workspace.open && workspace.id != keep { - workspace.open = false; + for view in &mut store.views.views { + if view.open && view.id != keep { + view.open = false; detached += 1; } } - store.workspaces.active = Some(keep); - store.workspaces.save(); + store.views.active = Some(keep); + store.views.save(); if detached > 0 { log::info!("launch: restoring 1 workspace, left {detached} detached"); } @@ -196,54 +182,42 @@ impl WorkspaceStore { /// Detach a workspace: its window is gone, but the panes keep running in /// the daemon and the entry stays for the picker to reopen. pub fn close_window(cx: &mut gpui::App, id: WorkspaceId) { + // The last moment this client can see what the machine calls the + // workspace — and a detached workspace is precisely what the picker + // lists, so the hint matters most here. Read before the borrow, as in + // [`record_geometry`](Self::record_geometry). + let hint = Self::all(cx) + .get(id) + .and_then(|view| crate::ui::machine_mirror::display_hint(cx, view)); let Some(store) = Self::try_store(cx) else { return; }; - if let Some(workspace) = store.workspaces.get_mut(id) { - workspace.open = false; - workspace.touch(); + if let Some(view) = store.views.get_mut(id) { + view.open = false; + view.touch(); + if let Some((label, subject)) = hint { + view.label = Some(label); + view.subject = subject; + } } - store.workspaces.save(); - } - - /// Drop the pane ids a workspace claims, keeping its layout. Answers - /// whether anything changed, so a caller can skip the follow-up push to a - /// remote that owns the record. - /// - /// Called right after those panes have been killed — see - /// [`Workspace::forget_pane_ids`] for why the ids have to go rather than - /// being left for the reattach to trip over. - pub fn forget_pane_ids(cx: &mut gpui::App, id: WorkspaceId) -> bool { - let Some(store) = Self::try_store(cx) else { - return false; - }; - let Some(workspace) = store.workspaces.get_mut(id) else { - return false; - }; - let forgotten = workspace.forget_pane_ids(); - if forgotten == 0 { - return false; - } - store.workspaces.save(); - log::info!("workspace {id} forgot {forgotten} pane id(s): its sessions were ended"); - true + store.views.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. + /// The caller is responsible for the machine-side half (killing panes, + /// `WorkspaceRemove`); this only drops the client's pointer. pub fn remove(cx: &mut gpui::App, id: WorkspaceId) { let Some(store) = Self::try_store(cx) else { return; }; - store.workspaces.workspaces.retain(|w| w.id != id); - if store.workspaces.active == Some(id) { - store.workspaces.active = None; + store.views.views.retain(|w| w.id != id); + if store.views.active == Some(id) { + store.views.active = None; } - store.workspaces.save(); + store.views.save(); } - // ----- the client / remote storage split ------------------- + // ----- the client / machine split ------------------- /// The machine a workspace's panes are on. `HostId::LOCAL` for a workspace /// this client owns, and for an id that is no longer on file — a window @@ -258,71 +232,16 @@ impl WorkspaceStore { } /// Whether this client can reach the machine `id`'s panes are on *right - /// now* — the predicate both halves of the layout cache turn on - /// ([`claimable_session`], [`record_session`]). + /// now*. /// /// A local workspace is always reachable: its daemon is this machine's, and /// a gate that could answer otherwise for a local window would stop it - /// saving its own tabs. + /// acting on its own workspace. pub fn machine_is_connected(cx: &mut gpui::App, id: WorkspaceId) -> bool { let Some(host) = Self::remote_ref(cx, id) else { return true; }; - crate::ui::remote_connect::RemoteConnections::get(cx, host.host_id()).is_some() - } - - /// The process whose pane ids this workspace's record is about: this - /// machine's daemon for a local workspace, the far machine's `tty7-server` - /// for a remote one. `None` when it cannot be named — an older peer, a - /// machine not connected right now, or a brand-new workspace with no host - /// yet — which every reader treats as "no instance check possible". - /// - /// One function for both because [`Workspace::daemon_instance`] means the - /// same thing on both sides. It used to be local-only, on the reasoning - /// that a remote server's identity is tracked live per connection instead - /// — but that live map lives in memory, so it is empty on the launch that - /// matters most: the one where the client was closed while the remote - /// server was replaced. - pub fn serving_instance(cx: &mut gpui::App, id: Option) -> Option { - match id.and_then(|id| Self::remote_ref(cx, id)) { - Some(host) => crate::ui::remote_connect::RemoteConnections::get(cx, host.host_id()) - .map(|h| h.peer().instance.clone()) - .filter(|instance| !instance.is_empty()), - None => crate::daemon::spawn::local_daemon_instance(), - } - } - - /// Blank `id`'s saved pane ids when they were recorded against a different - /// server process than `instance`, and persist that. Answers whether any - /// were dropped. - /// - /// The remote counterpart of the check [`claimable_session`] runs for a - /// local workspace at claim time. It cannot run there for a remote one: at - /// claim time the machine is usually not connected yet, so there is no - /// instance to compare against. The reconnect is the first moment the - /// answer exists, which is where this is called from. - pub fn forget_stale_pane_ids(cx: &mut gpui::App, id: WorkspaceId, instance: &str) -> bool { - let current = (!instance.is_empty()).then_some(instance); - let Some(store) = Self::try_store(cx) else { - return false; - }; - let Some(workspace) = store.workspaces.get_mut(id) else { - return false; - }; - let dropped = workspace.forget_stale_pane_ids(current); - if dropped == 0 { - return false; - } - // Stamped now rather than left for the next save: the record has just - // been made to describe *this* server, and a crash before the window - // saves must not leave it claiming the old process again. - workspace.daemon_instance = current.map(str::to_string); - store.workspaces.save(); - log::info!( - "workspace {id}: {dropped} saved pane id(s) belong to a previous \ - tty7-server process; rebuilding from the layout" - ); - true + crate::ui::remote_connect::HostLinks::get(cx, host.host_id()).is_some() } /// The client-side entry for `host` — the existing one if this machine has @@ -331,7 +250,7 @@ impl WorkspaceStore { /// The two ids are deliberately different things: the entry has its own /// [`WorkspaceId`] (this client's handle, what the window registry and the /// Window menu key on), and `host.workspace` is the id **on the remote**, - /// which is what the `WorkspacePut` / `WorkspaceGet` calls carry. Reusing + /// which is what the machine-tree operations carry. Reusing /// one id for both would collide the moment two machines minted the same /// uuid, and would quietly make a client id meaningful off this machine. /// @@ -344,64 +263,23 @@ impl WorkspaceStore { return WorkspaceId::new(); }; let existing = store - .workspaces - .workspaces + .views + .views .iter() .find(|w| w.host.as_ref() == Some(&host)) .map(|w| w.id); let id = match existing { Some(id) => id, None => { - let workspace = Workspace::on_remote(host); - let id = workspace.id; - store.workspaces.workspaces.push(workspace); + let view = WindowView::on_remote(host); + let id = view.id; + store.views.views.push(view); id } }; - store.workspaces.save(); + store.views.save(); id } - - /// Merge an authoritative record pulled from the remote into the client's - /// entry. Only the remote-owned fields move; `open`, `window` and `host` - /// stay as this machine left them (see [`Workspace::apply_remote_json`]). - /// - /// A record that will not decode is dropped with a log line rather than - /// failing the open: the layout is recoverable on the next push, an - /// unopenable workspace is not. - pub fn apply_remote(cx: &mut gpui::App, id: WorkspaceId, record: &serde_json::Value) { - let Some(store) = Self::try_store(cx) else { - return; - }; - let Some(workspace) = store.workspaces.get_mut(id) else { - return; - }; - if let Err(e) = workspace.apply_remote_json(record) { - log::warn!("remote workspace {id} sent a record this build cannot read: {e}"); - return; - } - store.workspaces.save(); - } - - /// What to send the remote for `id`: its store key and the remote-owned half - /// of the record. `None` for a local workspace — there is nobody to send to. - pub fn remote_payload( - cx: &gpui::App, - id: WorkspaceId, - ) -> Option<(RemoteRef, String, serde_json::Value)> { - let workspace = Self::all(cx).get(id)?; - let host = workspace.host.clone()?; - let key = host.store_key(); - // The record travels under the *remote's* id, not the client entry's: - // the remote store is keyed by its own ids, and a record whose `id` - // disagreed with its key would be a workspace that renames itself on - // every round trip. - let mut record = workspace.to_remote_json(); - if let Some(obj) = record.as_object_mut() { - obj.insert("id".to_string(), serde_json::json!(key)); - } - Some((host, key, record)) - } } /// The machine a window showing `id` is bound to. @@ -409,17 +287,14 @@ impl WorkspaceStore { /// The whole of "one window, one machine" reduces to this being a *function*: a /// window shows one workspace, a workspace names one host, so a window has one /// host and there is no arrangement of the data in which it has two. Split out -/// from [`WorkspaceStore::host_of`] so it can be tested against a workspace set +/// from [`WorkspaceStore::host_of`] so it can be tested against a view set /// built by hand, with no globals and nothing written to disk. /// /// An id that is not on file answers `LOCAL`: a window whose workspace was /// deleted out from under it is showing nothing, and "nothing" is here — the /// safe answer, because it is the one that refuses no local action. -pub(crate) fn host_for(workspaces: &Workspaces, id: WorkspaceId) -> HostId { - workspaces - .get(id) - .map(|w| w.host_id()) - .unwrap_or(HostId::LOCAL) +pub(crate) fn host_for(views: &WindowViews, id: WorkspaceId) -> HostId { + views.get(id).map(|w| w.host_id()).unwrap_or(HostId::LOCAL) } /// Whether rebinding a window from `previous` to `current` moved it to another @@ -429,451 +304,10 @@ pub(crate) fn crosses_machines(previous: HostId, current: HostId) -> bool { previous != current } -/// The layout a window opening on `workspace` may rebuild — the read-side twin -/// of [`record_session`]. -/// -/// A remote entry's `session` is this client's copy of a record the machine -/// owns: pulled on connect, refreshed on every `WorkspaceChanged`, pushed back -/// on every structural change. Rebuilding from it is the whole of "reconnecting -/// gets my tabs back", and it is safe to do because every pane it names is -/// routed by [`crate::ui::remote_workspace::pane_workspace_for`] — a leaf in a -/// remote workspace attaches or spawns *over there*, and a machine that cannot -/// be reached fails the spawn rather than falling back to a local shell. -/// -/// `reachable` is what keeps that guarantee from being theoretical. With the -/// link down, `List` answers nothing, so every leaf would miss its live pane and -/// try to spawn a fresh one — either failing (an empty window, having thrown the -/// layout away) or, worse, landing a second shell next to the one still running -/// over there. So an unreachable remote workspace opens empty **without -/// touching the cached layout**, and -/// [`crate::ui::remote_workspace`]'s connect path rebuilds the window the moment -/// the machine answers. -/// `current_instance` is the identity of the process serving this workspace's -/// panes (see [`WorkspaceStore::serving_instance`]). A workspace whose saved ids -/// were recorded against a different one blanks them first — after a restart the -/// numbers begin again at 1, so a stale id would otherwise pass the aliveness -/// check by landing on whatever unrelated pane holds it now. Blanked in the -/// stored entry too, not just the returned copy, so the record stops claiming -/// panes that no longer exist even if the window never saves again. -/// -/// A remote workspace usually reaches the early return above instead: at claim -/// time its machine is not connected yet, so there is no instance to compare and -/// no layout to hand back. `remote_workspace::finish_attempt` runs the same -/// check the moment the connect answers, which is the first point it can. -fn claimable_session( - workspace: &mut Workspace, - reachable: bool, - current_instance: Option<&str>, -) -> Session { - if workspace.is_remote() && !reachable { - return Session::default(); - } - let dropped = workspace.forget_stale_pane_ids(current_instance); - if dropped > 0 { - log::info!( - "workspace {}: {dropped} saved pane id(s) belong to a previous serving \ - process; restoring with fresh shells (and agent resume where recorded)", - workspace.id - ); - } - workspace.session.clone() -} - -/// Write a window's layout onto its entry — the write-side twin of -/// [`claimable_session`]. -/// -/// A window that cannot reach its machine is not describing that machine's -/// layout (its panes failed to restore, or are sitting there disconnected), so -/// it records nothing rather than replacing the copy we have with the wreckage. -/// The remote's own `workspaces.json` is still the authority; this entry is the -/// cache the next launch opens from. -/// The record is stamped with the process its pane ids came from (`instance`): -/// this machine's daemon for a local workspace, the far machine's -/// `tty7-server` for a remote one. That is what lets the next launch tell a -/// surviving process from a replaced one — see [`claimable_session`] and -/// [`WorkspaceStore::forget_stale_pane_ids`]. -/// -/// The unreachable early return doubles as the guard on that stamp: with the -/// machine down there is no instance to record, and writing `None` over a good -/// one would throw away the very comparison the next connect needs. -fn record_session( - workspace: &mut Workspace, - session: Session, - reachable: bool, - instance: Option, -) { - if workspace.is_remote() && !reachable { - return; - } - workspace.session = session; - workspace.daemon_instance = instance; -} - #[cfg(test)] mod tests { use super::*; - fn leaf(cwd: &str) -> SessionPane { - SessionPane::Leaf { - cwd: Some(std::path::PathBuf::from(cwd)), - pane_id: Some(7), - ssh_spec: None, - agent: None, - agent_session_id: None, - agent_launch_argv: None, - } - } - - fn local_layout() -> Session { - Session { - tabs: vec![SessionTab { - name: None, - sidebar_group: None, - pane: leaf("/Users/me/work"), - }], - ..Session::default() - } - } - - fn remote_ref() -> RemoteRef { - RemoteRef::new( - RemoteTarget::Alias { - alias: "build-box".into(), - }, - WorkspaceId::new(), - ) - } - - /// A local workspace records its layout the way it always did. - #[test] - fn a_local_workspace_stores_its_own_layout() { - let mut workspace = Workspace::default(); - record_session(&mut workspace, local_layout(), true, None); - assert_eq!(workspace.session.tabs.len(), 1); - assert_eq!(workspace.pane_ids(), vec![7]); - } - - /// The point of the whole cache: a connected remote window's layout is - /// kept, so the next launch has something to open from and - /// `remote_payload` has something to push. Without this, reconnecting to a - /// machine gives an empty window every time. - #[test] - fn a_connected_remote_workspace_stores_its_layout() { - let mut workspace = Workspace::on_remote(remote_ref()); - record_session(&mut workspace, local_layout(), true, None); - assert_eq!(workspace.session.tabs.len(), 1); - assert_eq!( - workspace.pane_ids(), - vec![7], - "the pane ids are the remote daemon's, and are what a reconnect re-attaches" - ); - } - - /// A remote workspace's record is stamped with the **server's** instance, - /// not left blank. That stamp is the only part of "which process minted - /// these ids" that survives the client being closed, and it is what the - /// next connect compares against before re-attaching anything. - #[test] - fn a_connected_remote_workspace_records_the_serving_instance() { - let mut workspace = Workspace::on_remote(remote_ref()); - record_session( - &mut workspace, - local_layout(), - true, - Some("server-a".to_string()), - ); - assert_eq!(workspace.daemon_instance.as_deref(), Some("server-a")); - } - - /// …and an unreachable machine does not un-stamp it. `None` there means - /// "nobody to ask", and writing it over a good value would disarm the very - /// check the next connect needs — the ids would look current again. - #[test] - fn an_unreachable_remote_window_does_not_erase_the_recorded_instance() { - let mut workspace = Workspace::on_remote(remote_ref()); - workspace.session = local_layout(); - workspace.daemon_instance = Some("server-a".to_string()); - record_session(&mut workspace, Session::default(), false, None); - assert_eq!(workspace.daemon_instance.as_deref(), Some("server-a")); - assert_eq!(workspace.session.tabs.len(), 1, "and the layout stays too"); - } - - /// A local workspace opens on the layout it saved. - #[test] - fn a_local_workspace_reopens_its_saved_layout() { - let mut workspace = Workspace { - session: local_layout(), - ..Workspace::default() - }; - let claimed = claimable_session(&mut workspace, true, None); - assert_eq!(claimed.tabs.len(), 1); - // And the entry is left alone. - assert_eq!(workspace.session.tabs.len(), 1); - } - - /// Claiming a local workspace whose ids were recorded against a *different* - /// daemon process blanks them — in the returned session **and** in the - /// stored entry. After a reboot the numbers restart from 1, so a stale id - /// passes the aliveness check by landing on whatever unrelated pane holds - /// it now; blanking is what turns that into an honest fresh spawn (with - /// the agent resume the leaf recorded). - #[test] - fn claiming_a_local_workspace_from_another_daemon_process_blanks_its_ids() { - let mut workspace = Workspace { - session: local_layout(), - daemon_instance: Some("previous-boot".into()), - ..Workspace::default() - }; - let leaf_id = |session: &Session| match &session.tabs[0].pane { - SessionPane::Leaf { pane_id, .. } => *pane_id, - SessionPane::Split { .. } => panic!("the fixture is a single leaf"), - }; - let claimed = claimable_session(&mut workspace, true, Some("current-boot")); - assert_eq!(claimed.tabs.len(), 1, "the layout still restores"); - assert_eq!( - leaf_id(&claimed), - None, - "but no leaf may attach by a number from a dead daemon" - ); - assert!(workspace.pane_ids().is_empty(), "the entry agrees"); - - // Same process → the ids stay attachable. - let mut workspace = Workspace { - session: local_layout(), - daemon_instance: Some("current-boot".into()), - ..Workspace::default() - }; - let claimed = claimable_session(&mut workspace, true, Some("current-boot")); - assert_eq!(leaf_id(&claimed), Some(7)); - } - - /// A connected remote workspace reopens on the layout its machine last - /// reported — the read half of "reconnecting gets my tabs back". - #[test] - fn a_connected_remote_workspace_reopens_its_layout() { - let mut workspace = Workspace::on_remote(remote_ref()); - workspace.session = local_layout(); - let claimed = claimable_session(&mut workspace, true, None); - assert_eq!(claimed.tabs.len(), 1); - assert_eq!(workspace.session.tabs.len(), 1); - } - - /// With the machine unreachable, `List` answers nothing, so every leaf - /// would miss its live pane and try to spawn a fresh one beside it. The - /// window opens empty instead — and, the half that took a real launch to - /// get right, **the cached layout survives**: it is what the connect path - /// rebuilds the window from a moment later. - #[test] - fn an_unreachable_remote_workspace_opens_empty_but_keeps_its_layout() { - let mut workspace = Workspace::on_remote(remote_ref()); - workspace.session = local_layout(); - - let claimed = claimable_session(&mut workspace, false, None); - assert!(claimed.tabs.is_empty(), "the window must open with no tabs"); - assert_eq!( - workspace.session.tabs.len(), - 1, - "and the layout must still be there for the connect to rebuild from" - ); - } - - /// The write-side twin: a window that could not restore its panes is not - /// describing the machine's layout, so its empty tab list must not replace - /// the copy we have of it. - #[test] - fn an_unreachable_remote_window_does_not_overwrite_the_cached_layout() { - let mut workspace = Workspace::on_remote(remote_ref()); - workspace.session = local_layout(); - record_session(&mut workspace, Session::default(), false, None); - assert_eq!(workspace.session.tabs.len(), 1); - } - - /// The launch path end to end, with the store's own reachability lookup - /// rather than a hand-passed flag: nothing has ever connected to that - /// machine in this process, so the window opens empty and the layout it - /// will be rebuilt from is still on file afterwards. - /// - /// The config dir is pinned first because `claim` and `record` both persist - /// — without it this test would rewrite the developer's real - /// `session.json`. - #[gpui::test] - fn an_unconnected_machine_keeps_its_workspace_layout_across_a_claim( - cx: &mut gpui::TestAppContext, - ) { - cx.update(|cx| { - // The same path every other config-pinning test in this process - // uses: `set_config_dir` is first-call-wins, so a test that pinned - // a *different* scratch would silently redirect whichever tests - // lost the race away from the directory they then read back. - crate::core::config::pin_test_config_dir(); - - let mut entry = Workspace::on_remote(remote_ref()); - entry.session = local_layout(); - let id = entry.id; - WorkspaceStore::install_for_test( - cx, - Workspaces { - workspaces: vec![entry], - active: None, - }, - ); - - let (claimed, session) = WorkspaceStore::claim(cx, Some(id)); - assert_eq!(claimed, id); - assert!( - session.tabs.is_empty(), - "an unreachable machine's window opens empty" - ); - - // …and the window recording that emptiness does not erase what the - // machine still has. - WorkspaceStore::record(cx, id, Session::default(), None); - assert_eq!( - WorkspaceStore::all(cx).get(id).unwrap().session.tabs.len(), - 1, - "the cached layout must survive for the connect to rebuild from" - ); - }); - } - - /// "End Sessions" kills the panes and then has to say so on file, or - /// reopening the workspace walks into the reattach path with ids nothing - /// answers to. The second call answering `false` is what lets the caller - /// skip the push that follows. - #[gpui::test] - fn forgetting_a_workspaces_panes_is_recorded_once(cx: &mut gpui::TestAppContext) { - cx.update(|cx| { - crate::core::config::pin_test_config_dir(); - - let mut entry = Workspace::on_remote(remote_ref()); - entry.session = local_layout(); - let id = entry.id; - WorkspaceStore::install_for_test( - cx, - Workspaces { - workspaces: vec![entry], - active: None, - }, - ); - assert_eq!(WorkspaceStore::all(cx).get(id).unwrap().pane_ids(), vec![7]); - - assert!(WorkspaceStore::forget_pane_ids(cx, id)); - let after = WorkspaceStore::all(cx).get(id).unwrap(); - assert!(after.pane_ids().is_empty()); - assert_eq!( - after.session.tabs.len(), - 1, - "the layout is exactly what reopening rebuilds from" - ); - assert!( - !WorkspaceStore::forget_pane_ids(cx, id), - "nothing left to forget" - ); - }); - } - - /// **The cold-launch half of the restart check.** `RemoteLinks::instances` - /// is in memory, so on the first connect after the client starts every - /// machine is a first sighting and nothing is judged a restart. A server - /// replaced while the client was closed would therefore sail through, and - /// its recycled ids — daemons number panes from 1 — would attach to - /// whatever unrelated shells hold those numbers now. The stamp on the - /// record is what closes that, so this is the test that has to hold. - #[gpui::test] - fn a_remote_workspace_drops_pane_ids_minted_by_a_previous_server( - cx: &mut gpui::TestAppContext, - ) { - cx.update(|cx| { - crate::core::config::pin_test_config_dir(); - - let mut entry = Workspace::on_remote(remote_ref()); - entry.session = local_layout(); - entry.daemon_instance = Some("server-a".to_string()); - let id = entry.id; - WorkspaceStore::install_for_test( - cx, - Workspaces { - workspaces: vec![entry], - active: None, - }, - ); - - // Same process: these ids still name the panes they always did. - assert!(!WorkspaceStore::forget_stale_pane_ids(cx, id, "server-a")); - assert_eq!(WorkspaceStore::all(cx).get(id).unwrap().pane_ids(), vec![7]); - - // An unknown instance is never judged — a peer too old to report - // one must not cost the user every pane on the machine. - assert!(!WorkspaceStore::forget_stale_pane_ids(cx, id, "")); - assert_eq!(WorkspaceStore::all(cx).get(id).unwrap().pane_ids(), vec![7]); - - // Replaced: the claims go, the layout stays, and the stamp moves on. - assert!(WorkspaceStore::forget_stale_pane_ids(cx, id, "server-b")); - let after = WorkspaceStore::all(cx).get(id).unwrap(); - assert!(after.pane_ids().is_empty()); - assert_eq!( - after.session.tabs.len(), - 1, - "the layout is exactly what the rebuild draws from" - ); - assert_eq!( - after.daemon_instance.as_deref(), - Some("server-b"), - "stamped now, so a crash before the next save cannot re-arm the old claim" - ); - - // And the same server is not a restart twice over. - assert!(!WorkspaceStore::forget_stale_pane_ids(cx, id, "server-b")); - }); - } - - /// **Why clearing the ids locally is not enough.** The remote owns the - /// record, so reopening pulls its copy over the client's — and - /// a copy that still claims the killed panes puts them straight back. This - /// is the constraint `windows::forget_killed_panes` pushes to satisfy; if - /// this assertion ever flips, that push is dead weight. - #[test] - fn a_remote_record_reinstates_pane_ids_a_client_only_clear_dropped() { - let mut theirs = Workspace::on_remote(remote_ref()); - theirs.session = local_layout(); - let record = theirs.to_remote_json(); - - let mut ours = Workspace::on_remote(remote_ref()); - ours.session = local_layout(); - ours.forget_pane_ids(); - assert!(ours.pane_ids().is_empty()); - - ours.apply_remote_json(&record).unwrap(); - assert_eq!( - ours.pane_ids(), - vec![7], - "the machine's copy wins, so the clear has to reach it" - ); - } - - /// The remote-bound payload travels under the *remote's* id, so a record - /// pushed and pulled back names the same workspace both times. - #[test] - fn the_remote_payload_is_keyed_by_the_remote_id_not_the_client_entry() { - let host = remote_ref(); - let workspace = Workspace::on_remote(host.clone()); - let mut record = workspace.to_remote_json(); - record - .as_object_mut() - .unwrap() - .insert("id".into(), serde_json::json!(host.store_key())); - assert_eq!(host.store_key(), host.workspace.to_string()); - assert_ne!(host.store_key(), workspace.id.to_string()); - assert_eq!(record["id"], serde_json::json!(host.store_key())); - // The client-owned half never crosses. - for client_only in tty7_core::core::session::CLIENT_OWNED_FIELDS { - assert!( - record.get(*client_only).is_none(), - "{client_only} must not be sent to the remote" - ); - } - } - /// **The window/host invariant, as a test.** /// /// A window is one machine. The inverse is listed under @@ -882,7 +316,7 @@ mod tests { /// stays a bare `PathBuf` — so it has to be nailed down rather than /// believed. /// - /// What is actually being asserted: for any workspace set containing local + /// What is actually being asserted: for any view set containing local /// and remote entries on several machines, the host a window binds to is a /// *function* of the workspace it shows. Every id answers exactly one /// machine, and no id answers two. @@ -893,23 +327,23 @@ mod tests { }; let gpu = RemoteTarget::direct("me", "gpu.lab", 2222); - let local = Workspace::default(); - let build_a = Workspace::on_remote(RemoteRef::new(build.clone(), WorkspaceId::new())); - let build_b = Workspace::on_remote(RemoteRef::new(build, WorkspaceId::new())); - let gpu_a = Workspace::on_remote(RemoteRef::new(gpu, WorkspaceId::new())); + let local = WindowView::default(); + let build_a = WindowView::on_remote(RemoteRef::new(build.clone(), WorkspaceId::new())); + let build_b = WindowView::on_remote(RemoteRef::new(build, WorkspaceId::new())); + let gpu_a = WindowView::on_remote(RemoteRef::new(gpu, WorkspaceId::new())); let (local_id, build_a_id, build_b_id, gpu_id) = (local.id, build_a.id, build_b.id, gpu_a.id); - let workspaces = Workspaces { - workspaces: vec![local, build_a, build_b, gpu_a], - ..Workspaces::default() + let views = WindowViews { + views: vec![local, build_a, build_b, gpu_a], + ..WindowViews::default() }; // Three machines are represented, and they stay apart. - let l = host_for(&workspaces, local_id); - let b1 = host_for(&workspaces, build_a_id); - let b2 = host_for(&workspaces, build_b_id); - let g = host_for(&workspaces, gpu_id); + let l = host_for(&views, local_id); + let b1 = host_for(&views, build_a_id); + let b2 = host_for(&views, build_b_id); + let g = host_for(&views, gpu_id); assert_eq!(l, HostId::LOCAL); assert_eq!(b1, b2, "two workspaces on one box share its connection"); assert_ne!(b1, g); @@ -917,11 +351,11 @@ mod tests { assert_ne!(g, l); // The answer is stable: asking twice cannot give a window a second host. - assert_eq!(host_for(&workspaces, build_a_id), b1); + assert_eq!(host_for(&views, build_a_id), b1); // And a window whose workspace was deleted underneath it falls back to // local rather than to some other machine's id. - assert_eq!(host_for(&workspaces, WorkspaceId::new()), HostId::LOCAL); + assert_eq!(host_for(&views, WorkspaceId::new()), HostId::LOCAL); // Only a host change is a machine change — the trigger for dropping the // per-window state (the closed-tab stack) that could otherwise carry a @@ -942,10 +376,10 @@ mod tests { let other = RemoteTarget::Alias { alias: "other-box".into(), }; - let a = Workspace::on_remote(RemoteRef::new(build.clone(), WorkspaceId::new())); - let b = Workspace::on_remote(RemoteRef::new(build, WorkspaceId::new())); - let c = Workspace::on_remote(RemoteRef::new(other, WorkspaceId::new())); - let local = Workspace::default(); + let a = WindowView::on_remote(RemoteRef::new(build.clone(), WorkspaceId::new())); + let b = WindowView::on_remote(RemoteRef::new(build, WorkspaceId::new())); + let c = WindowView::on_remote(RemoteRef::new(other, WorkspaceId::new())); + let local = WindowView::default(); assert_eq!(a.host_id(), b.host_id()); assert_ne!(a.host_id(), c.host_id()); diff --git a/src/core/update.rs b/src/core/update.rs index 5be71b18..4dabbce2 100644 --- a/src/core/update.rs +++ b/src/core/update.rs @@ -199,7 +199,7 @@ pub fn open_releases_page() { } /// Tiny persisted state for the update checker, stored at `update.json` in the -/// config dir (alongside `config.json` / `session.json`). Currently just the +/// config dir (alongside `config.json` / `views.json`). Currently just the /// last version we popped the modal for, so we never nag twice for one release. #[derive(Debug, Default, serde::Serialize, serde::Deserialize)] struct UpdateState { diff --git a/src/core/window_state.rs b/src/core/window_state.rs index cd15b728..0436b37a 100644 --- a/src/core/window_state.rs +++ b/src/core/window_state.rs @@ -1,11 +1,10 @@ //! The gpui-facing half of [`WindowState`]. //! //! The struct itself, its `window.json` IO, and the "is this geometry sane" -//! guard live in `tty7-core` — `session.json` embeds the geometry in each -//! [`Workspace`](crate::core::session::Workspace), so it has to parse on a -//! machine that never links gpui. What is left here is the only part that -//! genuinely needs gpui: turning the four stored `f32`s into a -//! [`Bounds`] and back. +//! guard live in `tty7-core` — `views.json` embeds the geometry in each +//! [`WindowView`](crate::core::session::WindowView), which is defined there. +//! What is left here is the only part that genuinely needs gpui: turning the +//! four stored `f32`s into a [`Bounds`] and back. use gpui::{Bounds, Pixels, point, px}; diff --git a/src/main.rs b/src/main.rs index b8448860..7f5550af 100644 --- a/src/main.rs +++ b/src/main.rs @@ -79,7 +79,7 @@ fn spawn_config_watcher(cx: &mut App) { let Ok(event) = res else { return }; // React to events that touch our `config.json`, or a theme file dropped // into the `themes/` subfolder — both feed the same registry reload below. - // Everything else in the dir (`session.json`, `history`, the daemon + // Everything else in the dir (`views.json`, `history`, the daemon // socket, and our own `.config.json.tmp.` / `*.yaml.tmp.` atomic // scratch files, whose extensions aren't theme extensions) is ignored. let hit = event @@ -317,9 +317,13 @@ fn main() { // Daemon mode: when launched with `--daemon` we run the headless persistent // terminal server and never open a window. This is the backing process the GUI // auto-spawns and reconnects to; it owns all PTYs + child shells and outlives - // the GUI. Run to completion (the accept loop blocks until killed) then return. + // the GUI. It is the *same* daemon `tty7-server --daemon` runs on a remote + // box — panes plus the control dialect — because a local machine and a + // remote one are the same thing seen from different distances, and the + // workspace tree both serve lives behind control. Run to completion (the + // accept loop blocks until killed) then return. if std::env::args().any(|a| a == "--daemon") { - if let Err(e) = crate::daemon::server::run() { + if let Err(e) = crate::daemon::server::run_daemon() { log::error!("daemon exited with error: {e}"); } return; @@ -378,10 +382,9 @@ fn main() { // theme from it. It has to be read here, off the appearance-observer // path — see `ui::theme::SystemAppearance`. crate::ui::theme::refresh_system_appearance(cx); - // Read `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. + // Read `views.json` before any window is built: windows claim + // their workspace from this store rather than each parsing the + // file themselves. crate::core::session::WorkspaceStore::init(cx); // The window registry has to exist before the first window opens — // `ui::windows::open` registers into it. @@ -408,20 +411,26 @@ fn main() { }) .detach(); keymap::init(cx); + // Hold a control link to this machine's own daemon, exactly as a + // remote machine gets one: the daemon owns the workspace tree and + // serves it over control, so the local GUI is a control client + // like any other. Supervised on its own forever loop — see + // `ui::local_link`. + crate::ui::local_link::LocalLink::install(cx); // Come up on the *one* workspace the user was last in, at its own // remembered geometry (`ui::windows` owns that logic, since "New // Workspace" and the workspace picker need the identical path). // // Deliberately one window, not one per workspace that was open at - // quit: see `Workspaces::workspace_to_restore` for why, and + // quit: see `WindowViews::workspace_to_restore` for why, and // `WorkspaceStore::restore_one` for what happens to the others (they // are detached, not forgotten — panes keep running and the switcher // lists them). Quitting with every window closed — or a first run — // opens a single window on a fresh workspace. let any_saved = { let store = crate::core::session::WorkspaceStore::all(cx); - !store.workspaces.is_empty() + !store.views.is_empty() }; let reopen = crate::core::session::WorkspaceStore::restore_one(cx); // With nothing to reopen, what that one window should hold depends on diff --git a/src/terminal/element.rs b/src/terminal/element.rs index 0e6f37a8..979f10ee 100644 --- a/src/terminal/element.rs +++ b/src/terminal/element.rs @@ -1273,15 +1273,32 @@ impl TerminalElement { let Some(link) = self.view.read(cx).hovered_link.as_ref() else { return; }; - let row = link.line + display_offset; - if row < 0 || row as usize >= rows { - return; - } - let row = row as usize; - let mut col = link.start; - while col <= link.end && col < cols { - buf[row * cols + col].link_hover = true; - col += 1; + // The link may span several rows — a soft wrap, or a URL a program + // split with a hard newline. Paint every covered cell: full columns on + // the interior rows, clamped to `start`/`end` on the first and last. + let (start, end) = (link.start, link.end); + let mut line = start.line.0; + while line <= end.line.0 { + let grid_row = line + display_offset; + if grid_row >= 0 && (grid_row as usize) < rows { + let grid_row = grid_row as usize; + let col_start = if line == start.line.0 { + start.column.0 + } else { + 0 + }; + let col_end = if line == end.line.0 { + end.column.0 + } else { + cols.saturating_sub(1) + }; + let mut col = col_start; + while col <= col_end && col < cols { + buf[grid_row * cols + col].link_hover = true; + col += 1; + } + } + line += 1; } } diff --git a/src/terminal/pane_liveness.rs b/src/terminal/pane_liveness.rs index d0aac4cf..cb4e5a6c 100644 --- a/src/terminal/pane_liveness.rs +++ b/src/terminal/pane_liveness.rs @@ -32,12 +32,20 @@ //! probably fine and the *link* is what broke — and rendering it as "stopped" //! would tell the user their work is gone every time the network blinks. //! -//! **`Unknown` is never shown for this machine.** A local `List` travels a unix -//! socket to a daemon whose absence is itself the answer: no daemon, no live -//! panes. So a local host with no cached answer reads `Stopped`, which is what +//! **A local `List` failing is not `Unknown`.** It travels a unix socket to a +//! daemon whose absence is itself the answer: no daemon, no live panes. So a +//! local host with no cached *liveness* answer reads `Stopped`, which is what //! this page has always drawn — the async cache changes remote behaviour and //! leaves local pixels alone. //! +//! Not knowing which panes to ask about is a different thing, and it is +//! `Unknown` on every machine. The ids live in the machine's tree +//! ([`crate::ui::machine_mirror`]), so until that first pull lands there is no +//! question to put to the daemon — and "no ids yet" must not be read as "no +//! sessions", which is a claim about the user's work founded on our own +//! ignorance. Locally the pull lands within a frame or two of launch; where +//! there is no control link at all, a muted dot is exactly the truth. +//! //! # How it is filled //! //! [`sweep`] is called from the render paths that show liveness. It never @@ -58,7 +66,7 @@ use std::time::{Duration, Instant}; use gpui::{App, AppContext as _, BorrowAppContext as _}; -use crate::core::session::{Workspace, WorkspaceId, WorkspaceStore}; +use crate::core::session::{WindowView, WorkspaceId, WorkspaceStore}; use crate::terminal::{PaneRoute, RemoteTerminal}; use crate::ui::host_ops::{HostId, InFlight}; @@ -232,10 +240,17 @@ impl PaneLivenessCache { /// [`PaneLivenessCache::liveness`] for a whole workspace, read-only. /// /// The one call the render sites make. It cannot ask the wrong machine: the -/// host and the ids both come off the same [`Workspace`]. -pub fn liveness_of(cx: &App, workspace: &Workspace) -> Liveness { +/// host and the ids both come off the same [`WindowView`]. +pub fn liveness_of(cx: &App, workspace: &WindowView) -> Liveness { let host = workspace.host_id(); - let ids = workspace.pane_ids(); + // The ids live in the machine's tree; its mirror is where they are read. A + // machine whose tree has not been pulled leaves us with no question to ask, + // which is `Unknown` on any machine — reading it as `Stopped` would tell the + // user their sessions are gone on the strength of our own ignorance. See the + // module docs for why this is *not* the same as a failed local `List`. + let Some(ids) = crate::ui::machine_mirror::pane_ids(cx, workspace) else { + return Liveness::Unknown; + }; match cx.try_global::() { Some(cache) => cache.liveness(host, &ids), // Before the app has installed the global. Asked of an empty cache @@ -264,12 +279,12 @@ pub fn sweep(cx: &mut App) { // first so the borrow of the store is released before the probes, which // need `cx` mutably. let mut targets: Vec<(HostId, WorkspaceId)> = Vec::new(); - for w in &WorkspaceStore::all(cx).workspaces { + for w in &WorkspaceStore::all(cx).views { let host = w.host_id(); if targets.iter().any(|(seen, _)| *seen == host) { continue; } - if w.pane_ids().is_empty() { + if crate::ui::machine_mirror::pane_ids(cx, w).is_none_or(|ids| ids.is_empty()) { continue; } targets.push((host, w.id)); @@ -298,10 +313,10 @@ fn probe_host(cx: &mut App, host: HostId, workspace: WorkspaceId) { // // Recorded as a landed failure rather than returned from: a bare `return` // would leave `needs_probe` true, so the next frame would re-decide this, - // and `RemoteConnections::get` reaches its global mutably — which notifies, + // and `HostLinks::get` reaches its global mutably — which notifies, // which repaints, which sweeps. Storing the answer puts the decision behind // the same TTL as every other one. - if !host.is_local() && crate::ui::remote_connect::RemoteConnections::get(cx, host).is_none() { + if !host.is_local() && crate::ui::remote_connect::HostLinks::get(cx, host).is_none() { cx.update_global::(|cache, _| cache.finish_probe(host, None)); return; } diff --git a/src/terminal/search.rs b/src/terminal/search.rs index b1fb1166..15787727 100644 --- a/src/terminal/search.rs +++ b/src/terminal/search.rs @@ -861,7 +861,7 @@ fn truncate_at_unbalanced_close(token: &mut String) { /// Whether `c` may appear inside a URL per RFC 3986 (unreserved + reserved + `%`). /// Every such character is ASCII, so any CJK character, full-width bracket, arrow or /// emoji is rejected — which is what lets a URL be cut off from trailing CJK prose. -fn is_url_char(c: char) -> bool { +pub(super) fn is_url_char(c: char) -> bool { c.is_ascii_alphanumeric() || matches!( c, diff --git a/src/terminal/smart_select.rs b/src/terminal/smart_select.rs index e36ab0f5..410eb26f 100644 --- a/src/terminal/smart_select.rs +++ b/src/terminal/smart_select.rs @@ -91,7 +91,7 @@ pub(super) fn grid_smart_range( }); } - let (text, points, click_idx) = logical_line_at(term, click)?; + let (text, points, click_idx) = logical_line_at(term, click, false)?; let chars: Vec = text.chars().collect(); let separators = term.semantic_escape_chars(); // A span whose flanks are separator chars ends exactly where alacritty's @@ -305,7 +305,10 @@ mod tokenizer { /// The contiguous run of cells carrying the same OSC 8 hyperlink URI as the /// clicked cell, following soft wraps in both directions (a long link wraps /// across rows; stopping at the row edge would truncate the selection). -fn hyperlink_run(term: &Term, click: Point) -> Option<(Point, Point)> { +pub(super) fn hyperlink_run( + term: &Term, + click: Point, +) -> Option<(Point, Point)> { let grid = term.grid(); let cols = term.columns(); if click.column.0 >= cols { @@ -363,9 +366,17 @@ fn hyperlink_run(term: &Term, click: Point) -> Option<(Poin /// the text with wide-char spacers dropped, a per-char grid point, and the /// char index the click landed on. `None` when the click maps to no char /// (out-of-bounds column). -fn logical_line_at( +/// +/// When `bridge_hard_wrap` is set, rows are also joined across a *producer* +/// hard newline (no `WRAPLINE` flag) when the row is filled to the right edge +/// with a link char that continues into the first column of the next row. This +/// lets link resolution recover a URL a printing program split with a literal +/// `\n`, while double-click smart-select (which passes `false`) keeps its +/// word/semantic boundaries and never glues separate output lines together. +pub(super) fn logical_line_at( term: &Term, click: Point, + bridge_hard_wrap: bool, ) -> Option<(String, Vec, usize)> { let cols = term.columns(); if click.column.0 >= cols { @@ -373,19 +384,46 @@ fn logical_line_at( } let grid = term.grid(); let last_col = Column(cols - 1); + let top = term.topmost_line(); + let bottom = term.bottommost_line(); let wraps = |line: Line| grid[line][last_col].flags.contains(Flags::WRAPLINE); + // A hard bridge joins `line` to `line + 1` when the row is full to the + // right edge with a link char and the next row opens with one too, which + // rules out gluing an ordinary short line onto the following paragraph. + // + // It cannot rule out the converse: a hard newline carries no signal about + // whether the producer split a URL, so a *complete* URL that happens to end + // exactly at the right edge is bridged onto whatever the next row starts + // with (`…/a` + `README.md` resolves as `…/aREADME.md`). There is no + // reliable test for that — the head of a genuinely split URL is itself a + // valid URL — so we accept the false positive: the address bar shows the + // mistake and the user is one glance from spotting it. + // + // What we do not accept is the same accident promoting the *second* row to + // the authority. `https://good.com` + `@evil.com/x` parses as userinfo, so + // the real host becomes `evil.com` while the underline still reads + // `good.com` — a phishing hop wearing a trusted label. Never bridge into + // one. + let is_link_char = |c: char| super::search::is_url_char(c); + let hard = |line: Line| { + // `line < bottom` must stay ahead of the `line + 1` lookup — the last + // grid line has no successor to index. + bridge_hard_wrap && line < bottom && is_link_char(grid[line][last_col].c) && { + let next = grid[Line(line.0 + 1)][Column(0)].c; + is_link_char(next) && next != '@' + } + }; + let continues = |line: Line| wraps(line) || hard(line); let mut start_line = click.line; - let top = term.topmost_line(); let mut guard = 0; - while start_line > top && guard < MAX_WRAP_ROWS && wraps(start_line - 1) { + while start_line > top && guard < MAX_WRAP_ROWS && continues(start_line - 1) { start_line -= 1; guard += 1; } let mut end_line = click.line; - let bottom = term.bottommost_line(); guard = 0; - while end_line < bottom && guard < MAX_WRAP_ROWS && wraps(end_line) { + while end_line < bottom && guard < MAX_WRAP_ROWS && continues(end_line) { end_line += 1; guard += 1; } @@ -770,6 +808,90 @@ mod tests { ); } + #[test] + fn hard_wrapped_url_is_bridged_only_for_links() { + // A printing program emitted a literal `\n` mid-URL: the head fills + // row 0 exactly (20 chars, no WRAPLINE flag) and the tail lands on + // row 1. Soft-wrap stitching can't see across this gap; the hard + // bridge in link mode joins them, while smart-select stays put. + let term = term_with(20, 4, "https://example.com/\r\ndeep/path/seg rest"); + // The break carries no WRAPLINE flag — this is a producer hard newline, + // not a terminal soft wrap. + assert!( + !term.grid()[Line(0)][Column(19)] + .flags + .contains(Flags::WRAPLINE), + "fixture must be a hard newline, not a soft wrap" + ); + + // Link mode (bridge_hard_wrap = true) recovers the whole URL spanning + // both rows. + let click = Point::new(Line(0), Column(3)); + let (text, _points, _idx) = + logical_line_at(&term, click, true).expect("logical line under click"); + let idx = text.find("https").expect("url in bridged line"); + let (_s, _e, url) = + crate::terminal::search::url_span_at(&text, idx + 2).expect("url span in bridged line"); + assert_eq!(url, "https://example.com/deep/path/seg"); + + // Smart-select mode (bridge_hard_wrap = false) must NOT glue the two + // output lines together. + let (text, _points, _idx) = + logical_line_at(&term, click, false).expect("logical line under click"); + assert!( + !text.contains("deep"), + "double-click must not bridge a hard newline: {text:?}" + ); + } + + #[test] + fn a_hard_break_before_userinfo_is_never_bridged() { + // Row 0 ends with a bare host that fills the row exactly, row 1 opens + // with `@`. Bridging would resolve `https://good.com@evil.com/x`, whose + // authority per RFC 3986 is `evil.com` — the underline would read + // `good.com` while the click navigated elsewhere. The hard bridge must + // refuse this one even though the row shape otherwise invites it. + let term = term_with(20, 4, "go1 https://good.com\r\n@evil.com/x rest"); + assert!( + !term.grid()[Line(0)][Column(19)] + .flags + .contains(Flags::WRAPLINE), + "fixture must be a hard newline, not a soft wrap" + ); + + let click = Point::new(Line(0), Column(8)); + let (text, _points, idx) = + logical_line_at(&term, click, true).expect("logical line under click"); + assert!( + !text.contains("evil"), + "a hard break before `@` must not bridge: {text:?}" + ); + let (_s, _e, url) = + crate::terminal::search::url_span_at(&text, idx).expect("url span under click"); + assert_eq!(url, "https://good.com"); + } + + #[test] + fn a_soft_wrap_before_userinfo_still_stitches() { + // The `@` guard is about the *ambiguity* of a hard newline. A soft wrap + // is the terminal folding one logical line, so the continuation is + // certain and a userinfo URL must still resolve whole. + let term = term_with(20, 4, "see https://user1234@ex.com/z rest"); + assert!( + term.grid()[Line(0)][Column(19)] + .flags + .contains(Flags::WRAPLINE), + "fixture must be a soft wrap, not a hard newline" + ); + + let click = Point::new(Line(0), Column(10)); + let (text, _points, idx) = + logical_line_at(&term, click, true).expect("logical line under click"); + let (_s, _e, url) = + crate::terminal::search::url_span_at(&text, idx).expect("url span under click"); + assert_eq!(url, "https://user1234@ex.com/z"); + } + #[test] fn wide_glyph_and_its_spacer_resolve_to_the_same_word() { // Each Han char occupies two cells; the second carries WIDE_CHAR_SPACER diff --git a/src/terminal/view.rs b/src/terminal/view.rs index ea32fd23..f69d418d 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -98,8 +98,8 @@ impl gpui::EventEmitter for TerminalView {} /// /// The id arrives asynchronously, on the agent's own hooks, long after /// everything that *structurally* changes a window. Nothing else was making the -/// window save in between, so whether the id reached `session.json` came down -/// to whether the user happened to open a tab, split a pane or move focus +/// window save in between, so whether the id reached the persisted layout came +/// down to whether the user happened to open a tab, split a pane or move focus /// afterwards. That is what made resume-after-End-Sessions work sometimes and /// not others: the layout on file simply had no agent in it. pub struct AgentSessionChanged; @@ -544,14 +544,14 @@ pub struct TerminalView { } /// A link under the mouse, remembered so the grid can underline its cells. The -/// `line` is the alacritty grid line (display row minus the scroll offset), which -/// stays fixed as the viewport scrolls; `start..=end` are the inclusive columns -/// the link's text spans on that line. -#[derive(Clone, PartialEq)] +/// endpoints are alacritty grid points (line = display row minus the scroll +/// offset), which stay fixed as the viewport scrolls. A link the terminal +/// wrapped — or a producer split across rows with a hard newline — spans +/// several rows, so `start` and `end` can sit on different lines. +#[derive(Clone, Debug, PartialEq)] pub(super) struct HoveredLink { - pub line: i32, - pub start: usize, - pub end: usize, + pub start: Point, + pub end: Point, } enum LoopbackOpen { @@ -1599,7 +1599,7 @@ impl TerminalView { /// machine.** The host id comes off the workspace's own `RemoteTarget`, /// through the same `connection_key` the connection was opened under — so /// the id resolves to the very host object - /// [`RemoteConnections::insert`](crate::ui::remote_connect::RemoteConnections::insert) + /// [`HostLinks::insert`](crate::ui::remote_connect::HostLinks::insert) /// registered, with no second source of truth to drift from it. Setting the /// route and setting the host is one operation because a pane that ran its /// shell on one machine and its `git` on another would be worse than @@ -5410,56 +5410,23 @@ impl TerminalView { if !cx.global::().link_url { return false; } - let term = self.terminal.term.lock(); - let Some(line) = Self::grid_line(&term, row) else { + let include_loopback = self.can_forward_loopback(cx); + let Some((target, _start, _end)) = self.resolve_link_at(col, row, true, include_loopback) + else { return false; }; - let cols = term.columns(); - if col >= cols { - return false; - } - - // 1) Explicit OSC 8 hyperlink carried on the cell. - let cell = &term.grid()[line][Column(col)]; - if let Some(hl) = cell.hyperlink() { - let uri = hl.uri().to_string(); - drop(term); - self.open_url(&uri, window, cx); - return true; - } - - // 2) Fall back to detecting a bare URL or file path in the row's text. - let mut text = String::with_capacity(cols); - for c in 0..cols { - text.push(term.grid()[line][Column(c)].c); - } - drop(term); - // A relative path in the output is resolved against the cwd and - // stat-checked, then handed to the local file opener — so a remote - // pane's cwd must not be used. There, only absolute-looking local hits - // and URLs remain clickable. - let cwd = self.local_cwd(); - if let Some(link) = super::search::link_at(&text, col, cwd.as_deref(), true) { - match link.target { - LinkTarget::Url(url) => self.open_url(&url, window, cx), - LinkTarget::File { path, line, column } => { - // A configured template (e.g. opening the file in an editor) - // takes precedence; otherwise fall back to the OS opener. - match cx.global::().link_file_command.as_deref() { - Some(template) => run_file_command(template, &path, line, column), - None => open_file_path(&path), - } + match target { + LinkTarget::Url(url) => self.open_url(&url, window, cx), + LinkTarget::File { path, line, column } => { + // A configured template (e.g. opening the file in an editor) + // takes precedence; otherwise fall back to the OS opener. + match cx.global::().link_file_command.as_deref() { + Some(template) => run_file_command(template, &path, line, column), + None => open_file_path(&path), } } - true - } else if self.can_forward_loopback(cx) - && let Some((_, _, url)) = super::loopback::loopback_url_span_at(&text, col) - { - self.open_url(&url, window, cx); - true - } else { - false } + true } fn open_url(&self, url: &str, window: &mut Window, cx: &mut Context) { @@ -5608,63 +5575,60 @@ impl TerminalView { include_files: bool, include_loopback: bool, ) -> Option { + self.resolve_link_at(col, row, include_files, include_loopback) + .map(|(_, start, end)| HoveredLink { start, end }) + } + + /// The link under screen cell `(col, row)` and the inclusive grid points it + /// spans, shared by hover-underline and click-to-open so both agree on the + /// extent. Resolution runs over the *logical* line — soft-wrapped rows plus + /// producer hard newlines are stitched back together — so a URL split across + /// rows resolves whole instead of stopping at the first row edge. + fn resolve_link_at( + &self, + col: usize, + row: usize, + include_files: bool, + include_loopback: bool, + ) -> Option<(LinkTarget, Point, Point)> { let term = self.terminal.term.lock(); let line = Self::grid_line(&term, row)?; let cols = term.columns(); if col >= cols { return None; } + let click = Point::new(line, Column(col)); - // 1) Explicit OSC 8 hyperlink: highlight the whole contiguous run carrying - // the same URI, which may be wider than the visible link text. + // 1) Explicit OSC 8 hyperlink: highlight the whole contiguous run + // carrying the same URI, following soft wraps across rows. if let Some(hl) = term.grid()[line][Column(col)].hyperlink() { let uri = hl.uri().to_string(); - let same = |c: usize| { - term.grid()[line][Column(c)] - .hyperlink() - .is_some_and(|h| h.uri() == uri) - }; - let mut start = col; - while start > 0 && same(start - 1) { - start -= 1; + if let Some((start, end)) = super::smart_select::hyperlink_run(&term, click) { + return Some((LinkTarget::Url(uri), start, end)); } - let mut end = col; - while end + 1 < cols && same(end + 1) { - end += 1; - } - return Some(HoveredLink { - line: line.0, - start, - end, - }); } - // 2) Bare URL or file path detected in the row's text. - let mut text = String::with_capacity(cols); - for c in 0..cols { - text.push(term.grid()[line][Column(c)].c); - } + // 2) Bare URL or file path detected in the logical line. `bridge_hard_wrap` + // is on so a URL a program printed with a literal `\n` mid-way is + // recovered whole, not truncated at the break. + let (text, points, click_idx) = super::smart_select::logical_line_at(&term, click, true)?; drop(term); - // Same gate as the click path above — hover must not underline a link - // the click cannot open. + // Same gate as the click path — a relative path is resolved against the + // cwd and stat-checked, so a remote pane's cwd must not be used. let cwd = self.local_cwd(); - let link = - super::search::link_at(&text, col, cwd.as_deref(), include_files).or_else(|| { + let link = super::search::link_at(&text, click_idx, cwd.as_deref(), include_files) + .or_else(|| { include_loopback.then(|| { - super::loopback::loopback_url_span_at(&text, col).map(|(start, end, url)| { - super::search::LinkMatch { + super::loopback::loopback_url_span_at(&text, click_idx).map( + |(start, end, url)| super::search::LinkMatch { start, end, target: LinkTarget::Url(url), - } - }) + }, + ) })? })?; - Some(HoveredLink { - line: line.0, - start: link.start, - end: link.end, - }) + Some((link.target, points[link.start], points[link.end])) } /// The inline command line, anchored right where the shell prompt @@ -8053,7 +8017,7 @@ mod tests { /// (which needs a window, a daemon and a pane): the derivation under test is /// the target → `HostId` one, and pinning it here is what catches a future /// `set_workspace` that forgets the host half. The ids must agree with what - /// `RemoteConnections::insert` registered — same `connection_key`, checked + /// `HostLinks::insert` registered — same `connection_key`, checked /// by `connection_keys_match_the_contract_table` in `tty7-core`. #[test] fn a_panes_host_is_its_workspaces_machine() { @@ -8111,6 +8075,27 @@ pub(crate) fn quiet_test_pane( (view, daemon_side) } +/// [`quiet_test_pane`], marked as a native-SSH pane — the shape a remote +/// window's local SSH split has. `ssh_spec` is otherwise set only by the real +/// spawn path, which needs an actual SSH handshake. +#[cfg(all(test, unix))] +pub(crate) fn quiet_test_ssh_pane( + pane_id: u64, + window: &mut Window, + cx: &mut gpui::App, +) -> (gpui::Entity, std::os::unix::net::UnixStream) { + let (view, stream) = quiet_test_pane(pane_id, window, cx); + view.update(cx, |view, _| { + view.ssh_spec = Some(Box::new( + serde_json::from_str( + r#"{"host":"build-box","port":22,"user":"me","auth_mode":"auto"}"#, + ) + .expect("a minimal NativeSshSpec decodes"), + )); + }); + (view, stream) +} + /// gpui-harness tests: a real (headless) App + Window around a `TerminalView` /// wired to a socketpair, so `handle_event` and the event pump run exactly as /// in production. The test plays the daemon on the other end of the socket — @@ -8279,9 +8264,8 @@ mod gpui_tests { view.hover_link_at(0, 23, true, cx); assert_eq!(view.last_hover_cell, Some((0, 23))); view.hovered_link = Some(HoveredLink { - line: 23, - start: 0, - end: 3, + start: Point::new(Line(23), Column(0)), + end: Point::new(Line(23), Column(3)), }); // The same geometry again changes nothing... view.set_grid_size(80, 24, px(8.), px(17.)); @@ -9695,19 +9679,19 @@ mod gpui_tests { cx: &mut Context, ) -> crate::core::session::WorkspaceId { use crate::core::session::{ - RemoteRef, RemoteTarget, WorkspaceId, WorkspaceStore, Workspaces, + RemoteRef, RemoteTarget, WindowViews, WorkspaceId, WorkspaceStore, }; use crate::terminal::PaneWorkspace; let host = RemoteRef::new( RemoteTarget::direct("me", "build-box", 22), WorkspaceId::new(), ); - let entry = crate::core::session::Workspace::on_remote(host.clone()); + let entry = crate::core::session::WindowView::on_remote(host.clone()); let id = entry.id; WorkspaceStore::install_for_test( cx, - Workspaces { - workspaces: vec![entry], + WindowViews { + views: vec![entry], active: None, }, ); diff --git a/src/ui/app.rs b/src/ui/app.rs index ac111e15..3a56e5ec 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -449,6 +449,14 @@ pub struct Tab { /// clicking a file in the tree behind it brings the editor back — the same /// "click it, it comes forward" rule as window stacking. pub(crate) overlay_top: OverlayTop, + /// This tab's identity in the daemon's machine tree — the id every + /// semantic operation about it carries. Minted here (the daemon keeps a + /// client-minted id, see `ControlRequest::TabCreate`), so the tab can be + /// addressed before its create has round-tripped. A `Cell` because the + /// sync layer re-points it at an existing daemon tab when it recognizes + /// one by its panes (`tree_sync::adopt_tab_ids`), and that pass runs with + /// the same shared borrow every save runs under. + pub(crate) tree_id: std::cell::Cell, } /// Stacking order for the two overlays that cover the whole column. See @@ -470,6 +478,25 @@ impl Tab { code: None, overlay_top: OverlayTop::default(), sidebar_group: std::cell::RefCell::new(None), + tree_id: std::cell::Cell::new(tty7_core::core::machine::TabId::new()), + } + } + + /// A tab mirroring one the daemon's tree already holds — labels and + /// identity from the tree, the pane views from `pane` (built by the delta + /// application, which attaches or reuses them). + pub(crate) fn from_tree(tree: &tty7_core::core::machine::Tab, pane: Pane) -> Self { + Self { + pane, + name: tree.name.clone(), + last_focused: None, + diff_overlay: None, + code: None, + overlay_top: OverlayTop::default(), + sidebar_group: std::cell::RefCell::new( + tree.sidebar_group.clone().map(std::path::PathBuf::from), + ), + tree_id: std::cell::Cell::new(tree.id), } } @@ -708,7 +735,7 @@ pub struct Tty7App { pub(crate) worktree_prompt: Option, /// When `Some`, the active tab renders only this one leaf full-window /// (Cmd+Shift+Enter maximize). Cleared on any structural / navigation change. - maximized: Option>, + pub(crate) maximized: Option>, /// Whether the tab chips currently show their ⌘1…⌘9 switch badges /// (shown while bare ⌘/Ctrl is held; see `hints::on_modifiers_changed`). pub(crate) mod_hint_badges: bool, @@ -914,30 +941,59 @@ impl Tty7App { window: &mut Window, cx: &mut Context, ) -> Self { - // Claiming marks the workspace open and hands back its saved tabs, so - // the store (not this window) stays the single writer of session.json. + // Claiming marks the workspace open; the store stays the single + // writer of the view file. let restore = cx.global::().restore_session; let known = id.is_some_and(|id| WorkspaceStore::all(cx).get(id).is_some()); - let (workspace, saved) = WorkspaceStore::claim(cx, id); - // A workspace that was already on file restores its tab/split layout and - // each pane's cwd, unless the user turned restore off — then it starts - // fresh. A *brand-new* one has no tabs to restore, so what it comes up - // with is the caller's call: `None` here takes the first-run path in - // `with_session`, spawning a single default terminal, which is what - // `New Workspace` and a first run both want. Handing an empty session - // through instead lands on the home page, for the launch that exists to - // show the workspace picker. + let workspace = WorkspaceStore::claim(cx, id); + // A workspace's layout lives in its machine's tree, so a restore + // *asks* rather than reads: the window opens empty and + // `hydrate_window_from_tree` rebuilds it the moment the pull answers — + // against the local daemon that is milliseconds, so the empty state is + // effectively one frame; against a remote machine it is however long + // the link takes, which is the shape remote windows always had. A + // remote machine still unreachable when the hydration gives up is + // re-hydrated by the supervisor's reconnect. + let is_remote = WorkspaceStore::all(cx) + .get(workspace) + .is_some_and(|w| w.is_remote()); + // A remote workspace hydrates even with restore off: its panes are + // running sessions on another machine, not a saved layout. + let hydrate = known && (restore || is_remote); + // What the window opens holding is the caller's call for a *brand-new* + // workspace: `None` takes the first-run path in `with_session`, + // spawning a single default terminal — what `New Workspace` and a + // first run both want — while an empty session lands on the home page, + // for the launch that exists to show the workspace picker. A known + // workspace opens empty (the hydration fills it), or on a fresh shell + // when the user turned restore off. let session = match (known, fresh) { - (true, _) => restore.then_some(saved), + (true, _) if hydrate => Some(Session::default()), + (true, _) => None, (false, crate::ui::windows::FreshStart::Shell) => None, (false, crate::ui::windows::FreshStart::HomePage) => Some(Session::default()), }; 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 hydrate { + // No immediate save: the window is deliberately empty, and racing + // the pull with a diff that reads as "close everything" is exactly + // what the informed gate exists to prevent. + crate::ui::tree_sync::hydrate_window_from_tree(cx, workspace); + } else { + // A local window that skipped hydration shows what the user chose + // (a fresh shell, restore off): its state is the intended layout, + // and its sync may speak for the whole tree. A remote window that + // lands here has *not* seen its machine's tree yet, so it stays + // additive until a hydration informs it. + if !is_remote { + crate::ui::tree_sync::mark_window_informed(cx, workspace); + } + // Persist right away. The leaves just spawned (or reattached) now + // carry daemon pane ids, and nothing else writes them until the + // next *structural* change — so a crash before the user happens to + // open a tab would strand every one of those panes in the daemon. + app.save_session(cx); + } // If startup reused a daemon that speaks a different wire protocol // (an app upgrade while the old service kept running), the sessions // just restored above are living on that old dialect. Surface the @@ -1409,12 +1465,12 @@ impl Tty7App { app } - /// 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`. + /// Push this window's structure to its machine's tree (and its geometry to + /// the view file). Called after every structural change — the name + /// predates the tree migration, and it remains the single funnel. pub(crate) fn save_session(&self, cx: &mut App) { - // Tripwire for the write this record must never take: a pane created - // for one workspace being persisted under another. Each view remembers + // Tripwire for the write this sync must never make: a pane created + // for one workspace being recorded under another. Each view remembers // the workspace whose window created it; if that and the id this save // records under have come apart, the window's tabs and its identity // are describing two different workspaces — the exact corruption that @@ -1436,34 +1492,16 @@ impl Tty7App { ); } } - let tabs: Vec = self - .tabs - .iter() - .map(|tab| tab_to_session(tab, cx)) - .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. - 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( + // The layout goes nowhere near the view file: the machine that owns it + // hears about the change as the semantic operations it amounts to, + // local and remote alike. What this client persists is only the + // geometry, ridden on the same funnel so reopening lands where we are. + WorkspaceStore::record_geometry( cx, self.workspace, - session, - Some(WindowState::from_bounds(self.window_bounds)), + WindowState::from_bounds(self.window_bounds), ); - // …and for a remote workspace the machine that owns the layout has to - // hear about it, or `session.json` is the only place it exists and any - // other client (or a fresh install) opens the workspace empty. No-ops - // for a local workspace and for a machine we are not connected to — - // the latter is also what keeps a window that failed to restore from - // pushing its emptiness over a good record. - self.push_remote_layout(self.workspace, cx); + crate::ui::tree_sync::sync_window(self, cx); } /// This window is going away: capture its final state (a plain `cd` may @@ -1480,20 +1518,32 @@ impl Tty7App { // every `New Workspace` the user closes without using would leave one. // // Unless the emptiness is *this client's* ignorance rather than the - // machine's answer. `claimable_session` deliberately opens a remote - // workspace empty when its machine cannot be reached, so a window - // opened while the box was asleep and then closed — there was nothing - // in it to work on — would take the entry with it: its `RemoteRef`, its - // cached layout and its geometry, while its panes are still running + // machine's answer. Every window opens empty and waits for its tree + // pull, so a window opened while the box was asleep and then closed — + // there was nothing in it to work on — would take the entry with it: + // its `RemoteRef` and its geometry, while its panes are still running // over there. Nothing would reconnect it and nothing would offer it // again; the only way back is re-adding the machine by hand. let answered = WorkspaceStore::machine_is_connected(cx, self.workspace); - if self.tabs.is_empty() && answered { + if self.tabs.is_empty() + && answered + && crate::ui::tree_sync::window_is_informed(cx, self.workspace) + { + // Same as the picker swap: an empty workspace being dropped takes + // its (empty) tree on the machine with it. Only an informed window + // may say so — one still waiting on its hydration is empty because + // the pull has not answered, not because the workspace is. + crate::ui::tree_sync::fire_workspace_op(cx, self.workspace, |ws| { + tty7_core::daemon::control::ControlRequest::WorkspaceRemove { workspace: ws } + }); WorkspaceStore::remove(cx, self.workspace); } else { WorkspaceStore::close_window(cx, self.workspace); } crate::ui::windows::WindowRegistry::unregister(cx, self.workspace); + // The window's tree-sync bookkeeping goes with the window; the + // machine's tree itself keeps the workspace, which is the detach. + crate::ui::tree_sync::forget(cx, self.workspace); // The workspace just moved from "on screen" to "detached" — the Window // menu is the only place that says so. crate::ui::windows::refresh_menu(cx); @@ -1625,16 +1675,35 @@ impl Tty7App { if previous == id { return; } - if self.tabs.is_empty() { + // Only an *informed* empty window proves the workspace is blank: one + // still waiting on its hydration is empty because the pull has not + // answered, and dropping the workspace then would delete a populated + // tree on the strength of our own ignorance. + if self.tabs.is_empty() && crate::ui::tree_sync::window_is_informed(cx, previous) { + // Dropping the blank workspace here, so the machine's tree drops + // its (equally blank) copy — otherwise every visit to the picker + // would leave an empty workspace behind on the daemon. + crate::ui::tree_sync::fire_workspace_op(cx, previous, |ws| { + tty7_core::daemon::control::ControlRequest::WorkspaceRemove { workspace: ws } + }); WorkspaceStore::remove(cx, previous); + } else if self.tabs.is_empty() { + WorkspaceStore::close_window(cx, previous); } else { self.save_session(cx); WorkspaceStore::close_window(cx, previous); } + crate::ui::tree_sync::forget(cx, previous); - let (claimed, session) = WorkspaceStore::claim(cx, Some(id)); + let claimed = WorkspaceStore::claim(cx, Some(id)); crate::ui::windows::WindowRegistry::rebind(cx, previous, claimed); - self.adopt_workspace(claimed, session, window, cx); + // The machine's tree is the layout's only home now, so an explicit + // pick from the switcher always hydrates — restore-off governs what + // *launch* comes back to, not what a deliberate open shows. The window + // swaps to empty and the pull rebuilds it, for the local daemon within + // milliseconds. + self.adopt_workspace(claimed, Session::default(), window, cx); + crate::ui::tree_sync::hydrate_window_from_tree(cx, claimed); } /// Take over an *already claimed* workspace: rebuild this window's tabs @@ -1718,6 +1787,7 @@ impl Tty7App { // Keep the group it had when closed — the row reappears where // it lived instead of flashing through Scratch. sidebar_group: std::cell::RefCell::new(st.sidebar_group), + tree_id: std::cell::Cell::new(tty7_core::core::machine::TabId::new()), }, ); self.active = insert_at; @@ -1958,27 +2028,17 @@ impl Tty7App { // layout returns exactly as it was. let _ = this.update_in(cx, |this, window, cx| { match &restarted { + // Rebuild from the machine's tree, which survived the + // restart on disk: the fresh daemon force-cleared every + // pane's live flag, so the resync revives each leaf as a + // fresh shell in its recorded cwd (agents resumed) — + // exactly the semantics the old saved-session rebuild + // hand-rolled. The pull waits out the local link coming + // back up to the fresh daemon. Ok(()) => { - let font_size = this.font_size; - // 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 pane_ws = this.window_workspace(cx); - let (tabs, active) = tabs_from_session( - pane_ws.as_ref(), - this.workspace, - saved, - font_size, - window, - cx, - ); - this.tabs = tabs; - this.active = active; + crate::ui::tree_sync::resync_window_from_tree(cx, this.workspace); } - // The fresh daemon never came up; rebuilding would panic in - // `new_terminal`'s connect `.expect`. Stay on the home page and + // The fresh daemon never came up. Stay on the home page and // leave a breadcrumb rather than crash — the user can retry. Err(e) => { log::error!("restart background service failed, staying on home page: {e}"); @@ -3088,8 +3148,8 @@ impl Tty7App { 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()) + .filter(|w| crate::ui::machine_mirror::pane_count(cx, w).unwrap_or(0) > 0) + .and_then(|w| crate::ui::machine_mirror::display_name(cx, w)) .unwrap_or_else(|| "tty7".to_string()); if *self.window_title.borrow() == title { return; @@ -4383,10 +4443,8 @@ impl Tty7App { /// Turn the title-bar workspace chip into a text field, seeded with the /// current name. Committing on Enter or blur mirrors the tab rename. pub(crate) fn start_workspace_rename(&mut self, window: &mut Window, cx: &mut Context) { - let current = WorkspaceStore::all(cx) - .get(self.workspace) - .map(|w| w.display_name()) - .unwrap_or_default(); + let current = + crate::ui::machine_mirror::display_name_for(cx, self.workspace).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( @@ -4412,7 +4470,7 @@ impl Tty7App { }; 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::tree_sync::rename_workspace(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); @@ -5867,7 +5925,7 @@ impl Tty7App { if !host.is_connected() { return None; } - let home = crate::ui::remote_connect::RemoteConnections::home(cx, host_id)?; + let home = crate::ui::remote_connect::HostLinks::home(cx, host_id)?; Some((host, Some(home))) } @@ -7154,6 +7212,10 @@ fn tab_to_session(tab: &Tab, cx: &App) -> SessionTab { name: tab.name.clone(), pane: pane_to_session(&tab.pane, cx), sidebar_group: tab.sidebar_group.borrow().clone(), + // Deliberately not the live tab's tree id. This snapshot outlives the + // daemon tab it mirrors (the closed-tab stack, the session file), and + // rebuilding from it is a *new* tab everywhere it matters. + tree_id: None, } } @@ -7291,7 +7353,7 @@ pub(crate) fn alive_panes_on( /// gate on session restore. /// /// The failure this closes: two workspace records claiming one pane id (a -/// corrupted `session.json`), or a stale id landing on an unrelated pane after +/// corrupted layout store), or a stale id landing on an unrelated pane after /// the numbers were reused. Before the daemon knew owners, both cases attached /// — one workspace's window silently picked up another's shell, which is how /// `work`'s seven tabs once ended up duplicated into `personal`. A pane with no @@ -7357,6 +7419,13 @@ fn tabs_from_session( // renders grouped on the first frame; the first landed probe // corrects it if the tab's repo changed while we were gone. sidebar_group: std::cell::RefCell::new(st.sidebar_group.clone()), + // A session lowered from the machine's tree names its daemon tabs; + // keeping those ids is what stops the first save from closing and + // recreating every one of them. + tree_id: std::cell::Cell::new( + st.tree_id + .unwrap_or_else(tty7_core::core::machine::TabId::new), + ), }); } // Clamp the saved active index into the rebuilt range (which can be empty @@ -7531,7 +7600,7 @@ fn session_to_pane( /// stamped on the view (so `save_session` can shout if a window's tabs and its /// identity ever come apart). `None` only for callers that genuinely have no /// workspace (tests). -fn new_terminal( +pub(crate) fn new_terminal( workspace: Option, owner: Option, font_size: f32, @@ -8502,6 +8571,148 @@ pub(crate) mod test_window { } } +/// A native-SSH split inside a *remote* workspace's window runs in this +/// client's daemon and is deliberately absent from the remote machine's tree — +/// so a tree-driven tab rebuild has no leaf for it, and has to keep its view +/// anyway or a running local session is orphaned with nothing on screen. +#[cfg(all(test, unix))] +mod ssh_rebuild_gpui_tests { + use super::test_window::harness_with_pane; + use crate::core::session::{ + RemoteRef, RemoteTarget, WindowView, WindowViews, WorkspaceId, WorkspaceStore, + }; + use crate::ui::pane::{Pane, PaneSlot}; + use gpui::TestAppContext; + use tty7_core::core::machine::{LayoutDelta, PaneNode, Tab as TreeTab}; + + #[gpui::test] + fn a_tree_rebuild_keeps_the_native_ssh_split_a_remote_tab_holds(cx: &mut TestAppContext) { + // A window with one tab holding remote pane 1 (as far as the window is + // concerned; the socketpair plays the daemon). + let (app, mut vcx, _remote_pane_stream) = harness_with_pane(cx); + + // Bind the window to a remote workspace and split a native-SSH pane + // into the tab — the state a remote window with a local SSH split has. + let remote = WindowView::on_remote(RemoteRef::new( + RemoteTarget::Alias { + alias: "build-box".into(), + }, + WorkspaceId::new(), + )); + let remote_id = remote.id; + let _ssh_stream = app.update_in(&mut vcx, |app, window, cx| { + WorkspaceStore::install_for_test( + cx, + WindowViews { + views: vec![remote], + active: None, + }, + ); + app.workspace = remote_id; + let (ssh_view, stream) = crate::terminal::view::quiet_test_ssh_pane(2, window, cx); + let existing = std::mem::replace(&mut app.tabs[0].pane, Pane::Empty); + app.tabs[0].pane = Pane::split_node( + gpui::Axis::Horizontal, + 0.5, + existing, + Pane::leaf(PaneSlot::Ready(ssh_view)), + ); + stream + }); + + // Another client of the remote machine restructured the tab. The + // delta's tree names only the remote pane — the SSH leaf was never in + // that tree to be named. + let applied = app.update_in(&mut vcx, |app, window, cx| { + let tab = TreeTab { + id: app.tabs[0].tree_id.get(), + name: None, + sidebar_group: None, + root: PaneNode::Leaf { pane: 1 }, + }; + app.apply_layout_delta( + &LayoutDelta::TabRestructured { tab, pane: None }, + window, + cx, + ) + }); + assert!( + applied, + "the delta must apply without falling back to a resync" + ); + + app.update_in(&mut vcx, |app, _, cx| { + let leaves = app.tabs[0].pane.leaves(); + assert_eq!(leaves.len(), 2, "the ssh split must survive the rebuild"); + assert!( + leaves.iter().any(|slot| match slot { + PaneSlot::Ready(view) => view.read(cx).ssh_spec().is_some(), + _ => false, + }), + "one leaf is still the native-SSH pane" + ); + assert!( + leaves.iter().any(|slot| match slot { + PaneSlot::Ready(view) => { + let view = view.read(cx); + view.ssh_spec().is_none() && view.pane_id == 1 + } + _ => false, + }), + "the remote pane's existing view is reused, not re-attached" + ); + }); + } + + /// A remote window's tab that is native-SSH through and through is + /// unrepresentable in the machine's tree **forever** — so it must be + /// invisible to the diff, not *held*. Held means "spawns are landing, + /// wait"; a tab that can never land would make every diff return before + /// the ordering and active-tab passes, freezing tab order and activation + /// sync for the whole window for as long as the tab exists. + #[gpui::test] + fn a_pure_native_ssh_tab_is_invisible_to_the_tree_not_held(cx: &mut TestAppContext) { + let (app, mut vcx, _remote_pane_stream) = harness_with_pane(cx); + + let remote = WindowView::on_remote(RemoteRef::new( + RemoteTarget::Alias { + alias: "build-box".into(), + }, + WorkspaceId::new(), + )); + let remote_id = remote.id; + let _ssh_stream = app.update_in(&mut vcx, |app, window, cx| { + WorkspaceStore::install_for_test( + cx, + WindowViews { + views: vec![remote], + active: None, + }, + ); + app.workspace = remote_id; + // A second tab holding only a native-SSH pane. + let (ssh_view, stream) = crate::terminal::view::quiet_test_ssh_pane(2, window, cx); + app.tabs + .push(super::Tab::new(Pane::leaf(PaneSlot::Ready(ssh_view)))); + stream + }); + + let (desired, _active, held) = app.update_in(&mut vcx, |app, _, cx| { + crate::ui::tree_sync::desired_tabs(app, cx) + }); + assert_eq!( + desired.len(), + 1, + "only the remote-backed tab can be named in the machine's tree" + ); + assert!( + held.is_empty(), + "the pure-SSH tab is permanently invisible, not held — holding it \ + would freeze ordering and active-tab sync for the whole window" + ); + } +} + #[cfg(test)] mod keybinding_gpui_tests { use super::test_window::harness; @@ -8623,7 +8834,7 @@ mod keybinding_gpui_tests { mod shell_menu_gpui_tests { use crate::core::config::Config; use crate::core::session::{ - RemoteRef, RemoteTarget, Session, Workspace, WorkspaceId, WorkspaceStore, Workspaces, + RemoteRef, RemoteTarget, Session, WindowView, WindowViews, WorkspaceId, WorkspaceStore, }; use crate::ui::app::Tty7App; use gpui::{AppContext, Entity, TestAppContext, VisualTestContext}; @@ -8714,7 +8925,7 @@ mod shell_menu_gpui_tests { ); // A workspace on a machine nothing in this process has connected to. - let remote = Workspace::on_remote(RemoteRef::new( + let remote = WindowView::on_remote(RemoteRef::new( RemoteTarget::Alias { alias: "build-box".into(), }, @@ -8724,8 +8935,8 @@ mod shell_menu_gpui_tests { app.update_in(&mut vcx, |app, window, cx| { WorkspaceStore::install_for_test( cx, - Workspaces { - workspaces: vec![remote], + WindowViews { + views: vec![remote], active: None, }, ); diff --git a/src/ui/hints.rs b/src/ui/hints.rs index 35426302..ec7b1326 100644 --- a/src/ui/hints.rs +++ b/src/ui/hints.rs @@ -150,7 +150,7 @@ 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`. + // on-disk view store. let window = cx.add_window(|window, cx| { Tty7App::with_session(None, Some(Session::default()), window, cx) }); diff --git a/src/ui/home.rs b/src/ui/home.rs index 94583b55..6c961e7f 100644 --- a/src/ui/home.rs +++ b/src/ui/home.rs @@ -318,6 +318,7 @@ mod tests { fn closed_tab_label_prefers_the_user_set_name() { let tab = SessionTab { name: Some("build".into()), + tree_id: None, sidebar_group: None, pane: leaf(Some("/work/getty")), }; @@ -328,6 +329,7 @@ mod tests { fn closed_tab_label_falls_back_to_the_first_leaf_cwd_dir_name() { let tab = SessionTab { name: None, + tree_id: None, sidebar_group: None, pane: leaf(Some("/work/getty")), }; @@ -336,6 +338,7 @@ mod tests { // Whitespace-only names don't count as names. let tab = SessionTab { name: Some(" ".into()), + tree_id: None, sidebar_group: None, pane: leaf(Some("/work/getty")), }; @@ -346,6 +349,7 @@ mod tests { fn closed_tab_label_searches_splits_for_the_first_cwd() { let tab = SessionTab { name: None, + tree_id: None, sidebar_group: None, pane: SessionPane::Split { axis: crate::core::session::SessionAxis::Horizontal, @@ -362,12 +366,14 @@ mod tests { // No name, no cwd — and "/" has no file name either. let unnamed = SessionTab { name: None, + tree_id: None, sidebar_group: None, pane: leaf(None), }; assert_eq!(closed_tab_label(&unnamed), None); let root = SessionTab { name: None, + tree_id: None, sidebar_group: None, pane: leaf(Some("/")), }; @@ -378,6 +384,7 @@ mod tests { fn closed_tab_label_clamps_runaway_names() { let tab = SessionTab { name: Some("a".repeat(40)), + tree_id: None, sidebar_group: None, pane: leaf(None), }; diff --git a/src/ui/local_link.rs b/src/ui/local_link.rs new file mode 100644 index 00000000..c63b173e --- /dev/null +++ b/src/ui/local_link.rs @@ -0,0 +1,216 @@ +//! The GUI's control link to **this machine's own daemon**. +//! +//! Local and remote machines are the same thing seen from different distances: +//! one machine, one daemon, one workspace tree, one control link. The remote +//! machines' links live in [`crate::ui::remote_connect::HostLinks`]; this +//! module is the local machine's — the link over which the GUI receives the +//! local daemon's pushes (`ControlEvent::Layout` deltas, `Preempted`) and +//! sends its semantic tree operations. +//! +//! # Not a `HostLinks` entry +//! +//! `HostLinks` doubles as the [`crate::ui::host_registry::HostRegistry`] +//! feeder: inserting there would register a *wire-backed* `Host` for this +//! machine, while the local file tree and git must keep going through the +//! in-process [`LocalHost`](tty7_core::host::local::LocalHost) — a socket +//! round trip per `stat` on the machine you are sitting at would be absurd. It +//! also keeps the `HostId::LOCAL`-never-holds-a-control-connection invariant +//! untouched: this link lives in its own global, not in any host table. +//! +//! # Not routed +//! +//! A remote control connection dials the local daemon's *pane* socket and asks +//! it to route (the GUI never speaks SSH). This machine needs no routing — the +//! daemon's control endpoint is right here, so the link is a plain connect plus +//! a `ControlHello`. +//! +//! # Its own pump +//! +//! The remote supervisor's pump deliberately stops when the last remote +//! workspace closes; this link must outlive that — a purely local session is +//! the *common* case — so [`LocalLink::install`] runs its own forever loop at +//! the same cadence. Each turn supervises the connection (reconnecting on the +//! same 1/2/4/…/30 s backoff a remote machine gets — the daemon may be +//! restarting or upgrading, and the GUI auto-spawns it, so "down" is always +//! transient) and drains the shared event queue, so local pushes are delivered +//! even when the remote pump is parked. Events land in that queue under +//! [`HostId::LOCAL`](tty7_core::host::HostId::LOCAL): the pump drains one +//! queue and machines differ only by id, which is the same-shape-everywhere +//! the whole design is after. +//! +//! # Both platforms +//! +//! The dial is the one part that differs, and only in its first line: a Unix +//! socket where there are Unix sockets, and the same token-checked loopback +//! endpoint the pane dialect uses on Windows (see +//! [`tty7_core::daemon::transport`]). Everything above `connect_blocking` — +//! supervision, backoff, the event queue, the tree sync that rides this link — +//! is one code path, because a machine's tree is what a window's layout *is* +//! and a platform without it is a platform where tabs do not come back. + +use std::sync::Arc; + +use gpui::{App, Global}; +use tty7_core::daemon::control::ControlClient; + +use crate::ui::remote_workspace::Backoff; + +/// The link, and the schedule for getting it back. +#[derive(Default)] +pub struct LocalLink { + client: Option>, + backoff: Backoff, + /// When the next attempt is due. `None` while the link is up or an + /// attempt is in flight. + next_attempt: Option, + attempting: bool, + /// Whether the forever loop is already running, so `install` is idempotent. + pumping: bool, +} + +impl Global for LocalLink {} + +impl LocalLink { + /// Start supervising the local link. Called once at startup; safe to call + /// again (the loop is a singleton). + pub fn install(cx: &mut App) { + // Local pushes need the same somewhere-to-go the remote ones have, + // and this loop may be the only one draining it. + crate::ui::remote_workspace::install_event_observer(); + let link = cx.default_global::(); + if link.pumping { + return; + } + link.pumping = true; + cx.spawn(async move |cx| { + loop { + cx.update(|cx| { + Self::tick(cx); + crate::ui::remote_workspace::drain_events(cx); + }); + cx.background_executor() + .timer(crate::ui::remote_workspace::PUMP_TICK) + .await; + } + }) + .detach(); + } + + /// The live control client for this machine's daemon, if there is one. + /// + /// `None` is always transient — the supervisor is already reconnecting — + /// so callers treat it exactly like an unreachable remote: skip the + /// operation, or queue nothing and rely on the full pull that follows a + /// reconnect. + pub fn client(cx: &mut App) -> Option> { + let link = cx.default_global::(); + link.client.as_ref().filter(|c| c.is_connected()).cloned() + } + + /// One supervision step: notice a dead link, drop it, and schedule or + /// launch the next attempt on the backoff. + fn tick(cx: &mut App) { + let now = std::time::Instant::now(); + let link = cx.default_global::(); + if link.attempting { + return; + } + if let Some(client) = &link.client { + if client.is_connected() { + return; + } + log::info!("lost the control link to the local daemon; reconnecting"); + link.client = None; + } + match link.next_attempt { + // Never attempted at all: due now. The daemon is normally already + // up (main spawns it before the first window), so the first tick + // should connect, not start a schedule. + None if link.backoff.attempt() == 0 => {} + None => { + link.next_attempt = Some(now + link.backoff.delay()); + return; + } + Some(at) if at > now => return, + Some(_) => {} + } + link.next_attempt = None; + link.attempting = true; + let _ = link.backoff.advance(); + + cx.spawn(async move |cx| { + let connected = cx + .background_executor() + .spawn(async move { connect_blocking() }) + .await; + cx.update(|cx| { + let link = cx.default_global::(); + link.attempting = false; + match connected { + Ok(client) => { + log::info!("control link to the local daemon is up"); + link.client = Some(client); + link.backoff.reset(); + link.next_attempt = None; + // Every fresh link starts with a full pull — deltas + // only advance a mirror that has a base to advance. + crate::ui::machine_mirror::MachineMirrors::refresh( + cx, + tty7_core::host::HostId::LOCAL, + ); + // …and re-runs every local window's sync: a window + // built while this link was still dialing is parked + // `Unprimed { dirty }` with nothing else scheduled to + // wake it (see `tree_sync::on_link_up`). + crate::ui::tree_sync::on_link_up(cx, tty7_core::host::HostId::LOCAL); + } + Err(e) => { + // The next tick schedules the following attempt off + // the already-advanced backoff. + log::debug!("local control link attempt failed: {e}"); + } + } + }); + }) + .detach(); + } +} + +/// Dial the local daemon's control endpoint and shake hands. **Blocking**; runs +/// on the background executor. +/// +/// `ensure_running` first, because the daemon is the GUI's own child in the +/// common case: on a cold start this races the daemon binding its listener, +/// and the backoff absorbs the one or two attempts that lose the race. +fn connect_blocking() -> std::io::Result> { + use tty7_core::daemon::control::ControlHello; + + crate::daemon::spawn::ensure_running().map_err(std::io::Error::other)?; + let hello = ControlHello::host_rpc( + uuid::Uuid::new_v4().to_string(), + // Its own label rather than this machine's hostname: if this session + // is ever preempted, "this computer" is the useful thing to show — + // the hostname would name the machine the user is already at. + "this computer", + ); + let sink: tty7_core::daemon::control::EventSink = Box::new(local_event_sink); + // The one platform difference: which kind of stream carries the dialect. + #[cfg(unix)] + let client = ControlClient::over_unix( + std::os::unix::net::UnixStream::connect(tty7_core::host::server::control_socket_path()?)?, + &hello, + sink, + )?; + // Loopback TCP with the daemon's token as a preamble — the access boundary + // Windows has instead of socket permissions; `connect_control` presents it. + #[cfg(windows)] + let client = + ControlClient::over_tcp(tty7_core::host::server::connect_control()?, &hello, sink)?; + Ok(Arc::new(client)) +} + +/// Local daemon pushes land in the same process-wide observer as every remote +/// machine's, attributed to [`HostId::LOCAL`](tty7_core::host::HostId::LOCAL). +fn local_event_sink(event: tty7_core::daemon::control::ControlEvent) { + tty7_core::daemon::control::observe_event(tty7_core::host::HostId::LOCAL, event); +} diff --git a/src/ui/machine_mirror.rs b/src/ui/machine_mirror.rs new file mode 100644 index 00000000..8ea8911f --- /dev/null +++ b/src/ui/machine_mirror.rs @@ -0,0 +1,643 @@ +//! A per-machine mirror of each daemon's workspace tree, for the surfaces that +//! read *about* workspaces without showing them. +//! +//! The machine's tree is the layout authority, so anything the client used to +//! answer from its own saved layout — a picker row's name, the "3 panes" +//! count, which pane ids a workspace claims — has to come from the tree now. +//! The windows that *show* a workspace already hold a per-window mirror +//! ([`crate::ui::tree_sync`]); this global is the read model for everything +//! else: the switcher, the Window menu, the title bar, the liveness sweep. +//! +//! # How it stays current +//! +//! One [`Machine`] per [`HostId`], filled by a `MachineGet` when a machine's +//! control link comes up and advanced from there by the same +//! [`LayoutDelta`] stream the windows consume — plus +//! [`note_synced_workspace`], because origin exclusion means this client never +//! hears its **own** operations back, and the per-window mirror they advanced +//! is the only other record of what they did. +//! +//! A delta that will not apply (a machine the pull has not answered for yet, a +//! tab it never heard of) marks nothing broken: the mirror re-pulls the whole +//! machine, exactly like a drifted window does. +//! +//! # It may be behind, and that is allowed +//! +//! Against the local daemon the first pull lands within milliseconds of +//! launch, so the picker's loading gap is about one frame. A machine that is +//! unreachable keeps its last pulled state for the rest of the process — stale +//! names beat no names — and a machine never reached this session simply has +//! no entry, which readers render as the not-knowing they are in. + +use std::collections::HashMap; + +use gpui::{App, Global}; +use tty7_core::core::machine::{LayoutDelta, Machine, PaneRecord, Tab, TabId, Workspace}; +use tty7_core::daemon::control::{ControlRequest, ReplyOk}; +use tty7_core::host::HostId; + +use crate::core::session::WorkspaceId; + +/// Every machine's last known tree, by the machine. +#[derive(Default)] +pub struct MachineMirrors { + machines: HashMap, + /// Hosts with a `MachineGet` in flight, so a burst of triggers costs one + /// round trip. + pulling: Vec, +} + +impl Global for MachineMirrors {} + +impl MachineMirrors { + /// The last pulled tree for `host`, or `None` when no pull has answered + /// yet this session. Read-only; renders may call it every frame. + pub fn machine(cx: &App, host: HostId) -> Option<&Machine> { + cx.try_global::()?.machines.get(&host) + } + + /// Whether `host`'s tree has been pulled at all — the "loading" / + /// "known but empty" distinction a picker wants to draw. + pub fn ready(cx: &App, host: HostId) -> bool { + Self::machine(cx, host).is_some() + } + + /// Pull `host`'s whole tree in the background and install it. Cheap to + /// call whenever a link comes up or a delta refuses to apply; concurrent + /// triggers coalesce into one round trip. + pub fn refresh(cx: &mut App, host: HostId) { + // A peer that does not advertise `machine-tree` (a server with no + // home directory for one) has no tree to pull; asking anyway costs a + // round trip per trigger to hear the same refusal. Reads keep their + // "never pulled" answer, which renders as not knowing. + let client = match crate::ui::tree_sync::tree_control_for(cx, host) { + crate::ui::tree_sync::TreeLink::Ready(client) => client, + crate::ui::tree_sync::TreeLink::Unserved => { + log::debug!("not pulling {host:?}: its server does not serve the machine tree"); + return; + } + crate::ui::tree_sync::TreeLink::Down => return, + }; + let mirrors = cx.default_global::(); + if mirrors.pulling.contains(&host) { + return; + } + mirrors.pulling.push(host); + cx.spawn(async move |cx| { + let pulled = cx + .background_executor() + .spawn(async move { + match client.call(ControlRequest::MachineGet) { + Ok(ReplyOk::MachineTree(machine)) => Some(machine), + Ok(other) => { + log::warn!("MachineGet answered {other:?}"); + None + } + Err(e) => { + log::debug!("could not pull the machine tree: {e}"); + None + } + } + }) + .await; + cx.update(|cx| { + let mirrors = cx.default_global::(); + mirrors.pulling.retain(|h| *h != host); + if let Some(machine) = pulled { + mirrors.machines.insert(host, *machine); + cx.refresh_windows(); + } + }); + }) + .detach(); + } + + /// Install a freshly pulled tree — for the paths that already hold one + /// (a window's hydration pulls `MachineGet` anyway). + /// + /// Repaints, like [`refresh`](Self::refresh)'s landing does: every workspace + /// name, pane count and liveness dot on screen reads this global, and a + /// pull that lands without a repaint leaves the chrome a frame (or, on a + /// quiet screen, indefinitely) behind the tree it is describing. + pub fn install(cx: &mut App, host: HostId, machine: Machine) { + cx.default_global::().machines.insert(host, machine); + cx.refresh_windows(); + } + + /// Advance `host`'s mirror by one delta about the workspace `key` names. + /// + /// A delta that names state the mirror does not hold re-pulls the machine + /// whole; a delta arriving before the first pull is dropped, because that + /// pull's answer already includes it. + pub fn apply_delta(cx: &mut App, host: HostId, key: &str, delta: &LayoutDelta) { + let Ok(id) = key.parse::() else { + return; + }; + let applied = match cx.default_global::().machines.get_mut(&host) { + Some(machine) => apply(machine, id, delta), + None => true, + }; + if !applied { + log::debug!("machine mirror for {host:?} fell behind; re-pulling"); + Self::refresh(cx, host); + } + } + + /// Record the post-state of this client's own operations on `machine_ws` — + /// the half of the history origin exclusion keeps out of the delta stream. + /// A workspace the mirror has not seen is created; `None` tabs leave the + /// structure alone (a label-only op). + pub fn note_synced_workspace( + cx: &mut App, + host: HostId, + machine_ws: WorkspaceId, + tabs: Vec, + active: Option, + ) { + let Some(machine) = cx.default_global::().machines.get_mut(&host) else { + return; + }; + let ws = match machine.workspaces.iter_mut().find(|w| w.id == machine_ws) { + Some(ws) => ws, + None => { + machine.workspaces.push(Workspace { + id: machine_ws, + ..Workspace::default() + }); + machine.workspaces.last_mut().expect("just pushed") + } + }; + ws.tabs = tabs; + ws.active_tab = active; + } + + /// Fold in a workspace-level operation this client just fired + /// ([`crate::ui::tree_sync::fire_workspace_op`]) — same reason as + /// [`note_synced_workspace`]: the writer never hears its own echo. + pub fn note_workspace_op(cx: &mut App, host: HostId, request: &ControlRequest) { + let Some(machine) = cx.default_global::().machines.get_mut(&host) else { + return; + }; + match request { + ControlRequest::WorkspaceRename { workspace, name } => { + if let Some(ws) = machine.workspaces.iter_mut().find(|w| w.id == *workspace) { + ws.name = name.clone(); + } + } + ControlRequest::WorkspaceTouch { workspace } => { + if let Some(ws) = machine.workspaces.iter_mut().find(|w| w.id == *workspace) { + ws.last_active = crate::ui::home::now_secs(); + } + } + ControlRequest::WorkspaceRemove { workspace } => { + machine.workspaces.retain(|w| w.id != *workspace); + } + _ => {} + } + } +} + +/// Advance one machine's copy by one delta. `false` means the delta names +/// state the mirror does not hold and the caller should re-pull. +fn apply(machine: &mut Machine, workspace: WorkspaceId, delta: &LayoutDelta) -> bool { + // The two deltas that do not require the workspace to exist yet. + match delta { + LayoutDelta::WorkspaceCreated { workspace: ws } => { + machine.workspaces.retain(|w| w.id != ws.id); + machine.workspaces.push(ws.clone()); + return true; + } + LayoutDelta::WorkspaceDeleted => { + machine.workspaces.retain(|w| w.id != workspace); + return true; + } + // Facts about a pane are registry-wide; the workspace key only says + // who referenced it. Upserted rather than matched, because the record + // may have been born from another client's op this mirror never saw. + LayoutDelta::PaneFacts { pane } => { + match machine.panes.iter_mut().find(|p| p.id == pane.id) { + Some(record) => *record = pane.clone(), + None => machine.panes.push(pane.clone()), + } + return true; + } + _ => {} + } + let Some(ws) = machine.workspaces.iter_mut().find(|w| w.id == workspace) else { + return false; + }; + match delta { + LayoutDelta::WorkspaceCreated { .. } + | LayoutDelta::WorkspaceDeleted + | LayoutDelta::PaneFacts { .. } => unreachable!("handled above"), + LayoutDelta::WorkspaceRenamed { name } => { + ws.name = name.clone(); + true + } + LayoutDelta::WorkspaceTouched { last_active } => { + ws.last_active = *last_active; + true + } + LayoutDelta::ActiveTabChanged { tab } => { + ws.active_tab = Some(*tab); + true + } + LayoutDelta::TabCreated { at, tab } => { + // Deltas and full pulls have no ordering barrier: a create that + // straddles a pull arrives *after* the snapshot that already + // carries its tab. Replace-by-id (the `WorkspaceCreated` retain + // above is the precedent) rather than insert twice. + ws.tabs.retain(|t| t.id != tab.id); + let at = (*at).min(ws.tabs.len()); + ws.tabs.insert(at, tab.clone()); + true + } + LayoutDelta::TabClosed { tab } => { + let before = ws.tabs.len(); + ws.tabs.retain(|t| t.id != *tab); + if ws.tabs.is_empty() { + ws.active_tab = None; + } + ws.tabs.len() != before + } + LayoutDelta::TabRenamed { tab, name } => { + let Some(t) = ws.tabs.iter_mut().find(|t| t.id == *tab) else { + return false; + }; + t.name = name.clone(); + true + } + LayoutDelta::TabRegrouped { tab, group } => { + let Some(t) = ws.tabs.iter_mut().find(|t| t.id == *tab) else { + return false; + }; + t.sidebar_group = group.clone(); + true + } + LayoutDelta::TabMoved { tab, to } => { + let Some(from) = ws.tabs.iter().position(|t| t.id == *tab) else { + return false; + }; + let moved = ws.tabs.remove(from); + ws.tabs.insert((*to).min(ws.tabs.len()), moved); + true + } + LayoutDelta::TabRestructured { tab, pane } => { + let Some(t) = ws.tabs.iter_mut().find(|t| t.id == tab.id) else { + return false; + }; + *t = tab.clone(); + if let Some(pane) = pane { + match machine.panes.iter_mut().find(|p| p.id == pane.id) { + Some(record) => *record = pane.clone(), + None => machine.panes.push(pane.clone()), + } + } + true + } + LayoutDelta::RatioChanged { tab, path, ratio } => { + let Some(t) = ws.tabs.iter_mut().find(|t| t.id == *tab) else { + return false; + }; + match t.root.descend_mut(path) { + Some(tty7_core::core::machine::PaneNode::Split { ratio: r, .. }) => { + *r = *ratio; + true + } + _ => false, + } + } + } +} + +// --------------------------------------------------------------------------- +// Reading a client entry's display facts off its machine's mirror +// --------------------------------------------------------------------------- + +/// The tree workspace a client entry points at, with the pane registry it +/// reads records from. `None` while the machine has not been pulled (or no +/// longer lists the workspace). +fn view_of<'a>( + cx: &'a App, + entry: &crate::core::session::WindowView, +) -> Option<(&'a Workspace, &'a [PaneRecord])> { + let machine = MachineMirrors::machine(cx, entry.host_id())?; + let machine_ws = entry.host.as_ref().map(|r| r.workspace).unwrap_or(entry.id); + let ws = machine.workspaces.iter().find(|w| w.id == machine_ws)?; + Some((ws, &machine.panes)) +} + +/// What the picker and the window title call `entry`: the user-set name, else +/// derived from the tree's repo groups and cwds. +/// +/// Falls back to +/// [`WindowView::label`](crate::core::session::WindowView::label) — what the +/// machine last said, before it +/// stopped answering. The tree wins whenever it answers; the hint is for the +/// rows the picker exists to offer, on machines that are asleep. `None` only +/// when this client has never seen the workspace named at all, which is a +/// brand-new entry and nothing a user is choosing between. +pub fn display_name(cx: &App, entry: &crate::core::session::WindowView) -> Option { + match view_of(cx, entry) { + Some((ws, panes)) => Some(display_name_of(ws, panes)), + None => entry.label.clone(), + } +} + +/// A tree workspace's label: the user-set name, else the repository most of +/// its tabs live in, else the first pane's directory, else `"Untitled"`. +pub fn display_name_of(ws: &Workspace, panes: &[PaneRecord]) -> String { + if let Some(name) = ws.name.as_deref().map(str::trim).filter(|n| !n.is_empty()) { + return name.to_string(); + } + subject_path_of(ws, panes) + .and_then(|path| { + std::path::Path::new(&path) + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + }) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "Untitled".to_string()) +} + +/// The path a workspace is *about*: the repo group most tabs belong to (ties +/// toward the earliest tab), else the first pane's cwd. What the picker's dim +/// subtitle shows, and what [`display_name_of`] takes the basename of. +pub fn subject_path_of(ws: &Workspace, panes: &[PaneRecord]) -> Option { + let mut counts: Vec<(&str, usize)> = Vec::new(); + for group in ws.tabs.iter().filter_map(|t| t.sidebar_group.as_deref()) { + match counts.iter_mut().find(|(g, _)| *g == group) { + Some((_, n)) => *n += 1, + None => counts.push((group, 1)), + } + } + let dominant = counts.into_iter().max_by_key(|(_, n)| *n).map(|(g, _)| g); + let first_cwd = ws + .tabs + .iter() + .flat_map(|t| t.root.pane_ids()) + .find_map(|id| { + panes + .iter() + .find(|p| p.id == id) + .and_then(|p| p.cwd.as_deref()) + }); + dominant.or(first_cwd).map(str::to_string) +} + +/// [`display_name`] looked up by the client's workspace id, with the shared +/// not-knowing fallback — for the sites that hold an id rather than an entry. +pub fn display_name_for(cx: &App, client_ws: WorkspaceId) -> Option { + let entry = crate::core::session::WorkspaceStore::all(cx).get(client_ws)?; + display_name(cx, entry) +} + +/// [`subject_path_of`] for a client entry, falling back to the stamped hint for +/// the same reason [`display_name`] does. +pub fn subject_path(cx: &App, entry: &crate::core::session::WindowView) -> Option { + match view_of(cx, entry) { + Some((ws, panes)) => subject_path_of(ws, panes).or_else(|| entry.subject.clone()), + None => entry.subject.clone(), + } +} + +/// The pair a client entry should carry on file, read off its machine's mirror — +/// for [`WorkspaceStore::record_geometry`](crate::core::session::WorkspaceStore::record_geometry) +/// to stamp. `None` for a machine that has not answered: a hint is only ever +/// replaced by something better, never blanked by not knowing. +pub fn display_hint( + cx: &App, + entry: &crate::core::session::WindowView, +) -> Option<(String, Option)> { + let (ws, panes) = view_of(cx, entry)?; + Some((display_name_of(ws, panes), subject_path_of(ws, panes))) +} + +/// Every pane id `entry`'s tree claims on its machine. `None` when the +/// machine's tree has not been pulled — which a caller about to state a fact +/// ("3 running sessions will be ended") must render as not knowing, not as +/// zero. +pub fn pane_ids(cx: &App, entry: &crate::core::session::WindowView) -> Option> { + let (ws, _) = match view_of(cx, entry) { + Some(view) => view, + // A pulled machine that no longer lists the workspace *is* an answer: + // it claims nothing. + None if MachineMirrors::ready(cx, entry.host_id()) => return Some(Vec::new()), + None => return None, + }; + Some(ws.tabs.iter().flat_map(|t| t.root.pane_ids()).collect()) +} + +/// How many terminals `entry` holds across every tab, per its machine's tree. +pub fn pane_count(cx: &App, entry: &crate::core::session::WindowView) -> Option { + pane_ids(cx, entry).map(|ids| ids.len()) +} + +#[cfg(test)] +mod tests { + use tty7_core::core::machine::{Axis, PaneNode, Tab, TabId}; + + use super::*; + + fn machine_with(ws: Workspace) -> Machine { + Machine { + workspaces: vec![ws], + panes: Vec::new(), + } + } + + fn leaf_tab(pane: u64) -> Tab { + Tab::leaf(pane) + } + + /// A machine that is not answering still has to produce a row a user can + /// choose: the picker's whole job is offering workspaces on machines that + /// are asleep, and "Untitled" with a blank subtitle is not an offer. So the + /// stamped hint stands in until a pull lands, and the tree wins the moment + /// one does. + #[gpui::test] + fn an_unpulled_machine_falls_back_to_the_stamped_label(cx: &mut gpui::TestAppContext) { + use crate::core::session::{WindowView, WindowViews, WorkspaceStore}; + + cx.update(|cx| { + let mut view = WindowView::default(); + view.label = Some("api".into()); + view.subject = Some("/repo/api".into()); + let id = view.id; + let entry = view.clone(); + WorkspaceStore::install_for_test( + cx, + WindowViews { + views: vec![view], + active: None, + }, + ); + + // Nothing pulled: the hint is what the row says. + assert_eq!(display_name(cx, &entry).as_deref(), Some("api")); + assert_eq!(subject_path(cx, &entry).as_deref(), Some("/repo/api")); + assert!( + display_hint(cx, &entry).is_none(), + "and a machine that has not answered contributes no new hint" + ); + + // The tree answers, and outranks it. + let mut tree = Workspace { + id, + name: Some("web".into()), + ..Workspace::default() + }; + tree.tabs = vec![leaf_tab(1)]; + MachineMirrors::install(cx, HostId::LOCAL, machine_with(tree)); + assert_eq!(display_name(cx, &entry).as_deref(), Some("web")); + assert_eq!( + display_hint(cx, &entry).map(|(label, _)| label).as_deref(), + Some("web"), + "which is what the next save stamps" + ); + }); + } + + #[test] + fn a_workspace_created_delta_lands_whole_and_a_deleted_one_removes_it() { + let mut machine = Machine::default(); + let ws = Workspace::default(); + let id = ws.id; + assert!(apply( + &mut machine, + id, + &LayoutDelta::WorkspaceCreated { workspace: ws }, + )); + assert_eq!(machine.workspaces.len(), 1); + assert!(apply(&mut machine, id, &LayoutDelta::WorkspaceDeleted)); + assert!(machine.workspaces.is_empty()); + } + + #[test] + fn structural_deltas_advance_the_mirrored_tree() { + let ws = Workspace::default(); + let id = ws.id; + let mut machine = machine_with(ws); + let tab = leaf_tab(1); + let tab_id = tab.id; + assert!(apply( + &mut machine, + id, + &LayoutDelta::TabCreated { at: 0, tab }, + )); + let restructured = Tab { + id: tab_id, + name: None, + sidebar_group: None, + root: PaneNode::Split { + axis: Axis::Vertical, + ratio: 0.5, + a: Box::new(PaneNode::Leaf { pane: 1 }), + b: Box::new(PaneNode::Leaf { pane: 2 }), + }, + }; + assert!(apply( + &mut machine, + id, + &LayoutDelta::TabRestructured { + tab: restructured, + pane: Some(PaneRecord::new(2)), + }, + )); + let ws = &machine.workspaces[0]; + assert_eq!(ws.tabs[0].root.pane_ids(), vec![1, 2]); + assert_eq!( + machine.panes.len(), + 1, + "the rider pane record is upserted into the registry" + ); + } + + /// Deltas and full pulls have no ordering barrier: a `TabCreated` that + /// straddles a `MachineGet` arrives after a snapshot that already carries + /// its tab. Applying it must replace by id, not insert a second copy. + #[test] + fn a_tab_created_delta_that_straddled_a_pull_lands_once() { + let ws = Workspace::default(); + let id = ws.id; + let mut machine = machine_with(ws); + let delta = LayoutDelta::TabCreated { + at: 0, + tab: leaf_tab(1), + }; + assert!(apply(&mut machine, id, &delta)); + assert!(apply(&mut machine, id, &delta)); + assert_eq!( + machine.workspaces[0].tabs.len(), + 1, + "the second application is the pull/delta overlap, not a second tab" + ); + } + + #[test] + fn a_delta_about_a_tab_the_mirror_never_saw_asks_for_a_repull() { + let ws = Workspace::default(); + let id = ws.id; + let mut machine = machine_with(ws); + assert!( + !apply( + &mut machine, + id, + &LayoutDelta::TabRenamed { + tab: TabId::new(), + name: Some("x".into()), + }, + ), + "an unappliable delta must say so, so the caller re-pulls" + ); + // …and so does one about a workspace the machine does not list. + assert!(!apply( + &mut machine, + WorkspaceId::new(), + &LayoutDelta::WorkspaceRenamed { name: None }, + )); + } + + #[test] + fn pane_facts_upsert_the_registry_even_for_a_pane_born_elsewhere() { + let mut machine = Machine::default(); + let mut record = PaneRecord::new(7); + record.cwd = Some("/work".into()); + assert!(apply( + &mut machine, + WorkspaceId::new(), + &LayoutDelta::PaneFacts { + pane: record.clone(), + }, + )); + record.live = true; + assert!(apply( + &mut machine, + WorkspaceId::new(), + &LayoutDelta::PaneFacts { pane: record }, + )); + assert_eq!(machine.panes.len(), 1, "updated in place, not duplicated"); + assert!(machine.panes[0].live); + } + + /// The precedence `Workspace::display_name` always had, read off the tree: + /// user name, then the dominant repo group, then the first pane's cwd. + #[test] + fn display_names_derive_from_the_tree_with_the_session_precedence() { + let mut ws = Workspace::default(); + let panes = vec![PaneRecord { + cwd: Some("/home/me/scratch".into()), + ..PaneRecord::new(1) + }]; + ws.tabs = vec![leaf_tab(1)]; + assert_eq!(display_name_of(&ws, &panes), "scratch"); + + ws.tabs[0].sidebar_group = Some("/repo/tty7".into()); + assert_eq!(display_name_of(&ws, &panes), "tty7"); + + ws.name = Some(" Release prep ".into()); + assert_eq!(display_name_of(&ws, &panes), "Release prep"); + + assert_eq!(display_name_of(&Workspace::default(), &[]), "Untitled"); + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index b0a2c9a5..49ff7c2d 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -22,6 +22,8 @@ pub mod host_ops; #[allow(dead_code)] pub mod host_registry; pub mod keymap; +pub mod local_link; +pub mod machine_mirror; pub mod palette; pub mod pane; pub mod pending_pane; @@ -42,5 +44,6 @@ pub mod tab_sidebar; pub mod tab_strip; pub mod theme; pub mod tray; +pub mod tree_sync; pub mod windows; pub mod worktree_prompt; diff --git a/src/ui/pane.rs b/src/ui/pane.rs index 0d13cdd2..7d07a0a0 100644 --- a/src/ui/pane.rs +++ b/src/ui/pane.rs @@ -716,12 +716,21 @@ impl Pane { window.refresh(); } }); - // End the drag on release. + // End the drag on release — and persist the ratio + // it landed on. The drag itself only moves the + // shared cell; without this save the new ratio + // reached disk (and now the machine's tree) only as + // a passenger on some later structural change. window.on_mouse_event({ let dragging = dragging.clone(); - move |_ev: &MouseUpEvent, _phase, window, _cx| { + move |_ev: &MouseUpEvent, _phase, window, cx| { if dragging.get() { dragging.set(false); + if let Some(app) = + crate::ui::windows::WindowRegistry::app_in(cx, window) + { + app.update(cx, |app, cx| app.save_session(cx)); + } window.refresh(); } } diff --git a/src/ui/remote_connect.rs b/src/ui/remote_connect.rs index e1208b40..44a3621e 100644 --- a/src/ui/remote_connect.rs +++ b/src/ui/remote_connect.rs @@ -15,7 +15,7 @@ //! | 2 | Resolve one into a self-contained SSH spec | [`spec_for`] | //! | 3 | Open a routed control connection through the local daemon | [`connect_blocking`] | //! | 4 | Read the machine's own workspace list | [`rows_from_list`] | -//! | 5 | Hold the connection for the workspaces bound to it | [`RemoteConnections`] | +//! | 5 | Hold the connection for the workspaces bound to it | [`HostLinks`] | //! //! ## Machines are configured once //! @@ -42,7 +42,6 @@ use std::sync::{Arc, Mutex, OnceLock}; use std::time::Duration; use gpui::{App, Global}; -use serde_json::Value; use crate::core::config::Config; use crate::core::session::{RemoteTarget, WorkspaceId}; @@ -401,11 +400,11 @@ fn handshake( /// Ask a connected machine for its workspaces. pub fn list_workspaces(host: &Arc) -> io::Result> { - match host.client().call(ControlRequest::WorkspaceList)? { - ReplyOk::Json(Value::Array(list)) => Ok(rows_from_list(&list)), + match host.client().call(ControlRequest::MachineGet)? { + ReplyOk::MachineTree(machine) => Ok(rows_from_machine(&machine)), other => Err(io::Error::new( io::ErrorKind::InvalidData, - format!("the server answered a workspace list with {other:?}"), + format!("the server answered a machine tree with {other:?}"), )), } } @@ -444,38 +443,24 @@ fn client_hostname() -> String { #[derive(Clone, Debug, PartialEq, Eq)] pub struct RemoteWorkspaceRow { pub id: WorkspaceId, - /// Already resolved through `Workspace::display_name`'s rules, so a record - /// with no user-set name still reads as its repo or directory. + /// The user-set name when there is one, else derived from the tabs' repo + /// groups and cwds — the same precedence `Workspace::display_name` gives a + /// local workspace, computed here from the machine's tree. pub name: String, pub panes: usize, pub last_active: u64, - /// The raw record, kept so opening the row can `apply_remote_json` it - /// without a second round trip. - pub record: Value, } -/// Turn a `WorkspaceList` payload into picker rows, newest first. -/// -/// Records the client cannot decode are **skipped, not fatal**: the list is -/// written by whichever tty7 last touched that machine, and one record from a -/// newer build must not make every other workspace on the box unreachable. -pub fn rows_from_list(list: &[Value]) -> Vec { - let mut rows: Vec = list +/// Turn a machine's tree into picker rows, newest first. +pub fn rows_from_machine(machine: &tty7_core::core::machine::Machine) -> Vec { + let mut rows: Vec = machine + .workspaces .iter() - .filter_map(|record| { - // The remote record is the remote-owned half of a `Workspace`, so it - // decodes by merging onto a blank one — which is also what gives us - // `display_name` and `pane_count` for free rather than reimplemented. - let mut workspace = crate::core::session::Workspace::default(); - workspace.apply_remote_json(record).ok()?; - let id: WorkspaceId = serde_json::from_value(record.get("id")?.clone()).ok()?; - Some(RemoteWorkspaceRow { - id, - name: workspace.display_name(), - panes: workspace.pane_count(), - last_active: workspace.last_active, - record: record.clone(), - }) + .map(|ws| RemoteWorkspaceRow { + id: ws.id, + name: crate::ui::machine_mirror::display_name_of(ws, &machine.panes), + panes: ws.tabs.iter().map(|t| t.root.pane_ids().len()).sum(), + last_active: ws.last_active, }) .collect(); rows.sort_by_key(|row| std::cmp::Reverse(row.last_active)); @@ -486,16 +471,21 @@ pub fn rows_from_list(list: &[Value]) -> Vec { // 5. Holding the connections // --------------------------------------------------------------------------- -/// The live remote machines, by [`HostId`]. +/// The live control links, by [`HostId`] — one per machine, one machine per +/// entry. /// -/// One entry per *machine*, not per workspace — the same granularity the SSH -/// connection is pooled at and the same one [`crate::ui::host_registry`] uses, -/// so two windows on one box share a connection, a host object and a git-status -/// cache. This table holds the concrete [`RemoteHost`] because pushing a layout -/// needs its control client; `HostRegistry` holds the same object erased to -/// `dyn Host` for the panels. +/// The name says the model: every machine this client talks to is reached +/// over exactly one control link, and the local machine is a machine like any +/// other — its link simply lives in its own global +/// ([`LocalLink`](crate::ui::local_link::LocalLink)) because it is in-process +/// rather than wire-backed. One entry per *machine*, not per workspace — the +/// same granularity the SSH connection is pooled at and the same one +/// [`crate::ui::host_registry`] uses, so two windows on one box share a +/// connection, a host object and a git-status cache. This table holds the +/// concrete [`RemoteHost`] because pushing a layout needs its control client; +/// `HostRegistry` holds the same object erased to `dyn Host` for the panels. #[derive(Default)] -pub struct RemoteConnections { +pub struct HostLinks { hosts: HashMap>, /// Each machine's `$HOME`, as its handshake reported it. /// @@ -511,24 +501,18 @@ pub struct RemoteConnections { homes: HashMap, } -impl Global for RemoteConnections {} +impl Global for HostLinks {} -impl RemoteConnections { +impl HostLinks { /// The connection to `id`, if this process has one. pub fn get(cx: &mut App, id: HostId) -> Option> { - cx.default_global::() - .hosts - .get(&id) - .cloned() + cx.default_global::().hosts.get(&id).cloned() } /// Where a *new* workspace on `id` would start: that machine's own `$HOME`, /// never this client's. pub fn home(cx: &mut App, id: HostId) -> Option { - cx.default_global::() - .homes - .get(&id) - .cloned() + cx.default_global::().homes.get(&id).cloned() } /// Record a connection, and register the same object with the host registry @@ -540,14 +524,14 @@ impl RemoteConnections { pub fn insert(cx: &mut App, host: Arc, home: PathBuf) { let id = host.id(); crate::ui::host_registry::HostRegistry::insert(cx, Arc::clone(&host).into_shared()); - let table = cx.default_global::(); + let table = cx.default_global::(); table.hosts.insert(id, host); table.homes.insert(id, home); } /// Drop a machine's connection once nothing is using it. pub fn remove(cx: &mut App, id: HostId) { - let table = cx.default_global::(); + let table = cx.default_global::(); table.hosts.remove(&id); table.homes.remove(&id); crate::ui::host_registry::HostRegistry::remove(cx, id); @@ -555,50 +539,10 @@ impl RemoteConnections { /// Machines currently connected. Diagnostics and teardown. pub fn len(cx: &mut App) -> usize { - cx.default_global::().hosts.len() + cx.default_global::().hosts.len() } } -/// Push a workspace's layout to the machine that owns it (the -/// remote's `workspaces.json` is the authority). Blocking. -pub fn put_remote_layout(host: &Arc, key: String, record: Value) -> io::Result<()> { - host.client() - .call(ControlRequest::WorkspacePut { - id: key, - json: record, - }) - .map(|_| ()) -} - -/// Pull one workspace's authoritative record from the machine that owns it. -/// Blocking. -/// -/// The read side of the split, and what a -/// [`ControlEvent::WorkspaceChanged`](crate::daemon::control::ControlEvent) -/// asks for: the event says only *that* a record moved, so the record itself is -/// fetched rather than carried. `ErrorKind::NotFound` is a real answer — the -/// workspace was deleted on the far side — and is deliberately distinguishable -/// from an empty one. -pub fn get_remote_layout(host: &Arc, key: String) -> io::Result { - match host - .client() - .call(ControlRequest::WorkspaceGet { id: key })? - { - ReplyOk::Json(record) => Ok(record), - other => Err(io::Error::new( - io::ErrorKind::InvalidData, - format!("the server answered a workspace record with {other:?}"), - )), - } -} - -/// Forget a workspace on the machine that owns it. Blocking. -pub fn delete_remote_workspace(host: &Arc, key: String) -> io::Result<()> { - host.client() - .call(ControlRequest::WorkspaceDelete { id: key }) - .map(|_| ()) -} - // --------------------------------------------------------------------------- // 6. Install consent // --------------------------------------------------------------------------- @@ -776,7 +720,7 @@ pub fn register(cx: &mut App) { crate::daemon::router::set_route_auth_responder(Arc::new(GuiRouteAuth)); // Touch the globals so the first connect isn't also the first allocation of // the table it writes into, on a thread that is holding a socket open. - let _ = RemoteConnections::len(cx); + let _ = HostLinks::len(cx); } /// The oldest install waiting for an answer, if any. @@ -1271,54 +1215,47 @@ mod tests { assert_eq!(endpoint_label("", "box.local", 22), "box.local"); } - /// The picker's rows come from records the *remote* wrote, so they have to - /// survive a record this build cannot read: one bad entry may not hide the - /// rest of the machine's workspaces. + /// The picker's rows come from the machine's tree: newest first, with a + /// name derived the way a local workspace's would be when none is set. #[test] - fn rows_skip_undecodable_records_and_sort_newest_first() { + fn rows_from_the_tree_sort_newest_first_and_derive_names() { + use tty7_core::core::machine::{Machine, PaneRecord, Tab, Workspace}; let older = WorkspaceId::new(); let newer = WorkspaceId::new(); - let list = vec![ - serde_json::json!({ - "id": older.to_string(), - "name": "api", - "session": { "tabs": [] }, - "last_active": 100, - }), - // No `id` at all — unreadable, and skipped rather than fatal. - serde_json::json!({ "name": "broken" }), - serde_json::json!({ - "id": newer.to_string(), - "name": "web", - "session": { "tabs": [] }, - "last_active": 500, - }), - ]; - let rows = rows_from_list(&list); - assert_eq!(rows.len(), 2, "the undecodable record is skipped"); + let machine = Machine { + workspaces: vec![ + Workspace { + id: older, + name: Some("api".into()), + last_active: 100, + tabs: vec![Tab::leaf(1)], + ..Default::default() + }, + Workspace { + id: newer, + name: None, + last_active: 500, + tabs: vec![Tab::leaf(2)], + ..Default::default() + }, + ], + panes: vec![ + PaneRecord::new(1), + PaneRecord { + cwd: Some("/srv/checkout".into()), + ..PaneRecord::new(2) + }, + ], + }; + let rows = rows_from_machine(&machine); + assert_eq!(rows.len(), 2); assert_eq!(rows[0].id, newer, "newest first"); - assert_eq!(rows[0].name, "web"); - assert_eq!(rows[1].id, older); - } - - /// A record with no user-set name falls back to the same derived name a - /// local workspace would get, rather than showing a raw uuid. - #[test] - fn rows_derive_a_name_when_the_record_has_none() { - let id = WorkspaceId::new(); - let list = vec![serde_json::json!({ - "id": id.to_string(), - "session": { - "tabs": [{ - "pane": { "Leaf": { "cwd": "/srv/checkout" } } - }] - }, - "last_active": 1, - })]; - let rows = rows_from_list(&list); - assert_eq!(rows.len(), 1); - assert_eq!(rows[0].name, "checkout"); + assert_eq!( + rows[0].name, "checkout", + "no user name falls back to the first pane's directory" + ); assert_eq!(rows[0].panes, 1); + assert_eq!(rows[1].name, "api", "a user-set name wins"); } fn host(label: &str, detail: &str) -> HostChoice { diff --git a/src/ui/remote_workspace.rs b/src/ui/remote_workspace.rs index 9d8e8c38..5f4751cc 100644 --- a/src/ui/remote_workspace.rs +++ b/src/ui/remote_workspace.rs @@ -1,7 +1,7 @@ //! The window's half of "Connect to Host". //! //! [`ui::remote_connect`](crate::ui::remote_connect) is the plumbing — SSH -//! specs, routed control connections, the remote workspace store. This is the +//! specs, routed control connections, the remote machine's tree. This is the //! part that lives on a window: the state the home page renders, the steps that //! move between those states, and the guards that keep a window on one machine. //! @@ -19,7 +19,7 @@ //! |---|---| //! | New tab / split | [`Tty7App::spawn_host`] — a remote window refuses to spawn a local shell | //! | Reopening a closed tab | [`Tty7App::rebind_host`] clears the closed stack when a window changes machine | -//! | Restart / session restore | `WorkspaceStore::record`'s storage split — a remote entry never holds a local layout on disk | +//! | Restart / session restore | the machine's own tree is the only layout source — the client persists no layout at all | //! //! The fourth path, dragging a tab between windows, does not exist in tty7: //! tabs never leave the window they were opened in, so there is nothing to @@ -661,10 +661,10 @@ impl Tty7App { rows: rows.clone(), }, ); - remote_connect::RemoteConnections::insert(cx, connected.host, home.clone()); + remote_connect::HostLinks::insert(cx, connected.host, home.clone()); self.prompt_remote_daemon_mismatch_later(cx); // Nothing left to *show* about the attempt: the machine is now - // in `RemoteConnections` and its group in the switcher fills + // in `HostLinks` and its group in the switcher fills // itself from there and from the snapshot above. self.connect = None; } @@ -688,7 +688,8 @@ impl Tty7App { ) { let host = RemoteRef::new(target, row.id); let id = WorkspaceStore::claim_remote(cx, host); - WorkspaceStore::apply_remote(cx, id, &row.record); + // No record to apply: the machine's tree is pulled when the window + // hydrates, which the enter below sets in motion. self.enter_remote_workspace(id, window, cx); } @@ -711,7 +712,8 @@ impl Tty7App { // it from the tabs' repo/cwd, which is the same rule a local workspace // follows and the one intended. A workspace that opened in // `~` and then had a repo opened in it renames itself for free. - self.push_remote_layout(id, cx); + // The machine learns about the workspace when the window's hydration + // finds nothing under this id and creates it (`WorkspaceCreate`). log::info!( "new remote workspace on {target} rooted at {}", home.display() @@ -746,44 +748,6 @@ impl Tty7App { cx.notify(); } - /// Push this window's workspace record to the machine that owns it. - /// - /// The other half of the storage split: the client keeps `open`, the window - /// geometry and the pointer; everything that is a fact about the machine - /// goes over there. Fire-and-forget on a background task — a failed push is - /// a log line, not a modal, because the record is rewritten on every - /// structural change anyway. - pub(crate) fn push_remote_layout(&self, id: WorkspaceId, cx: &mut gpui::App) { - let Some((host, key, record)) = WorkspaceStore::remote_payload(cx, id) else { - return; - }; - let Some(connection) = remote_connect::RemoteConnections::get(cx, host.host_id()) else { - // Not connected: the layout is pushed again when it is, and the - // remote's own copy is still the last good one. - return; - }; - // Marked before the task starts and cleared when it lands, so a - // `WorkspaceChanged` that arrives in between does not pull the record - // this push is replacing back over the top of it. - cx.default_global::().pushing.insert(id); - cx.spawn(async move |cx| { - cx.background_executor() - .spawn(async move { - if let Err(e) = remote_connect::put_remote_layout(&connection, key, record) { - log::warn!( - "could not push the workspace layout to {}: {e}", - host.target - ); - } - }) - .await; - cx.update(|cx| { - cx.default_global::().pushing.remove(&id); - }); - }) - .detach(); - } - /// `open: true` remote workspaces reconnect at launch. /// /// **M6 owns the behaviour**; this owns the seam. Startup opens a window per @@ -798,7 +762,7 @@ impl Tty7App { return; }; remote_connect::register(cx); - if remote_connect::RemoteConnections::get(cx, host.host_id()).is_some() { + if remote_connect::HostLinks::get(cx, host.host_id()).is_some() { // Another window on the same machine got there first. One connection // per machine is the point — D7's "connect immediately" is about the // *machine*, and a second link to it would be a second SSH session @@ -1060,15 +1024,6 @@ pub(crate) fn pane_route_for(cx: &gpui::App, workspace: WorkspaceId) -> crate::t crate::terminal::PaneRoute::for_workspace(pane_workspace_for(cx, workspace).as_ref()) } -/// The connection for a remote workspace, if this process has one. -pub(crate) fn connection_for( - cx: &mut gpui::App, - workspace: WorkspaceId, -) -> Option> { - let host = WorkspaceStore::remote_ref(cx, workspace)?; - remote_connect::RemoteConnections::get(cx, host.host_id()) -} - // --------------------------------------------------------------------------- // The supervisor (the connection state machine, running) // --------------------------------------------------------------------------- @@ -1078,12 +1033,12 @@ pub(crate) fn connection_for( /// Fast enough that a `Preempted` push turns a window read-only while the user /// is still looking at the machine they typed on, slow enough to be free: a tick /// is a hash-map walk over the handful of machines a person has open. -const PUMP_TICK: Duration = Duration::from_millis(250); +pub(crate) const PUMP_TICK: Duration = Duration::from_millis(250); /// One machine's link, as the supervisor sees it. /// /// Per **machine**, not per workspace, because that is the granularity a -/// connection actually has (`RemoteConnections` is keyed by [`HostId`], and two +/// connection actually has (`HostLinks` is keyed by [`HostId`], and two /// windows on one box share a link). Preemption is the one thing that is /// per-workspace, and it is kept separately for exactly that reason. struct MachineLink { @@ -1116,6 +1071,13 @@ pub(crate) struct RemoteLinks { /// Workspaces taken over, and by whom. Per **workspace**: one machine can /// hold three of them and lose exactly one. preempted: std::collections::HashMap, + /// Workspaces being taken *back*: [`RemoteLinks::retry_now`] cleared their + /// preemption and the reconnect is in flight. Remembered because the + /// window still shows the pre-takeover layout, and [`finish_attempt`] + /// must rebuild it from the tree whole (`Adopt::Replace`) — the IfEmpty + /// hydration it runs for an ordinary reconnect skips any non-empty + /// window, which is precisely what a preempted window is. + reclaiming: std::collections::HashSet, /// Machines the user has deliberately disconnected from. /// /// Without this the supervisor would reconnect on the next tick: it keeps a @@ -1135,12 +1097,6 @@ pub(crate) struct RemoteLinks { /// absent from this map has never been seen before, which is **not** the /// same as having restarted — see [`finish_attempt`]. instances: std::collections::HashMap, - /// Workspaces this client is pushing a layout for right now. - /// - /// Read by [`refresh_remote_workspace`], which skips them: a record we are - /// in the middle of replacing is not one to pull back over the top of - /// ourselves. - pushing: std::collections::HashSet, /// The start-up sheet queue. Lives here because it is part of the /// same connection state and has to survive individual windows — the sheet /// belongs to a machine, not to whichever window happened to ask first. @@ -1162,6 +1118,18 @@ impl gpui::Global for RemoteLinks {} /// `remote_connect`'s install mailbox uses, for the identical reason. static EVENTS: Mutex> = Mutex::new(Vec::new()); +/// Point the process-wide control-event observer at [`EVENTS`]. Idempotent +/// (installing the same closure again is harmless), and shared with the local +/// link's pump ([`crate::ui::local_link::LocalLink::install`]) — whichever +/// comes up first, reader threads must never find nobody listening. +pub(crate) fn install_event_observer() { + crate::daemon::control::set_event_observer(Arc::new(|host, event| { + if let Ok(mut queue) = EVENTS.lock() { + queue.push((host, event)); + } + })); +} + impl RemoteLinks { /// Start the supervisor, and make sure control events have somewhere to go. /// @@ -1169,11 +1137,7 @@ impl RemoteLinks { /// (the connect flow, opening one, start-up), because any of them can be the /// first. pub(crate) fn ensure_running(cx: &mut gpui::App) { - crate::daemon::control::set_event_observer(Arc::new(|host, event| { - if let Ok(mut queue) = EVENTS.lock() { - queue.push((host, event)); - } - })); + install_event_observer(); if cx.default_global::().pumping { return; } @@ -1233,7 +1197,12 @@ impl RemoteLinks { return; }; let links = cx.default_global::(); - links.preempted.remove(&workspace); + if links.preempted.remove(&workspace).is_some() { + // Taking back, not merely reconnecting: the window's layout is + // the pre-takeover one, so the attach that lands must rebuild it + // from the tree rather than trust what it shows. + links.reclaiming.insert(workspace); + } // Asking to reconnect outranks having asked to disconnect. links.suspended.remove(&host.host_id()); let link = links.machines.entry(host.host_id()).or_insert(MachineLink { @@ -1273,11 +1242,11 @@ impl RemoteLinks { // would keep reading from a socket that is about to be dropped under it. for (workspace, _) in workspaces_on(cx, host) { release_panes(cx, workspace); - cx.default_global::() - .preempted - .remove(&workspace); + let links = cx.default_global::(); + links.preempted.remove(&workspace); + links.reclaiming.remove(&workspace); } - remote_connect::RemoteConnections::remove(cx, host); + remote_connect::HostLinks::remove(cx, host); cx.default_global::().machines.remove(&host); log::info!("disconnected from a machine at the user's request"); cx.refresh_windows(); @@ -1317,6 +1286,7 @@ fn pump_tick(cx: &mut gpui::App) -> bool { let forgotten = links.machines.len(); links.machines.clear(); links.preempted.clear(); + links.reclaiming.clear(); links.suspended.clear(); // Logged because the *state* it leaves behind is indistinguishable from // never having connected: `status_of` reads a missing link as @@ -1338,8 +1308,8 @@ fn pump_tick(cx: &mut gpui::App) -> bool { if suspended.contains(&host) { continue; } - let live = remote_connect::RemoteConnections::get(cx, host) - .is_some_and(|h| h.client().is_connected()); + let live = + remote_connect::HostLinks::get(cx, host).is_some_and(|h| h.client().is_connected()); let attempting = cx .try_global::() .and_then(|l| l.machines.get(&host)) @@ -1356,6 +1326,9 @@ fn pump_tick(cx: &mut gpui::App) -> bool { if became { changed = true; log::info!("link to {target} is attached"); + // A fresh link means whatever the mirror held is history; the + // full pull re-bases it before deltas resume advancing it. + crate::ui::machine_mirror::MachineMirrors::refresh(cx, host); } continue; } @@ -1366,8 +1339,8 @@ fn pump_tick(cx: &mut gpui::App) -> bool { // The link is down. Drop the dead host object so nothing keeps calling // into it — a control connection that has gone is the whole // workspace's lifeline, not one failed request. - if remote_connect::RemoteConnections::get(cx, host).is_some() { - remote_connect::RemoteConnections::remove(cx, host); + if remote_connect::HostLinks::get(cx, host).is_some() { + remote_connect::HostLinks::remove(cx, host); log::info!("lost the control connection to {target}; reconnecting"); } @@ -1426,7 +1399,7 @@ fn prune_suspended( /// window that is not there. fn bound_machines(cx: &gpui::App) -> Vec<(HostId, RemoteTarget)> { let mut out: Vec<(HostId, RemoteTarget)> = Vec::new(); - for workspace in &WorkspaceStore::all(cx).workspaces { + for workspace in &WorkspaceStore::all(cx).views { let Some(host) = workspace.host.as_ref() else { continue; }; @@ -1445,7 +1418,7 @@ fn bound_machines(cx: &gpui::App) -> Vec<(HostId, RemoteTarget)> { /// belong to. fn workspaces_on(cx: &gpui::App, host: HostId) -> Vec<(WorkspaceId, String)> { WorkspaceStore::all(cx) - .workspaces + .views .iter() .filter(|w| w.open) .filter_map(|w| { @@ -1456,14 +1429,11 @@ fn workspaces_on(cx: &gpui::App, host: HostId) -> Vec<(WorkspaceId, String)> { } /// Apply everything the reader threads pushed since the last tick. -fn drain_events(cx: &mut gpui::App) { +pub(crate) fn drain_events(cx: &mut gpui::App) { let events = match EVENTS.lock() { Ok(mut queue) => std::mem::take(&mut *queue), Err(_) => return, }; - // Read out before the loop: the pull is one round trip per workspace no - // matter how many events asked for it. - let stale = stale_workspaces(&events); for (host, event) in events { match event { // The takeover, arriving. The window goes read-only and @@ -1481,102 +1451,41 @@ fn drain_events(cx: &mut gpui::App) { .preempted .insert(id, by.clone()); release_panes(cx, id); + // The window's tree-sync state goes with the streams: its + // mirror and queue describe a session that just lost the + // workspace, and its `informed` licence must not survive into + // the take-back (see `tree_sync::on_preempted`). + crate::ui::tree_sync::on_preempted(cx, id); cx.refresh_windows(); } - // Handled by `stale_workspaces` above, in one pull per workspace. - ControlEvent::WorkspaceChanged { .. } => {} + // Another writer edited a workspace tree this client shows: apply + // the delta to the mirror and the live window (or re-pull the + // workspace when it will not apply cleanly). + ControlEvent::Layout { workspace, delta } => { + crate::ui::tree_sync::on_layout_delta(cx, host, &workspace, delta); + } + // The machine dropped deltas for this connection: every mirror of + // it is now wrong in a way no later delta repairs. Re-pull the + // machine whole and rebuild the windows on it — the recovery an + // unappliable delta already uses, here announced by the server + // instead of stumbled into. + ControlEvent::LayoutResync => { + log::info!("{host:?} dropped layout deltas for this client; re-pulling"); + crate::ui::machine_mirror::MachineMirrors::refresh(cx, host); + for (workspace, _) in crate::ui::windows::WindowRegistry::open_windows(cx) { + if WorkspaceStore::host_of(cx, workspace) != host { + continue; + } + // A preempted window stays passive; its take-back re-pulls. + if workspace_is_preempted(cx, workspace) { + continue; + } + crate::ui::tree_sync::resync_window_from_tree(cx, workspace); + } + } other => log::debug!("unhandled control event from {host:?}: {other:?}"), } } - for (host, key) in stale { - refresh_remote_workspace(cx, host, key); - } -} - -/// The workspaces a batch of events says to re-read, each named once. -/// -/// The machine's own record changed — another client of ours moved a tab, -/// renamed a workspace, closed one. The event carries **no record**, only "go -/// and read it again", and B3 is explicit that losing one of these is safe and -/// getting two is safe. That is exactly the licence to collapse a burst into one -/// round trip, and the reason nothing here tries to be incremental: there is no -/// state to keep, so there is none to get wrong. -fn stale_workspaces(events: &[(HostId, ControlEvent)]) -> Vec<(HostId, String)> { - let mut out: Vec<(HostId, String)> = Vec::new(); - for (host, event) in events { - if let ControlEvent::WorkspaceChanged { id } = event - && !out.iter().any(|(h, key)| h == host && key == id) - { - out.push((*host, id.clone())); - } - } - out -} - -/// Re-read one workspace's record from the machine that owns it and apply it. -/// -/// # Why this cannot interrupt what the user is doing -/// -/// It lands in the **store**, not in the window. `apply_remote` writes the three -/// remote-owned fields of the client's `Workspace` entry (`name`, `session`, -/// `last_active`) and nothing rebuilds a live window from that entry while the -/// window is open — a workspace's tabs are built when the window opens or swaps -/// workspaces, and `claimable_session` scrubs a remote entry's layout even then. -/// So there is no path from here to a closed tab, a re-spawned pane or a moved -/// focus; what a user sees change is the workspace's *name*. -/// -/// That is deliberate rather than incidental. The remote is the -/// authority for the layout, but the client that has the window open is the one -/// *living* in it, and rearranging somebody's panes underneath them because -/// another machine moved a tab is not a refresh, it is a fight. The remote's -/// layout is what a window opens *from* — on the next connect, reconnect or -/// reopen — and this keeps the copy it will open from current. -/// -/// # The one race, and how it is settled -/// -/// A push of ours can be in flight when an event arrives (the server excludes -/// the writer, so the event is another client's, but it may describe a moment -/// before our write). Applying it would briefly show that client's name for a -/// workspace we are mid-rename of. So a workspace with a push in flight is -/// skipped: our push is about to become the machine's truth, and B3's "dropping -/// one is safe" is what makes skipping the right move rather than a queue. -fn refresh_remote_workspace(cx: &mut gpui::App, host: HostId, store_key: String) { - let Some(id) = client_id_for(cx, host, &store_key) else { - // A workspace on that machine this client has no window on. Nothing to - // refresh; the record is pulled when it is opened. - log::debug!("remote workspace {store_key} changed on a machine with no window here"); - return; - }; - if cx.default_global::().pushing.contains(&id) { - log::debug!("skipping the refresh of {id}: this client is mid-push for it"); - return; - } - let Some(connection) = remote_connect::RemoteConnections::get(cx, host) else { - // Not connected: the next connect pulls the whole list anyway. - return; - }; - cx.spawn(async move |cx| { - let pulled = cx - .background_executor() - .spawn(async move { remote_connect::get_remote_layout(&connection, store_key) }) - .await; - match pulled { - Ok(record) => { - cx.update(|cx| { - WorkspaceStore::apply_remote(cx, id, &record); - cx.refresh_windows(); - }); - } - // The workspace was deleted on the far side. The window stays open - // with what it had — a window is never closed, and least of all - // because another machine decided this one was done with it. - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - log::info!("remote workspace {id} is gone from its machine; keeping the window"); - } - Err(e) => log::warn!("could not re-read remote workspace {id}: {e}"), - } - }) - .detach(); } /// Come back to a machine whose server was just replaced. @@ -1601,7 +1510,7 @@ fn reconnect_after_restart(origin: &str, cx: &mut gpui::App) { let Some(host) = remote_connect::origin_host(origin) else { return; }; - remote_connect::RemoteConnections::remove(cx, host); + remote_connect::HostLinks::remove(cx, host); for (workspace, _) in workspaces_on(cx, host) { RemoteLinks::retry_now(cx, workspace); } @@ -1669,7 +1578,7 @@ fn launch_attempt(cx: &mut gpui::App, host: HostId, target: RemoteTarget) { log::info!("took workspace {key} back from {who}"); } Ok(_) => {} - // A machine that has no workspace store (an older + // A machine that has no machine tree (an older // server) still serves files; the workspace is usable, // it simply cannot be claimed exclusively. Err(e) => log::warn!("could not attach to workspace {key}: {e}"), @@ -1692,43 +1601,39 @@ fn finish_attempt( ) { match outcome { Ok(connected) => { - // The remote's record is the authority for the layout, - // so what came back with the connect replaces what this client had. - let rows = connected.rows.clone(); - let instance = connected.host.peer().instance.clone(); let restarted = server_restarted(cx, host, &connected.host); // The home too, not just the connection: this is the path a machine // comes back on after a restart or a dropped link, and dropping it // here is what left "New Workspace" missing on a machine the panel // was quite happily calling connected. - remote_connect::RemoteConnections::insert(cx, connected.host, connected.home); - for (id, key) in workspaces_on(cx, host) { - if let Some(row) = rows.iter().find(|r| r.id.to_string() == key) { - WorkspaceStore::apply_remote(cx, id, &row.record); - } - cx.default_global::().preempted.remove(&id); - // The same question `restarted` answers, asked of the *record* - // rather than of this process's memory — and it is the only one - // that can answer across a client restart. `instances` is an - // in-memory map, so on a cold launch every machine is a first - // sighting and `restarted` is false; a server replaced while - // this client was closed would sail through, and its recycled - // ids would attach to whatever unrelated shells now hold the - // numbers. `daemon_instance` is on disk and remembers. - let stale = WorkspaceStore::forget_stale_pane_ids(cx, id, &instance); - if restarted || stale { - // Every pane id this workspace holds was minted by a process - // that is gone. Re-attaching them would cost one doomed round - // trip each and leave the window exactly as disconnected as - // it is now, so the window is rebuilt from the layout instead - // — the same thing a local daemon restart does. - rebuild_after_server_restart(cx, id); + remote_connect::HostLinks::insert(cx, connected.host, connected.home); + for (id, _key) in workspaces_on(cx, host) { + let reclaimed = { + let links = cx.default_global::(); + // The attach that just landed preempts whoever held the + // workspace, so a still-recorded preemption is one this + // reconnect ends — same situation as an explicit Take + // Back, and rebuilt the same way below. + links.preempted.remove(&id).is_some() | links.reclaiming.remove(&id) + }; + if restarted || reclaimed { + // Rebuild from the tree whole. After a server restart + // every pane this window shows lived in a process that is + // gone (a fresh server holds no live panes), so the tree + // lowers each leaf to a revival — fresh shells in the + // recorded cwds, agents resumed. After a take-back the + // panes may well be alive, but the *layout* on screen is + // the pre-takeover one: the IfEmpty hydration below would + // skip this non-empty window and leave it stale — the + // "take back re-pulls whole" the preemption paths promise + // happens here, as a Replace. + crate::ui::tree_sync::resync_window_from_tree(cx, id); } else { relink_panes(cx, id); - // A window that came up before its machine did has no panes to - // relink — it opened empty because there was nothing to route - // to. Now there is. - hydrate_window(cx, id); + // A window that came up before its machine did has no panes + // to relink — it opened empty because there was nothing to + // route to. Now there is: fill it from the tree. + crate::ui::tree_sync::hydrate_window_from_tree(cx, id); } // Same reason the window had no panes: with the machine // unreachable there was nothing to ask for its shells, so the @@ -1824,55 +1729,6 @@ fn relink_panes(cx: &mut gpui::App, workspace: WorkspaceId) { } } -/// Build the tabs of a window that opened before its machine was reachable. -/// -/// This is the other end of [`crate::core::session::WorkspaceStore::claim`]'s -/// reachability rule. A remote workspace reopened at launch has nowhere to route -/// to yet — the link is still being built — so it opens empty rather than -/// spawning a second set of shells beside the ones still running over there. -/// The layout it *would* have opened from is the entry's cached session, which -/// [`finish_attempt`] has just refreshed from the machine itself, so by the time -/// this runs the window is rebuilding from the authority. -/// -/// # What it will not do -/// -/// **Only an empty window is touched.** A window with tabs is one the user is -/// working in; rearranging it because a link came back is the same fight -/// [`refresh_remote_workspace`] refuses to pick. That also makes this safe to -/// call on every reconnect — the second one through finds tabs and leaves. -fn hydrate_window(cx: &mut gpui::App, workspace: WorkspaceId) { - let session = match WorkspaceStore::all(cx).get(workspace) { - Some(entry) if entry.is_remote() => entry.session.clone(), - // Local, or an entry that went away while the connect was in flight. - _ => return, - }; - if session.tabs.is_empty() { - // Nothing to restore: a workspace that was quit from the home page, or - // a brand-new one. Its window is right as it is. - return; - } - let Some(handle) = crate::ui::windows::WindowRegistry::window_for(cx, workspace) else { - return; - }; - let Some(app) = - crate::ui::windows::WindowRegistry::app_for(cx, workspace).and_then(|app| app.upgrade()) - else { - return; - }; - if !app.read(cx).tabs.is_empty() { - return; - } - log::info!( - "rebuilding {} tab(s) of workspace {workspace} now its machine is reachable", - session.tabs.len() - ); - let _ = handle.update(cx, move |_, window, cx| { - app.update(cx, |app, cx| { - app.adopt_workspace(workspace, session, window, cx) - }); - }); -} - /// Whether the machine we just reconnected to is being served by a *different* /// `tty7-server` process than the one we last spoke to. /// @@ -1922,55 +1778,6 @@ fn note_instance( } } -/// Rebuild a workspace's window after its machine's server was replaced. -/// -/// The local analogue is [`Tty7App::restart_daemon_confirmed`], and this is -/// deliberately the same shape: the layout is the thing that survives, and every -/// leaf in it comes back as a fresh shell in its saved cwd. What makes it safe -/// here is only that [`server_restarted`] *knew* — the same rebuild triggered by -/// a guess would be a way to lose running work. -/// -/// **The saved pane ids are dropped first**, and that is what makes the resume -/// work rather than being a tidiness measure. `session_to_pane` keeps a remote -/// leaf's id unconditionally (its liveness cannot be probed without a round -/// trip) and lets the attach fail into a spawn *inside* the terminal — by which -/// point the code that would have sent `claude --resume ` has already -/// decided it wasn't needed. Clearing the ids up here makes the leaf take the -/// same path a dead local pane takes, so the agent conversation continues. -/// -/// Unlike [`hydrate_window`] this does **not** skip a window with tabs. Those -/// tabs are precisely what has to go: every one of them is a pane bound to a -/// process that no longer exists. -fn rebuild_after_server_restart(cx: &mut gpui::App, workspace: WorkspaceId) { - let mut session = match WorkspaceStore::all(cx).get(workspace) { - Some(entry) if entry.is_remote() => entry.session.clone(), - _ => return, - }; - if session.tabs.is_empty() { - return; - } - for tab in &mut session.tabs { - tty7_core::core::session::blank_pane_ids(&mut tab.pane); - } - let Some(handle) = crate::ui::windows::WindowRegistry::window_for(cx, workspace) else { - return; - }; - let Some(app) = - crate::ui::windows::WindowRegistry::app_for(cx, workspace).and_then(|app| app.upgrade()) - else { - return; - }; - log::info!( - "rebuilding {} tab(s) of workspace {workspace}: its machine is serving a new process", - session.tabs.len() - ); - let _ = handle.update(cx, move |_, window, cx| { - app.update(cx, |app, cx| { - app.adopt_workspace(workspace, session, window, cx) - }); - }); -} - /// Ask the window showing `workspace` to refill its "+" dropdown, now that its /// machine is answering. No-op for a workspace with no window on screen. fn refresh_window_shells(cx: &mut gpui::App, workspace: WorkspaceId) { @@ -2146,10 +1953,101 @@ pub(crate) fn workspace_accepts_input(cx: &gpui::App, workspace: WorkspaceId) -> RemoteLinks::status_of(cx, workspace).is_none_or(|s| s.accepts_input()) } +/// Whether another client's session currently holds `workspace`. Read by the +/// delta application, which must leave a preempted window passive — attaching +/// to the usurper's panes would steal the streams they are typing into. +pub(crate) fn workspace_is_preempted(cx: &gpui::App, workspace: WorkspaceId) -> bool { + cx.try_global::() + .is_some_and(|links| links.preempted.contains_key(&workspace)) +} + #[cfg(test)] mod tests { use super::*; + /// Take Back is `retry_now` on a preempted workspace, and the window it + /// recovers still shows the pre-takeover layout — so clearing the + /// preemption must leave a `reclaiming` mark behind for `finish_attempt` + /// to read, or the landed attach runs its ordinary IfEmpty hydration, + /// skips the non-empty window, and the stale layout survives to roll the + /// other client's edits back on the next save. + #[gpui::test] + fn taking_back_marks_the_workspace_for_a_whole_rebuild(cx: &mut gpui::TestAppContext) { + cx.update(|cx| { + // `retry_now` wakes the supervisor, whose first tick resolves the + // machine's route off the config global. + cx.set_global(crate::core::config::Config::default()); + let host = RemoteRef::new( + RemoteTarget::Alias { + alias: "build-box".into(), + }, + WorkspaceId::new(), + ); + let view = crate::core::session::WindowView { + host: Some(host), + ..Default::default() + }; + let id = view.id; + crate::core::session::WorkspaceStore::install_for_test( + cx, + crate::core::session::WindowViews { + views: vec![view], + active: None, + }, + ); + cx.default_global::() + .preempted + .insert(id, "laptop".into()); + + RemoteLinks::retry_now(cx, id); + + let links = cx.default_global::(); + assert!( + !links.preempted.contains_key(&id), + "the takeover is being reversed; the read-only state ends now" + ); + assert!( + links.reclaiming.contains(&id), + "the attach that lands must know to rebuild this window from the tree" + ); + }); + } + + /// A plain reconnect (never preempted) must not be marked for a rebuild — + /// its panes are alive and re-attachable, and a Replace would tear down + /// views the relink was about to reuse. + #[gpui::test] + fn a_plain_reconnect_is_not_marked_for_a_rebuild(cx: &mut gpui::TestAppContext) { + cx.update(|cx| { + cx.set_global(crate::core::config::Config::default()); + let host = RemoteRef::new( + RemoteTarget::Alias { + alias: "build-box".into(), + }, + WorkspaceId::new(), + ); + let view = crate::core::session::WindowView { + host: Some(host), + ..Default::default() + }; + let id = view.id; + crate::core::session::WorkspaceStore::install_for_test( + cx, + crate::core::session::WindowViews { + views: vec![view], + active: None, + }, + ); + + RemoteLinks::retry_now(cx, id); + + assert!( + !cx.default_global::().reclaiming.contains(&id), + "nothing was taken over, so nothing needs the Replace path" + ); + }); + } + #[test] fn the_status_strip_speaks_unless_everything_is_working() { assert_eq!(RemoteStatus::Attached.strip_message("build-box"), None); @@ -2263,52 +2161,6 @@ mod tests { ); } - /// Every leaf loses its id, at every depth. A `Split` branch that kept its - /// ids would leave those panes attaching to a dead process — and, worse, - /// skipping the agent resume, because that only fires for a leaf with no id. - #[test] - fn forgetting_pane_ids_reaches_every_leaf() { - use crate::core::session::{SessionAxis, SessionPane}; - - fn leaf(id: u64) -> SessionPane { - SessionPane::Leaf { - cwd: None, - pane_id: Some(id), - ssh_spec: None, - agent: None, - agent_session_id: None, - agent_launch_argv: None, - } - } - fn ids(pane: &SessionPane, out: &mut Vec>) { - match pane { - SessionPane::Leaf { pane_id, .. } => out.push(*pane_id), - SessionPane::Split { a, b, .. } => { - ids(a, out); - ids(b, out); - } - } - } - - let mut pane = SessionPane::Split { - axis: SessionAxis::Horizontal, - ratio: 0.5, - a: Box::new(leaf(1)), - b: Box::new(SessionPane::Split { - axis: SessionAxis::Vertical, - ratio: 0.5, - a: Box::new(leaf(2)), - b: Box::new(leaf(3)), - }), - }; - let forgotten = tty7_core::core::session::blank_pane_ids(&mut pane); - - let mut found = Vec::new(); - ids(&pane, &mut found); - assert_eq!(found, vec![None, None, None]); - assert_eq!(forgotten, 3, "every dropped claim is counted"); - } - // ── The reconnect schedule (no network) ───────────────────────────────── /// The schedule is fixed: **1/2/4/…/30s capped, retried for ever**. @@ -2429,82 +2281,8 @@ mod tests { assert_eq!(q.waiting(), 1); } - // ── `WorkspaceChanged` → re-read (B3's push, arriving) ─────────────────── + // ── The input gate ─────────────────────────────────────────────────────── - /// **A burst of changes costs one round trip per workspace.** - /// - /// B3's contract for this event is that it means only "read it again", so - /// losing one is safe and getting ten is safe. Collapsing them is the whole - /// of the logic that rule buys — and the thing that keeps a client with a - /// chatty peer from opening a `WorkspaceGet` per keystroke of theirs. - #[test] - fn a_burst_of_changes_asks_for_each_workspace_once() { - let (a, b) = (host("ssh-alias:a"), host("ssh-alias:b")); - let changed = |id: &str| ControlEvent::WorkspaceChanged { id: id.into() }; - let events = vec![ - (a, changed("w1")), - (a, changed("w1")), - (b, changed("w1")), - (a, changed("w2")), - (a, changed("w1")), - ]; - assert_eq!( - stale_workspaces(&events), - vec![ - (a, "w1".to_string()), - (b, "w1".to_string()), - (a, "w2".to_string()), - ], - "one pull per (machine, workspace), in the order they were heard" - ); - } - - /// The same workspace id on two machines is two workspaces — pane ids and - /// store keys are per machine, and merging them would refresh one window - /// from another box's record. - #[test] - fn a_change_is_scoped_to_the_machine_that_reported_it() { - let (a, b) = (host("ssh-alias:a"), host("ssh-alias:b")); - let events = vec![ - (a, ControlEvent::WorkspaceChanged { id: "same".into() }), - (b, ControlEvent::WorkspaceChanged { id: "same".into() }), - ]; - assert_eq!(stale_workspaces(&events).len(), 2); - } - - /// Every other event is somebody else's business. A takeover in particular - /// must not also trigger a pull: it is handled on its own path, and the - /// record has not changed. - #[test] - fn only_a_workspace_change_asks_for_a_re_read() { - let a = host("ssh-alias:a"); - let events = vec![ - ( - a, - ControlEvent::Preempted { - workspace: "w1".into(), - by: "desktop".into(), - }, - ), - ( - a, - ControlEvent::PaneExited { - pane_id: 3, - code: None, - }, - ), - ]; - assert!(stale_workspaces(&events).is_empty()); - } - - // ── The read-only degrade, state by state ─────────────────── - - /// The degrade in one table: which states are read-only, what the - /// bottom line says, and what the strip offers to do about it. - /// - /// `Preempted` reads differently on purpose — "not connected" would be a - /// lie, because the link is usually fine and the workspace is simply - /// somebody else's now. #[test] fn every_state_says_what_it_means_for_the_keyboard() { let cases = [ @@ -2625,7 +2403,7 @@ mod tests { crate::ui::windows::WindowRegistry::init(cx); let (host, target) = machine("build-box"); - let mut entry = crate::core::session::Workspace::on_remote(RemoteRef::new( + let mut entry = crate::core::session::WindowView::on_remote(RemoteRef::new( target, WorkspaceId::new(), )); @@ -2633,8 +2411,8 @@ mod tests { let id = entry.id; WorkspaceStore::install_for_test( cx, - crate::core::session::Workspaces { - workspaces: vec![entry], + crate::core::session::WindowViews { + views: vec![entry], active: None, }, ); diff --git a/src/ui/switcher.rs b/src/ui/switcher.rs index 379d9f4d..0669db59 100644 --- a/src/ui/switcher.rs +++ b/src/ui/switcher.rs @@ -25,13 +25,14 @@ //! //! # Where the rows come from //! -//! `session.json` already records remote workspaces (`Workspace::host`), so a +//! The view store records remote workspaces (`WindowView::host`), so a //! machine's workspaces are listed **without connecting to it** — the client -//! remembers what it saw last time. Connecting only ever *adds*: the remote's -//! own store is the authority, so its rows are merged in when a link exists and -//! anything this client had not heard of shows up then (see [`Group::merge`]). -//! That is what makes "expand a machine" a lazy, cheap gesture rather than a -//! wizard. +//! remembers which ones it saw, and their display facts come from the +//! machine's mirror (`ui::machine_mirror`). Connecting only ever *adds*: the +//! remote's own tree is the authority, so its rows are merged in when a link +//! exists and anything this client had not heard of shows up then (see +//! [`Group::merge`]). That is what makes "expand a machine" a lazy, cheap +//! gesture rather than a wizard. use std::collections::{HashMap, HashSet}; use std::path::PathBuf; @@ -194,7 +195,7 @@ struct Row { current: bool, /// Set for a workspace that exists on the remote but has no local record /// yet: opening it has to claim it first. `None` once it is in - /// `session.json` like any other. + /// the view store like any other. adopt: Option>, /// This row's id **on its own machine**, for a remote workspace. It is what /// the remote's list is matched against — the local [`WorkspaceId`] above is @@ -217,7 +218,7 @@ pub(crate) struct HostSnapshot { /// it could never be given a group to appear in. pub target: RemoteTarget, /// What the remote said it had. The machine's `$HOME` is deliberately *not* - /// here — it lives in `RemoteConnections`, app-wide, because every window + /// here — it lives in `HostLinks`, app-wide, because every window /// needs it and only one of them ever did the connecting. pub rows: Vec, } @@ -317,7 +318,7 @@ impl Tty7App { { let app: &App = cx; let store = WorkspaceStore::all(app); - for w in &store.workspaces { + for w in &store.views { let (key, label, target) = match w.host.as_ref() { None => (String::new(), "This Computer".to_string(), None), Some(r) => { @@ -341,11 +342,14 @@ impl Tty7App { }); groups[slot].rows.push(Row { id: w.id, - name: w.display_name(), - path: w - .dominant_repo() - .or_else(|| w.first_cwd()) - .map(|p| crate::ui::home::display_path(&p)) + // Both read the machine's mirror — the tree owns the + // layout these used to be derived from. A machine not + // pulled yet (launch's first frames; an unreached remote) + // renders the not-knowing rather than a stale guess. + name: crate::ui::machine_mirror::display_name(app, w) + .unwrap_or_else(|| "Untitled".to_string()), + path: crate::ui::machine_mirror::subject_path(app, w) + .map(|p| crate::ui::home::display_path(std::path::Path::new(&p))) .unwrap_or_default(), when: crate::ui::home::relative_time(now, w.last_active), live: crate::terminal::pane_liveness::liveness_of(app, w), @@ -450,7 +454,7 @@ impl Tty7App { // connect, and every reconnect, records the machine's `$HOME` — and // that row is the only way to make a workspace on a machine, so it // has no business depending on which window did the connecting. - group.home = remote_connect::RemoteConnections::home(cx, id); + group.home = remote_connect::HostLinks::home(cx, id); if let Some(snapshot) = self.host_snapshots.get(&id) { group.merge(&snapshot.rows, now); } @@ -485,7 +489,7 @@ impl Tty7App { } _ => {} } - match remote_connect::RemoteConnections::get(cx, target.host_id()) { + match remote_connect::HostLinks::get(cx, target.host_id()) { Some(_) => Link::Connected, None => Link::Offline, } @@ -558,10 +562,7 @@ impl Tty7App { /// window's *current* workspace, because the field it opens is the chip. A /// list needs to rename the row that was aimed at, so it gets its own. fn switcher_rename(&mut self, id: WorkspaceId, window: &mut Window, cx: &mut Context) { - let current = WorkspaceStore::all(cx) - .get(id) - .map(|w| w.display_name()) - .unwrap_or_default(); + let current = crate::ui::machine_mirror::display_name_for(cx, id).unwrap_or_default(); let input = cx.new(|cx| InputState::new(window, cx).default_value(current)); input.update(cx, |state, cx| state.focus(window, cx)); let sub = cx.subscribe_in( @@ -586,7 +587,7 @@ impl Tty7App { return; }; let value = input.read(cx).value().trim().to_string(); - WorkspaceStore::rename(cx, id, (!value.is_empty()).then_some(value)); + crate::ui::tree_sync::rename_workspace(cx, id, (!value.is_empty()).then_some(value)); crate::ui::windows::refresh_menu(cx); if id == self.workspace { self.sync_window_title(window, cx); diff --git a/src/ui/tab_strip.rs b/src/ui/tab_strip.rs index ac4acbfe..edf6b6c0 100644 --- a/src/ui/tab_strip.rs +++ b/src/ui/tab_strip.rs @@ -442,9 +442,7 @@ impl Tty7App { // sweep is rate-limited, and past that gate each machine is only asked // once its own TTL has run out. crate::terminal::pane_liveness::sweep(cx); - let current = crate::core::session::WorkspaceStore::all(cx) - .get(self.workspace) - .map(|w| w.display_name()) + let current = crate::ui::machine_mirror::display_name_for(cx, self.workspace) .unwrap_or_else(|| "tty7".to_string()); // First character, uppercased — the whole point is a glyph that is // recognisably *this* workspace at a glance across windows. diff --git a/src/ui/theme.rs b/src/ui/theme.rs index ba3a6bf5..94514586 100644 --- a/src/ui/theme.rs +++ b/src/ui/theme.rs @@ -198,14 +198,19 @@ fn window_menu_items(cx: &App) -> Vec { items.push(MenuItem::Separator); } } + // From the machine's mirror — the tree owns the layout the name is + // derived from. Before the first pull lands the entry reads as the + // shared fallback; the menu is rebuilt on every roster change anyway. + let name = crate::ui::machine_mirror::display_name(cx, workspace) + .unwrap_or_else(|| "Untitled".to_string()); let label = if *open { - workspace.display_name() + name } else { // The age is the useful discriminator among detached ones — several // may share a repo name. format!( "{} — {}", - workspace.display_name(), + name, crate::ui::home::relative_time(now, workspace.last_active) ) }; diff --git a/src/ui/tree_sync.rs b/src/ui/tree_sync.rs new file mode 100644 index 00000000..284d5f5c --- /dev/null +++ b/src/ui/tree_sync.rs @@ -0,0 +1,2967 @@ +//! The write half of the client-side tree migration: every structural change a +//! window makes becomes **semantic operations** on the daemon-owned machine +//! tree, instead of a whole-layout write to a file. +//! +//! # Why a mirror-and-diff rather than ops at every call site +//! +//! `Tty7App::save_session` is already the single point every structural change +//! funnels through — twenty-odd call sites, each of which knows *that* +//! something changed but expresses it by handing over the whole tab list. This +//! module keeps that funnel: it holds, per window, a **mirror** of what the +//! daemon's tree looked like after the last acknowledged operation, and each +//! sync diffs the window's current state against it. Because consecutive syncs +//! differ by exactly one user action, the diff *recovers* that action — a +//! split diffs to one `PaneSplit`, a closed tab to one `TabClose` — without +//! twenty call sites each hand-rolling its own op sequence (and each being a +//! chance to get one wrong). Multi-step changes ("close other tabs") fall out +//! as the op sequence they are. +//! +//! The mirror is updated by running **the server's own tree surgery** +//! ([`PaneNode::split_leaf`] and friends are public for exactly this), so the +//! predicted post-state cannot drift from what the daemon will hold. +//! +//! # What happens when prediction and reality disagree +//! +//! Any failed operation — a refused edit, a dropped link — invalidates the +//! mirror instead of trying to patch around it: the queue is dropped, the tree +//! is re-pulled (`WorkspaceTree`), and the next diff against the *authoritative* +//! state re-emits exactly the edits that still matter. Reconciliation by +//! re-pull is the one recovery path, shared by every failure mode, which is +//! why none of them needs code of its own. +//! +//! # Panes that do not exist yet +//! +//! A leaf whose pane is still connecting (a fresh spawn with no daemon id) is +//! **invisible** to the tree until it lands: the daemon's leaves hold pane ids +//! and nothing else, so there is nothing to say yet. `land_pane`'s save is the +//! moment the id exists, and the diff then emits the `TabCreate` / `PaneSplit` +//! the earlier saves could not. A connecting leaf that is *re-attaching* to a +//! known pane id is representable all along. +//! +//! # One id space oddity +//! +//! Operations name the workspace by the **machine's** id. For a local window +//! that is the client's own [`WorkspaceId`]; for a remote one it is +//! `RemoteRef::workspace` — the id minted on that machine — while the client's +//! entry keeps its own id for the window registry. [`tree_workspace_id`] is the +//! one translation point. + +use std::collections::{HashMap, VecDeque}; +use std::io; +use std::sync::Arc; + +use gpui::{App, Global}; +use tty7_core::core::machine::{ + AgentFacts, Axis as TreeAxis, LayoutDelta, Machine, PaneNode, PaneRecord, PaneSeed, Side, + Tab as TreeTab, TabId, +}; +use tty7_core::daemon::control::{ControlClient, ControlRequest, ReplyOk}; +use tty7_core::host::HostId; + +use crate::core::session::{Session, SessionPane, SessionTab, WorkspaceId, WorkspaceStore}; +use crate::ui::app::Tty7App; +use crate::ui::pane::{Pane, PaneSlot}; + +/// The control link to `host`'s daemon, if one is up right now. +/// +/// The unification the whole design leans on: the local machine's link lives in +/// [`LocalLink`](crate::ui::local_link::LocalLink), a remote machine's in +/// [`HostLinks`](crate::ui::remote_connect::HostLinks), and +/// everything above this function stops caring which. `None` is always +/// transient (both holders have supervisors reconnecting), so callers treat it +/// as "not now": mark dirty and let the re-pull that follows reconnection +/// resend what still matters. +pub(crate) fn control_for(cx: &mut App, host: HostId) -> Option> { + if host.is_local() { + crate::ui::local_link::LocalLink::client(cx) + } else { + crate::ui::remote_connect::HostLinks::get(cx, host) + .map(|h| Arc::clone(h.client())) + .filter(|c| c.is_connected()) + } +} + +/// The control link to `host`, seen by a caller about to speak the tree verbs. +/// +/// [`TreeLink::Unserved`] is the difference from [`control_for`]'s plain +/// `None`: the peer is connected but does not advertise +/// [`feature::MACHINE_TREE`](tty7_core::daemon::control::feature::MACHINE_TREE) +/// — a server with no home directory to keep a tree in, or one predating the +/// verbs. "Down" is transient and retried; "unserved" is a fact about the +/// peer, and sending it tree verbs anyway would only trade this one clear +/// state for a refusal (or, on an old enough peer, a decode failure) per +/// operation. +pub(crate) enum TreeLink { + Ready(Arc), + Unserved, + Down, +} + +pub(crate) fn tree_control_for(cx: &mut App, host: HostId) -> TreeLink { + classify_tree_link(control_for(cx, host)) +} + +/// The judgement half of [`tree_control_for`]: what the handshake's +/// capability bits say this link is good for. +fn classify_tree_link(client: Option>) -> TreeLink { + match client { + Some(client) + if client + .hello() + .has_feature(tty7_core::daemon::control::feature::MACHINE_TREE) => + { + TreeLink::Ready(client) + } + Some(_) => TreeLink::Unserved, + None => TreeLink::Down, + } +} + +/// The machine-side id operations about this window's workspace must carry. +fn tree_workspace_id(cx: &App, client_ws: WorkspaceId) -> WorkspaceId { + WorkspaceStore::all(cx) + .get(client_ws) + .and_then(|w| w.host.as_ref()) + .map(|r| r.workspace) + .unwrap_or(client_ws) +} + +// --------------------------------------------------------------------------- +// The desired tree: what the window currently shows, in the daemon's shape +// --------------------------------------------------------------------------- + +/// One tab as the window wants the daemon to hold it. +#[derive(Debug, Clone)] +pub(crate) struct DesiredTab { + pub id: TabId, + pub name: Option, + pub group: Option, + pub root: DesiredNode, +} + +/// A pane tree whose leaves carry the [`PaneSeed`] that introduces them, so an +/// op that first mentions a pane has its birth certificate in hand. +#[derive(Debug, Clone)] +pub(crate) enum DesiredNode { + Leaf { + pane: u64, + seed: PaneSeed, + }, + Split { + axis: TreeAxis, + ratio: f32, + a: Box, + b: Box, + }, +} + +impl DesiredNode { + /// The first (top/left-most) leaf — the anchor every split materializes + /// around. + fn first_leaf(&self) -> (&u64, &PaneSeed) { + match self { + DesiredNode::Leaf { pane, seed } => (pane, seed), + DesiredNode::Split { a, .. } => a.first_leaf(), + } + } + + /// The plain tree shape, for comparing against a mirror tab's root. + fn to_pane_node(&self) -> PaneNode { + match self { + DesiredNode::Leaf { pane, .. } => PaneNode::Leaf { pane: *pane }, + DesiredNode::Split { axis, ratio, a, b } => PaneNode::Split { + axis: *axis, + ratio: *ratio, + a: Box::new(a.to_pane_node()), + b: Box::new(b.to_pane_node()), + }, + } + } + + /// The seed of the leaf holding `pane`. + fn seed_of(&self, pane: u64) -> Option<&PaneSeed> { + match self { + DesiredNode::Leaf { pane: p, seed } => (*p == pane).then_some(seed), + DesiredNode::Split { a, b, .. } => a.seed_of(pane).or_else(|| b.seed_of(pane)), + } + } +} + +/// Read the window's tabs into the daemon's shape. Tabs with nothing +/// representable yet (every pane still spawning) are omitted from the desired +/// list — but their identities are answered separately as *held*: the tab is +/// occupied, its panes just have no ids yet, and a diff that read its absence +/// as "closed" would delete the daemon tab (and spend the very records) a +/// revival in flight is about to replace. +/// +/// Held is strictly for the *transient* case. A remote window's tab that is +/// native-SSH through and through is unrepresentable **forever** — its panes +/// live in this client's daemon — and is neither desired nor held: as far as +/// this machine's tree is concerned, it does not exist. Holding it instead +/// would freeze the whole window's ordering and active-tab sync permanently, +/// because [`diff`] waits out held tabs before touching either. +pub(crate) fn desired_tabs( + app: &Tty7App, + cx: &App, +) -> (Vec, Option, Vec) { + let remote = WorkspaceStore::all(cx) + .get(app.workspace) + .is_some_and(|w| w.is_remote()); + let mut out = Vec::new(); + let mut active = None; + let mut held = Vec::new(); + for (index, tab) in app.tabs.iter().enumerate() { + let Some(root) = desired_node(&tab.pane, remote, cx) else { + // No root means every leaf is individually unrepresentable. If + // even one of them is merely *pending* (a spawn or an empty slot + // still to fill), the tab is held; a pure native-SSH tab is + // permanently invisible instead. The distinction also lets a + // mixed tab whose last tree-visible pane was closed fall out of + // `desired` entirely, so a Full diff closes its daemon tab + // rather than leaving a dead leaf on the machine for ever. + if !(remote && every_leaf_is_native_ssh(&tab.pane, cx)) { + held.push(tab.tree_id.get()); + } + continue; + }; + let id = tab.tree_id.get(); + if index == app.active { + active = Some(id); + } + out.push(DesiredTab { + id, + name: tab.name.clone(), + group: tab + .sidebar_group + .borrow() + .as_ref() + .map(|p| p.to_string_lossy().into_owned()), + root, + }); + } + (out, active, held) +} + +/// Whether every leaf of `pane` is a *ready* native-SSH view — the one kind +/// of leaf a remote window can never name in its machine's tree, because the +/// pane lives in this client's own daemon. Only meaningful for a tab whose +/// desired root came out `None`: it decides permanently-invisible versus +/// held (see [`desired_tabs`]). A connecting or empty leaf answers `false` — +/// those are pending, not foreign. +fn every_leaf_is_native_ssh(pane: &Pane, cx: &App) -> bool { + match pane { + Pane::Leaf(PaneSlot::Ready(view)) => view.read(cx).ssh_spec().is_some(), + Pane::Leaf(PaneSlot::Connecting(_)) | Pane::Empty => false, + Pane::Split { a, b, .. } => { + every_leaf_is_native_ssh(a, cx) && every_leaf_is_native_ssh(b, cx) + } + } +} + +/// One GUI pane node, in tree shape. `None` for the unrepresentable: a fresh +/// spawn with no pane id yet, and — in a remote window — a native-SSH leaf, +/// whose pane lives in *this* client's daemon and so cannot be named in the +/// remote machine's tree (its id would collide with an unrelated pane there). +fn desired_node(pane: &Pane, remote_window: bool, cx: &App) -> Option { + match pane { + Pane::Leaf(PaneSlot::Ready(view)) => { + let view = view.read(cx); + let ssh_spec = view.ssh_spec(); + if remote_window && ssh_spec.is_some() { + return None; + } + let agent = view.agent().map(|agent| { + let session = view.agent_session(); + AgentFacts { + agent, + session_id: session.as_ref().and_then(|s| s.session_id.clone()), + launch_argv: session.as_ref().and_then(|s| s.launch_argv.clone()), + status: None, + } + }); + Some(DesiredNode::Leaf { + pane: view.pane_id, + seed: PaneSeed { + pane: view.pane_id, + cwd: view + .spawnable_cwd() + .map(|p| p.to_string_lossy().into_owned()), + ssh_spec, + agent, + }, + }) + } + Pane::Leaf(PaneSlot::Connecting(pending)) => { + let spawn = &pending.read(cx).spawn; + let pane = spawn.restore_pane?; + let agent = spawn.agent.map(|agent| AgentFacts { + agent, + session_id: spawn.agent_session_id.clone(), + launch_argv: spawn.agent_launch_argv.clone(), + status: None, + }); + Some(DesiredNode::Leaf { + pane, + seed: PaneSeed { + pane, + cwd: spawn + .working_directory + .as_ref() + .map(|p| p.to_string_lossy().into_owned()), + ssh_spec: None, + agent, + }, + }) + } + Pane::Split { + axis, a, b, ratio, .. + } => { + let left = desired_node(a, remote_window, cx); + let right = desired_node(b, remote_window, cx); + match (left, right) { + (Some(a), Some(b)) => Some(DesiredNode::Split { + axis: match axis { + gpui::Axis::Horizontal => TreeAxis::Horizontal, + gpui::Axis::Vertical => TreeAxis::Vertical, + }, + ratio: ratio.get(), + a: Box::new(a), + b: Box::new(b), + }), + // One side has nothing to say yet: the other stands where the + // split will be, exactly as the daemon would collapse it. + (one, other) => one.or(other), + } + } + Pane::Empty => None, + } +} + +// --------------------------------------------------------------------------- +// The mirror, and the diff that recovers operations from it +// --------------------------------------------------------------------------- + +/// What the daemon's copy of this workspace looked like after the last +/// operation this window sent (or the last pull). +#[derive(Debug, Clone, Default, PartialEq)] +pub(crate) struct WsMirror { + pub tabs: Vec, + pub active: Option, +} + +/// Diff the window's desired state against the mirror, answering the operation +/// sequence that turns one into the other — and advancing the mirror to the +/// predicted post-state as it goes. +/// +/// `workspace` is the machine-side id the ops carry. +/// How much of the tree a window's diff may claim to speak for. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) enum SyncScope { + /// The window has seen the tree (it was hydrated from it, or the tree was + /// empty when it primed): its state is the whole story, and tabs it does + /// not show are tabs to close. + Full, + /// The window has **not** seen the tree — it opened empty ahead of a pull + /// that has not landed (or was skipped). Its tabs are additions and edits, + /// never evidence of absence: a diff that closed tree tabs such a window + /// simply never displayed would eat another session's layout. + Additive, +} + +pub(crate) fn diff( + workspace: WorkspaceId, + mirror: &mut WsMirror, + desired: &[DesiredTab], + desired_active: Option, + scope: SyncScope, + held: &[TabId], +) -> Vec { + let mut ops = Vec::new(); + + // Tabs that are gone. Position by position so the active-tab heal below + // sees the same intermediate states the server will. Only a window that + // has seen the tree may prune — see [`SyncScope`] — and a *held* tab (its + // panes are mid-spawn, so it is invisible in `desired` without being + // absent) is never pruned. + if scope == SyncScope::Full { + let mut index = 0; + while index < mirror.tabs.len() { + let id = mirror.tabs[index].id; + if desired.iter().any(|t| t.id == id) || held.contains(&id) { + index += 1; + continue; + } + let closed = mirror.tabs.remove(index); + ops.push(ControlRequest::TabClose { + workspace, + tab: closed.id, + }); + heal_active(mirror, index); + } + } + + // New tabs and per-tab reconciliation, in the window's order. An additive + // window appends its new tabs rather than claiming positions among tabs it + // has never seen. + for (index, want) in desired.iter().enumerate() { + match mirror.tabs.iter().position(|t| t.id == want.id) { + None => { + let at = match scope { + SyncScope::Full => index, + SyncScope::Additive => mirror.tabs.len(), + }; + create_tab(workspace, mirror, at, want, &mut ops); + } + Some(at) => reconcile_tab(workspace, mirror, at, want, &mut ops), + } + } + + // With any tab held, positions are ambiguous (a held tab occupies a slot + // the desired list cannot see), so ordering and activation wait for the + // save that follows the spawns landing. + if scope == SyncScope::Additive || !held.is_empty() { + return ops; + } + + // Order: fix each position left to right. The tab moved is always to the + // right of the slot it moves into, so earlier fixes stay fixed. + for (index, want) in desired.iter().enumerate() { + let at = mirror + .tabs + .iter() + .position(|t| t.id == want.id) + .expect("every desired tab exists after the passes above"); + if at != index { + let tab = mirror.tabs.remove(at); + mirror.tabs.insert(index, tab); + ops.push(ControlRequest::TabMove { + workspace, + tab: want.id, + to: index as u64, + }); + } + } + + // Which tab is active. + if let Some(active) = desired_active + && mirror.active != Some(active) + && mirror.tabs.iter().any(|t| t.id == active) + { + mirror.active = Some(active); + ops.push(ControlRequest::WorkspaceSetActiveTab { + workspace, + tab: active, + }); + } + + ops +} + +/// The server's active-tab heal, replayed on the mirror: after the tab at +/// `removed` left, a dangling active id re-points to the neighbour that slid +/// into its place (or the new last tab). +fn heal_active(mirror: &mut WsMirror, removed: usize) { + let named = mirror + .active + .is_some_and(|active| mirror.tabs.iter().any(|t| t.id == active)); + if named { + return; + } + if mirror.tabs.is_empty() { + mirror.active = None; + return; + } + mirror.active = Some(mirror.tabs[removed.min(mirror.tabs.len() - 1)].id); +} + +/// Emit the ops that create `want` whole: `TabCreate` anchored on its first +/// leaf, then one `PaneSplit` per split, then the labels. +fn create_tab( + workspace: WorkspaceId, + mirror: &mut WsMirror, + index: usize, + want: &DesiredTab, + ops: &mut Vec, +) { + let (first, seed) = want.root.first_leaf(); + ops.push(ControlRequest::TabCreate { + workspace, + at: Some(index as u64), + pane: seed.clone(), + tab: Some(want.id), + }); + let mut root = PaneNode::Leaf { pane: *first }; + materialize_splits(workspace, &want.root, &mut root, ops); + if want.name.is_some() { + ops.push(ControlRequest::TabRename { + workspace, + tab: want.id, + name: want.name.clone(), + }); + } + if want.group.is_some() { + ops.push(ControlRequest::TabSetGroup { + workspace, + tab: want.id, + group: want.group.clone(), + }); + } + mirror.tabs.insert( + index.min(mirror.tabs.len()), + TreeTab { + id: want.id, + name: want.name.clone(), + sidebar_group: want.group.clone(), + root, + }, + ); + // A created tab is active on the server; the final active pass corrects + // this when the window says otherwise. + mirror.active = Some(want.id); +} + +/// Turn the single leaf standing where `want` goes into `want`'s whole split +/// structure, top split first — each split replaces the leaf that anchors its +/// left side, exactly as the server's `split_leaf` will. +fn materialize_splits( + workspace: WorkspaceId, + want: &DesiredNode, + root: &mut PaneNode, + ops: &mut Vec, +) { + let DesiredNode::Split { axis, ratio, a, b } = want else { + return; + }; + let (anchor, _) = a.first_leaf(); + let (new, seed) = b.first_leaf(); + ops.push(ControlRequest::PaneSplit { + workspace, + pane: *anchor, + axis: *axis, + ratio: *ratio, + new: seed.clone(), + first: false, + }); + root.split_leaf(*anchor, *new, *axis, *ratio, false); + materialize_splits(workspace, a, root, ops); + materialize_splits(workspace, b, root, ops); +} + +/// Bring one existing tab in line: labels field by field, then the pane tree — +/// by the smallest op that explains the change, or by rebuilding the tab when +/// no single op does (a swap, a multi-pane rearrangement). +fn reconcile_tab( + workspace: WorkspaceId, + mirror: &mut WsMirror, + at: usize, + want: &DesiredTab, + ops: &mut Vec, +) { + { + let tab = &mut mirror.tabs[at]; + if tab.name != want.name { + tab.name = want.name.clone(); + ops.push(ControlRequest::TabRename { + workspace, + tab: want.id, + name: want.name.clone(), + }); + } + if tab.sidebar_group != want.group { + tab.sidebar_group = want.group.clone(); + ops.push(ControlRequest::TabSetGroup { + workspace, + tab: want.id, + group: want.group.clone(), + }); + } + } + + let desired_root = want.root.to_pane_node(); + if mirror.tabs[at].root == desired_root { + return; + } + if same_shape_and_panes(&mirror.tabs[at].root, &desired_root) { + fix_ratios( + workspace, + want.id, + &mut mirror.tabs[at].root, + &desired_root, + ops, + ); + return; + } + + let have = mirror.tabs[at].root.pane_ids(); + let wanted = desired_root.pane_ids(); + let added: Vec = wanted + .iter() + .copied() + .filter(|p| !have.contains(p)) + .collect(); + let removed: Vec = have + .iter() + .copied() + .filter(|p| !wanted.contains(p)) + .collect(); + + let done = match (added.as_slice(), removed.as_slice()) { + // One pane appeared: a split, if it reads as one. + ([new], []) => try_single_split(workspace, mirror, at, want, &desired_root, *new, ops), + // Panes left: close each, then check the shape agrees. + ([], gone) if !gone.is_empty() => { + for pane in gone { + mirror.tabs[at].root.remove_leaf(*pane); + ops.push(ControlRequest::PaneClose { + workspace, + pane: *pane, + }); + } + same_shape_and_panes(&mirror.tabs[at].root, &desired_root) + } + // One pane became another in place: the revival's rebind. + ([new], [old]) => { + let elsewhere = mirror + .tabs + .iter() + .enumerate() + .any(|(i, t)| i != at && t.root.contains(*new)); + let mut predicted = mirror.tabs[at].root.clone(); + predicted.replace_leaf(*old, *new); + if !elsewhere && same_shape_and_panes(&predicted, &desired_root) { + let seed = want + .root + .seed_of(*new) + .expect("the added pane is a desired leaf") + .clone(); + mirror.tabs[at].root = predicted; + ops.push(ControlRequest::PaneReplace { + workspace, + old: *old, + new: seed, + }); + true + } else { + false + } + } + _ => false, + }; + + if done { + fix_ratios( + workspace, + want.id, + &mut mirror.tabs[at].root, + &desired_root, + ops, + ); + return; + } + + // Nothing smaller explains it (a swap, several panes moved at once): + // rebuild the tab whole. The server broadcasts the same class of change as + // one `TabClosed` + `TabCreated`+splits, which mirroring clients apply by + // replacement — the granularity the delta contract already promises. + let closed = mirror.tabs.remove(at); + ops.push(ControlRequest::TabClose { + workspace, + tab: closed.id, + }); + heal_active(mirror, at); + create_tab(workspace, mirror, at, want, ops); +} + +/// One added leaf, read as the split it was: find it in the desired tree, +/// check its sibling side is a leaf the mirror already holds, and check that +/// the tree minus the new leaf is the tree the mirror has. Emits the +/// `PaneSplit` and answers whether it took. +fn try_single_split( + workspace: WorkspaceId, + mirror: &mut WsMirror, + at: usize, + want: &DesiredTab, + desired_root: &PaneNode, + new: u64, + ops: &mut Vec, +) -> bool { + let Some((sibling, axis, ratio, first)) = split_site(desired_root, new) else { + return false; + }; + let mut predicted = mirror.tabs[at].root.clone(); + if !predicted.split_leaf(sibling, new, axis, ratio, first) { + return false; + } + if !same_shape_and_panes(&predicted, desired_root) { + return false; + } + let seed = want + .root + .seed_of(new) + .expect("the added pane is a desired leaf") + .clone(); + mirror.tabs[at].root = predicted; + ops.push(ControlRequest::PaneSplit { + workspace, + pane: sibling, + axis, + ratio, + new: seed, + first, + }); + true +} + +/// Where `new` sits in `node`: the sibling **leaf** it split off from, with the +/// split's parameters. `None` when the sibling side is itself a split — the +/// server's `split_leaf` can only split a leaf, so that shape did not come from +/// one split and the caller falls back to a rebuild. +fn split_site(node: &PaneNode, new: u64) -> Option<(u64, TreeAxis, f32, bool)> { + let PaneNode::Split { axis, ratio, a, b } = node else { + return None; + }; + match (&**a, &**b) { + (PaneNode::Leaf { pane }, sibling) if *pane == new => { + if let PaneNode::Leaf { pane: s } = sibling { + return Some((*s, *axis, *ratio, true)); + } + return None; + } + (sibling, PaneNode::Leaf { pane }) if *pane == new => { + if let PaneNode::Leaf { pane: s } = sibling { + return Some((*s, *axis, *ratio, false)); + } + return None; + } + _ => {} + } + if a.contains(new) { + split_site(a, new) + } else if b.contains(new) { + split_site(b, new) + } else { + None + } +} + +/// Same structure and the same pane at every position, ratios ignored. +fn same_shape_and_panes(a: &PaneNode, b: &PaneNode) -> bool { + match (a, b) { + (PaneNode::Leaf { pane: pa }, PaneNode::Leaf { pane: pb }) => pa == pb, + ( + PaneNode::Split { + axis: ax, + a: aa, + b: ab, + .. + }, + PaneNode::Split { + axis: bx, + a: ba, + b: bb, + .. + }, + ) => ax == bx && same_shape_and_panes(aa, ba) && same_shape_and_panes(ab, bb), + _ => false, + } +} + +/// Walk two same-shaped trees and emit a `PaneSetRatio` per split whose +/// divider moved, updating the mirror side in place. +fn fix_ratios( + workspace: WorkspaceId, + tab: TabId, + mirror: &mut PaneNode, + desired: &PaneNode, + ops: &mut Vec, +) { + fn walk( + workspace: WorkspaceId, + tab: TabId, + mirror: &mut PaneNode, + desired: &PaneNode, + path: &mut Vec, + ops: &mut Vec, + ) { + let ( + PaneNode::Split { + ratio: mr, + a: ma, + b: mb, + .. + }, + PaneNode::Split { + ratio: dr, + a: da, + b: db, + .. + }, + ) = (mirror, desired) + else { + return; + }; + if (*mr - *dr).abs() > 1e-4 { + *mr = *dr; + ops.push(ControlRequest::PaneSetRatio { + workspace, + tab, + path: path.clone(), + ratio: *dr, + }); + } + path.push(Side::A); + walk(workspace, tab, ma, da, path, ops); + path.pop(); + path.push(Side::B); + walk(workspace, tab, mb, db, path, ops); + path.pop(); + } + let mut path = Vec::new(); + walk(workspace, tab, mirror, desired, &mut path, ops); +} + +// --------------------------------------------------------------------------- +// Per-window state, priming, and the op queue +// --------------------------------------------------------------------------- + +/// Where one window's sync stands. +enum SyncPhase { + /// No trustworthy mirror. `dirty` records that the window has state worth + /// pushing once one arrives; `priming` that a pull is in flight. + Unprimed { + dirty: bool, + priming: bool, + }, + Primed(WsMirror), +} + +struct WsState { + sync: SyncPhase, + /// Operations accepted but not yet sent. Drained strictly in order by one + /// in-flight sender at a time — the ops are a serial narrative, and two + /// senders would let a later op overtake the edit it builds on. + queue: VecDeque, + inflight: bool, + /// Whether this window has *seen* the tree — hydrated from it, primed + /// against an empty one, or deliberately declared authoritative (a + /// restore-off open). Until then its diffs run [`SyncScope::Additive`]: + /// a window that opened empty ahead of its pull must not read its own + /// emptiness as "close everything". + informed: bool, + /// Which prime/hydrate cycle the pulls in flight belong to. Bumped by + /// every path that invalidates the mirror (a hydration start, a desync, a + /// preemption); a pull landing under a different number is a pull whose + /// question is obsolete, and its answer is dropped rather than allowed to + /// roll a mirror that has since advanced back to older state. + epoch: u64, +} + +impl Default for WsState { + fn default() -> Self { + WsState { + sync: SyncPhase::Unprimed { + dirty: false, + priming: false, + }, + queue: VecDeque::new(), + inflight: false, + informed: false, + epoch: 0, + } + } +} + +/// Every window's sync state, by the *client's* workspace id. +#[derive(Default)] +pub(crate) struct TreeSync { + windows: HashMap, +} + +impl Global for TreeSync {} + +/// Push this window's current structure to its machine's tree. The single +/// entry point, called from `save_session` — i.e. from every structural change. +pub(crate) fn sync_window(app: &Tty7App, cx: &mut App) { + let client_ws = app.workspace; + // A window built outside the store (headless tests) has no machine to talk + // to; skipping keeps those windows byte-for-byte what they were. + if !cx.has_global::() { + return; + } + // A preempted window is read-only, and that has to hold on the write path + // too: a click on its tab strip would flip the usurper's active tab, and — + // worse — its next save would Full-diff the pre-takeover layout against + // the mirror and roll the usurper's edits back wholesale. Its sync state + // was dropped at preemption ([`on_preempted`]); taking the workspace back + // re-pulls the tree whole. + if crate::ui::remote_workspace::workspace_is_preempted(cx, client_ws) { + return; + } + adopt_tab_ids(app, cx); + let (desired, desired_active, held) = desired_tabs(app, cx); + let machine_ws = tree_workspace_id(cx, client_ws); + + let state = cx + .default_global::() + .windows + .entry(client_ws) + .or_default(); + match &mut state.sync { + SyncPhase::Unprimed { dirty, priming } => { + *dirty = true; + if !*priming { + *priming = true; + start_prime(cx, client_ws); + } + } + SyncPhase::Primed(mirror) => { + let scope = if state.informed { + SyncScope::Full + } else { + SyncScope::Additive + }; + let ops = diff(machine_ws, mirror, &desired, desired_active, scope, &held); + if !ops.is_empty() { + let (tabs, active) = (mirror.tabs.clone(), mirror.active); + state.queue.extend(ops); + // Origin exclusion means this client never hears these ops + // back, so the machine-wide mirror learns them here. + let host = WorkspaceStore::host_of(cx, client_ws); + crate::ui::machine_mirror::MachineMirrors::note_synced_workspace( + cx, host, machine_ws, tabs, active, + ); + pump(cx, client_ws); + } + } + } +} + +/// A control link to `host` just came up (or came back): re-run the sync for +/// every window bound to that machine. +/// +/// This is the retry [`start_prime`]'s unreachable arm leaves behind. A window +/// built while the link was still dialing parks as `Unprimed { dirty }`, and +/// the only other thing that re-enters [`sync_window`] is the *next* +/// structural change — on a first launch that may never come, and a quit +/// before it comes loses the window's layout (the machine never heard of it). +/// The link supervisor calling this on connect is what turns "the reconnect +/// gets there first" from a hope into a mechanism. Harmless for windows that +/// are already synced: their diff is empty and queues nothing. +pub(crate) fn on_link_up(cx: &mut App, host: HostId) { + for (workspace, app) in crate::ui::windows::WindowRegistry::open_windows(cx) { + if WorkspaceStore::host_of(cx, workspace) != host { + continue; + } + if let Some(app) = app.upgrade() { + app.update(cx, |app, cx| sync_window(app, cx)); + } + } +} + +/// Whether `client_ws`'s window has seen its machine's tree (or was declared +/// authoritative). The gate for destructive acts an *empty* window licenses — +/// a window whose hydration has not answered is empty because it is waiting, +/// not because the workspace is, and deleting the workspace on the strength of +/// that emptiness would take a populated tree with it. +pub(crate) fn window_is_informed(cx: &App, client_ws: WorkspaceId) -> bool { + cx.try_global::() + .and_then(|t| t.windows.get(&client_ws)) + .is_some_and(|s| s.informed) +} + +/// Declare that `client_ws`'s window speaks for the whole tree from here on — +/// the deliberate cases (a restore-off open, a window rebuilt from a source +/// the user chose) where the window's state *is* the intended layout. +pub(crate) fn mark_window_informed(cx: &mut App, client_ws: WorkspaceId) { + cx.default_global::() + .windows + .entry(client_ws) + .or_default() + .informed = true; +} + +/// Give GUI tabs that don't yet know their tree identity the mirror's, matched +/// by the panes they hold. This is what keeps a window whose tabs were built +/// before the tree was pulled (any full rebuild) from closing and recreating +/// every daemon tab it already matches. +fn adopt_tab_ids(app: &Tty7App, cx: &App) { + let Some(TreeSync { windows }) = cx.try_global::() else { + return; + }; + let Some(WsState { + sync: SyncPhase::Primed(mirror), + .. + }) = windows.get(&app.workspace) + else { + return; + }; + let known: Vec = app.tabs.iter().map(|t| t.tree_id.get()).collect(); + for tab in &app.tabs { + let id = tab.tree_id.get(); + if mirror.tabs.iter().any(|m| m.id == id) { + continue; + } + let panes: Vec = tab + .pane + .terminals() + .iter() + .map(|v| v.read(cx).pane_id) + .collect(); + if panes.is_empty() { + continue; + } + let Some(matched) = mirror + .tabs + .iter() + .find(|m| !known.contains(&m.id) && panes.iter().any(|p| m.root.contains(*p))) + else { + continue; + }; + tab.tree_id.set(matched.id); + } +} + +/// The workspace was just taken over by another client: drop everything this +/// window's sync believed. +/// +/// The queue and mirror go because they describe edits the usurper is about +/// to invalidate; `informed` goes because it is the licence to prune, and a +/// preempted window's next diff (after take-back re-primes it) must start +/// additive — its stale layout is *not* the whole story any more. Leaving +/// `informed` set was how a taken-back window's first save could still roll +/// the other client's work away. +pub(crate) fn on_preempted(cx: &mut App, client_ws: WorkspaceId) { + let Some(state) = cx.default_global::().windows.get_mut(&client_ws) else { + return; + }; + state.sync = SyncPhase::Unprimed { + dirty: false, + priming: false, + }; + state.queue.clear(); + state.informed = false; + // …and any pull in flight was asked on the lost session's behalf. + state.epoch += 1; +} + +/// Drop a window's sync state — its window is closing or rebinding. The +/// machine's tree keeps the workspace; only this client's bookkeeping goes. +pub(crate) fn forget(cx: &mut App, client_ws: WorkspaceId) { + if let Some(state) = cx.try_global::() { + let _ = state; + cx.default_global::().windows.remove(&client_ws); + } +} + +/// Fire one workspace-level operation (rename, touch, remove) at the machine +/// that owns `client_ws`'s tree. Fire-and-forget: these ops are idempotent +/// label writes with no ordering relationship to the structural queue. +/// +/// Unsent is not the same for all of them, which is what +/// [`unsendable`] is about: a rename or a touch that misses +/// its machine is a cosmetic loss the next one supersedes, while a +/// `WorkspaceRemove` that misses it leaves the workspace — and, after the +/// caller's kills, a set of dead leaves — on a machine no picker here lists any +/// more. That one gets said out loud. +pub(crate) fn fire_workspace_op( + cx: &mut App, + client_ws: WorkspaceId, + op: impl FnOnce(WorkspaceId) -> ControlRequest, +) { + if !cx.has_global::() { + return; + } + let host = WorkspaceStore::host_of(cx, client_ws); + let machine_ws = tree_workspace_id(cx, client_ws); + let request = op(machine_ws); + // The op will not echo back to this client (origin exclusion), so the + // machine-wide mirror folds it in here. + crate::ui::machine_mirror::MachineMirrors::note_workspace_op(cx, host, &request); + let client = match tree_control_for(cx, host) { + TreeLink::Ready(client) => client, + TreeLink::Unserved => { + unsendable( + &request, + "this machine's server does not serve the workspace tree", + ); + return; + } + TreeLink::Down => { + unsendable(&request, "there is no control link to its machine"); + return; + } + }; + cx.background_executor() + .spawn(async move { + if let Err(e) = client.call(request.clone()) { + unsendable(&request, &format!("the machine refused it: {e}")); + } + }) + .detach(); +} + +/// Report a workspace operation that did not reach its machine, at the volume +/// its consequences deserve. +/// +/// A dropped `WorkspaceRemove` is the one with a lasting cost: this client has +/// already forgotten the workspace, so nothing here will ever name it again, and +/// the machine keeps it. Everything else is a label that the next edit resends. +fn unsendable(request: &ControlRequest, why: &str) { + match request { + ControlRequest::WorkspaceRemove { workspace } => log::warn!( + "workspace {workspace} was deleted here but not on its machine ({why}); \ + its entry stays in that machine's tree, where another client will still \ + see it — delete it again from a client that can reach the machine" + ), + other => log::debug!("{other:?} not sent ({why}); the next edit carries it"), + } +} + +/// Set (or clear, with `None`) a workspace's user-chosen name. The name is +/// purely the machine's fact now — its tree is what every picker lists this +/// workspace from — so a rename is one fire-and-forget operation, and the +/// machine-wide mirror picks it up on the way out. +pub(crate) fn rename_workspace(cx: &mut App, client_ws: WorkspaceId, name: Option) { + fire_workspace_op(cx, client_ws, move |ws| ControlRequest::WorkspaceRename { + workspace: ws, + name, + }); +} + +/// Pull the authoritative tree for this workspace (creating it on the machine +/// when it has none), then land it as the mirror. +fn start_prime(cx: &mut App, client_ws: WorkspaceId) { + let host = WorkspaceStore::host_of(cx, client_ws); + let machine_ws = tree_workspace_id(cx, client_ws); + let client = match tree_control_for(cx, host) { + TreeLink::Ready(client) => client, + // Not reachable right now (or reachable but tree-less). Stay dirty; + // the next save retries, and a reconnect-triggered save is what + // usually gets there first. An unserved peer just keeps answering + // this way — the window works locally and nothing round-trips. + unavailable => { + if matches!(unavailable, TreeLink::Unserved) { + log::warn!( + "workspace {client_ws}: its machine's server does not serve the tree; \ + the layout will not be synced" + ); + } + if let Some(state) = cx.default_global::().windows.get_mut(&client_ws) + && let SyncPhase::Unprimed { priming, .. } = &mut state.sync + { + *priming = false; + } + return; + } + }; + let epoch = cx + .default_global::() + .windows + .get(&client_ws) + .map(|s| s.epoch) + .unwrap_or(0); + cx.spawn(async move |cx| { + let outcome = cx + .background_executor() + .spawn(async move { pull_or_create(&client, machine_ws) }) + .await; + cx.update(|cx| finish_prime(cx, client_ws, epoch, outcome)); + }) + .detach(); +} + +/// The blocking half of priming: the workspace's tree, or — when the machine +/// has never heard of it — the freshly created empty workspace. Created +/// nameless: the client keeps no name of its own any more, and the machine +/// derives a display name from the tabs the sync is about to send. +fn pull_or_create(client: &ControlClient, machine_ws: WorkspaceId) -> io::Result { + match client.call(ControlRequest::WorkspaceTree { + workspace: machine_ws, + }) { + Ok(ReplyOk::WorkspaceTree(ws)) => Ok(WsMirror { + tabs: ws.tabs, + active: ws.active_tab, + }), + Ok(other) => Err(io::Error::other(format!( + "WorkspaceTree answered {other:?}" + ))), + Err(e) if e.kind() == io::ErrorKind::NotFound => { + match client.call(ControlRequest::WorkspaceCreate { + name: None, + workspace: Some(machine_ws), + })? { + ReplyOk::WorkspaceTree(ws) => Ok(WsMirror { + tabs: ws.tabs, + active: ws.active_tab, + }), + other => Err(io::Error::other(format!( + "WorkspaceCreate answered {other:?}" + ))), + } + } + Err(e) => Err(e), + } +} + +fn finish_prime(cx: &mut App, client_ws: WorkspaceId, epoch: u64, outcome: io::Result) { + // `get_mut`, never `entry`: a window forgotten while the pull was in + // flight must not be resurrected as orphaned bookkeeping. + let Some(state) = cx.default_global::().windows.get_mut(&client_ws) else { + return; + }; + // Land only into the cycle that asked. A pull outlived by a hydration, a + // desync or a preemption (different epoch) — or by anything that already + // primed the mirror and let it advance — must be dropped, not installed: + // installing would roll the mirror back to the older tree and the next + // diff would faithfully re-emit the rollback as operations. + if state.epoch != epoch || !matches!(state.sync, SyncPhase::Unprimed { priming: true, .. }) { + log::debug!("workspace {client_ws}: dropping a superseded tree pull"); + return; + } + let was_dirty = matches!(state.sync, SyncPhase::Unprimed { dirty: true, .. }); + let landed = match outcome { + Ok(mirror) => { + // An empty tree has nothing an uninformed window could wrongly + // prune, so priming against one is as good as having seen it. + state.informed |= mirror.tabs.is_empty(); + let landed = (mirror.tabs.clone(), mirror.active); + state.sync = SyncPhase::Primed(mirror); + landed + } + Err(e) => { + log::warn!("could not pull the tree for workspace {client_ws}: {e}"); + state.sync = SyncPhase::Unprimed { + dirty: was_dirty, + priming: false, + }; + return; + } + }; + // The pull may have created the workspace on the machine, which this + // client (the writer) hears no delta for. + let host = WorkspaceStore::host_of(cx, client_ws); + let machine_ws = tree_workspace_id(cx, client_ws); + crate::ui::machine_mirror::MachineMirrors::note_synced_workspace( + cx, host, machine_ws, landed.0, landed.1, + ); + if !was_dirty { + return; + } + // The window changed while the pull was in flight; diff it now. + let Some(app) = + crate::ui::windows::WindowRegistry::app_for(cx, client_ws).and_then(|app| app.upgrade()) + else { + return; + }; + app.update(cx, |app, cx| sync_window(app, cx)); +} + +/// Send everything queued, in order, one batch in flight at a time. +fn pump(cx: &mut App, client_ws: WorkspaceId) { + let host = WorkspaceStore::host_of(cx, client_ws); + let client = tree_control_for(cx, host); + let state = cx + .default_global::() + .windows + .entry(client_ws) + .or_default(); + if state.inflight || state.queue.is_empty() { + return; + } + let client = match client { + TreeLink::Ready(client) => client, + TreeLink::Unserved => { + desync(cx, client_ws, "the server does not serve the machine tree"); + return; + } + TreeLink::Down => { + desync(cx, client_ws, "the control link is down"); + return; + } + }; + let batch: Vec = state.queue.drain(..).collect(); + state.inflight = true; + cx.spawn(async move |cx| { + let result = cx + .background_executor() + .spawn(async move { + for op in batch { + if let Err(e) = client.call(op.clone()) { + return Err((op, e)); + } + } + Ok(()) + }) + .await; + cx.update(|cx| { + if let Some(state) = cx.default_global::().windows.get_mut(&client_ws) { + state.inflight = false; + } + match result { + // More may have queued behind this batch. + Ok(()) => pump(cx, client_ws), + Err((op, e)) => { + log::warn!("tree operation {op:?} failed: {e}; re-pulling the tree"); + desync(cx, client_ws, "an operation was refused"); + } + } + }); + }) + .detach(); +} + +/// Prediction and reality disagreed (or the link went): drop what was queued, +/// forget the mirror, and re-pull. The next diff against the fresh pull +/// re-emits exactly the edits that still matter — one recovery path for every +/// failure mode. +fn desync(cx: &mut App, client_ws: WorkspaceId, why: &str) { + log::info!("resynchronizing workspace {client_ws} with its machine ({why})"); + let Some(state) = cx.default_global::().windows.get_mut(&client_ws) else { + return; + }; + state.queue.clear(); + state.inflight = false; + state.sync = SyncPhase::Unprimed { + dirty: true, + priming: true, + }; + // Older pulls in flight were asked against the mirror just discarded; + // bumping the epoch is what keeps their answers from landing over the + // re-pull this desync is about to start. + state.epoch += 1; + start_prime(cx, client_ws); +} + +// --------------------------------------------------------------------------- +// The read path: a window rebuilt from the machine's tree +// --------------------------------------------------------------------------- + +/// One workspace of a pulled [`Machine`], lowered into the `Session` shape the +/// window builder already consumes — the tree's leaves joined with their pane +/// registry records. +/// +/// The lowering *is* the revival decision, made per leaf by the daemon's own +/// liveness fact: a `live` pane keeps its id (the builder re-attaches), a dead +/// one lowers to an id-less leaf carrying the record's cwd, SSH spec and agent +/// resume — exactly the leaf shape that makes the builder spawn a successor. +/// The save that follows then diffs the successor's id against the mirror and +/// sends the `PaneReplace` that spends the old record. +pub(crate) fn session_from_tree( + ws: &tty7_core::core::machine::Workspace, + panes: &[PaneRecord], +) -> Session { + let tabs: Vec = ws + .tabs + .iter() + .map(|tab| SessionTab { + name: tab.name.clone(), + tree_id: Some(tab.id), + sidebar_group: tab.sidebar_group.clone().map(std::path::PathBuf::from), + pane: session_pane_from_node(&tab.root, panes), + }) + .collect(); + let active = ws + .active_tab + .and_then(|id| ws.tabs.iter().position(|t| t.id == id)) + .unwrap_or(0); + Session { active, tabs } +} + +fn session_pane_from_node(node: &PaneNode, panes: &[PaneRecord]) -> SessionPane { + match node { + PaneNode::Leaf { pane } => { + let record = panes.iter().find(|p| p.id == *pane); + let live = record.is_some_and(|r| r.live); + let (cwd, ssh_spec, agent) = match record { + Some(r) => ( + r.cwd.clone().map(std::path::PathBuf::from), + r.ssh_spec.clone(), + r.agent.clone(), + ), + None => (None, None, None), + }; + SessionPane::Leaf { + cwd, + // The daemon's liveness fact is the whole of the revival + // decision: an id is only worth keeping if the daemon holds a + // PTY for it *right now*. + pane_id: live.then_some(*pane), + ssh_spec, + agent: agent.as_ref().map(|a| a.agent), + agent_session_id: agent.as_ref().and_then(|a| a.session_id.clone()), + agent_launch_argv: agent.as_ref().and_then(|a| a.launch_argv.clone()), + } + } + PaneNode::Split { axis, ratio, a, b } => SessionPane::Split { + axis: match axis { + TreeAxis::Horizontal => crate::core::session::SessionAxis::Horizontal, + TreeAxis::Vertical => crate::core::session::SessionAxis::Vertical, + }, + ratio: *ratio, + a: Box::new(session_pane_from_node(a, panes)), + b: Box::new(session_pane_from_node(b, panes)), + }, + } +} + +/// How long an opening window waits for its machine's link before giving up on +/// the pull and staying empty. Generous against a slow daemon start; the local +/// link is normally up within one supervision tick. +const HYDRATE_LINK_DEADLINE: std::time::Duration = std::time::Duration::from_secs(15); +const HYDRATE_LINK_POLL: std::time::Duration = std::time::Duration::from_millis(200); + +/// Fill an (empty) window from the machine's tree: pull `MachineGet`, prime +/// the mirror with the workspace's tabs, and rebuild the window from them — +/// re-attaching live panes, spawning successors for dead ones. +/// +/// The window opens first and this runs behind it, because the pull is a round +/// trip that may have to wait out the link coming up; against the local daemon +/// it lands within milliseconds, so in practice the empty state is one frame. +/// +/// A workspace the machine has never heard of is created, empty. There is no +/// fallback source any more: the client keeps no layout of its own, so what +/// the machine answers is the layout. +pub(crate) fn hydrate_window_from_tree(cx: &mut App, client_ws: WorkspaceId) { + hydrate(cx, client_ws, Adopt::IfEmpty); +} + +/// What a finished pull may do to the window. +#[derive(Clone, Copy, PartialEq)] +enum Adopt { + /// Fill an empty window; a window with tabs wins over the pull (the user + /// got there first). The open/restore path. + IfEmpty, + /// Replace the window's tabs with the pulled tree. The delta-fallback + /// resync, where the window is known to have drifted. + Replace, +} + +fn hydrate(cx: &mut App, client_ws: WorkspaceId, adopt: Adopt) { + let host = WorkspaceStore::host_of(cx, client_ws); + let machine_ws = tree_workspace_id(cx, client_ws); + let epoch = { + let state = cx + .default_global::() + .windows + .entry(client_ws) + .or_default(); + state.sync = SyncPhase::Unprimed { + dirty: false, + priming: true, + }; + // Same contract as `desync`: anything queued was computed against a + // mirror this pull is about to replace, and letting it drain after + // the snapshot would silently diverge the server from it. + state.queue.clear(); + // This hydration owns the cycle from here; older pulls still in + // flight land under the previous number and are dropped. + state.epoch += 1; + state.epoch + }; + cx.spawn(async move |cx| { + // At launch the link is usually still dialing; wait it out briefly + // rather than failing an open the supervisor will fix in a second. A + // peer that is up but does not serve the tree is not waited on at all + // — that answer will not change, and fifteen silent seconds would + // read as a hang rather than as the fact it is. + let deadline = std::time::Instant::now() + HYDRATE_LINK_DEADLINE; + let client = loop { + match cx.update(|cx| tree_control_for(cx, host)) { + TreeLink::Ready(client) => break Some(client), + TreeLink::Unserved => { + log::warn!( + "workspace {client_ws}: its machine's server does not serve the \ + machine tree; opening empty" + ); + break None; + } + TreeLink::Down if std::time::Instant::now() > deadline => { + log::warn!("workspace {client_ws}: no link to its machine; opening empty"); + break None; + } + TreeLink::Down => cx.background_executor().timer(HYDRATE_LINK_POLL).await, + } + }; + let Some(client) = client else { + cx.update(|cx| { + if let Some(state) = cx.default_global::().windows.get_mut(&client_ws) { + if let SyncPhase::Unprimed { priming, .. } = &mut state.sync { + *priming = false; + } + } + }); + return; + }; + let outcome = cx + .background_executor() + .spawn(async move { pull_workspace(&client, machine_ws) }) + .await; + cx.update(|cx| finish_hydration(cx, client_ws, epoch, adopt, outcome)); + }) + .detach(); +} + +/// The blocking half: the whole machine (the tree plus the pane registry — +/// `WorkspaceTree` alone answers structure without the pane facts revival +/// needs), reduced to this workspace's mirror and session. A machine that has +/// no such workspace gets it created, empty. The machine rides along whole so +/// the caller can refresh the machine-wide mirror off a pull it already paid +/// for. +fn pull_workspace( + client: &ControlClient, + machine_ws: WorkspaceId, +) -> io::Result<(Machine, WsMirror, Session)> { + let machine: Machine = match client.call(ControlRequest::MachineGet)? { + ReplyOk::MachineTree(m) => *m, + other => return Err(io::Error::other(format!("MachineGet answered {other:?}"))), + }; + match machine.workspaces.iter().find(|w| w.id == machine_ws) { + Some(ws) => { + let mirror = WsMirror { + tabs: ws.tabs.clone(), + active: ws.active_tab, + }; + let session = session_from_tree(ws, &machine.panes); + Ok((machine, mirror, session)) + } + None => { + client.call(ControlRequest::WorkspaceCreate { + name: None, + workspace: Some(machine_ws), + })?; + Ok((machine, WsMirror::default(), Session::default())) + } + } +} + +fn finish_hydration( + cx: &mut App, + client_ws: WorkspaceId, + epoch: u64, + adopt: Adopt, + outcome: io::Result<(Machine, WsMirror, Session)>, +) { + // A hydration superseded by a newer cycle (another hydration, a desync, a + // preemption) must land nothing — not the mirror, not the window, and not + // the failure bookkeeping, all of which belong to the newer cycle now. + let current = cx + .default_global::() + .windows + .get(&client_ws) + .map(|s| s.epoch); + if current != Some(epoch) { + log::debug!("workspace {client_ws}: dropping a superseded hydration"); + return; + } + let (machine, mirror, session) = match outcome { + Ok(pulled) => pulled, + Err(e) => { + log::warn!("could not hydrate workspace {client_ws} from its machine: {e}"); + if let Some(state) = cx.default_global::().windows.get_mut(&client_ws) + && let SyncPhase::Unprimed { priming, .. } = &mut state.sync + { + *priming = false; + } + return; + } + }; + // The pull is a whole `MachineGet`; the machine-wide mirror gets it free. + let host = WorkspaceStore::host_of(cx, client_ws); + crate::ui::machine_mirror::MachineMirrors::install(cx, host, machine); + let was_dirty = { + // `get_mut`, never `entry` — same reason as `finish_prime`. + let Some(state) = cx.default_global::().windows.get_mut(&client_ws) else { + return; + }; + let dirty = matches!(state.sync, SyncPhase::Unprimed { dirty: true, .. }); + // An empty tree has nothing to adopt and nothing a window could + // wrongly prune, so the window is as informed as it will ever be. A + // non-empty tree informs the window only if the adopt below actually + // runs — see the IfEmpty return. + state.informed |= mirror.tabs.is_empty(); + state.sync = SyncPhase::Primed(mirror); + dirty + }; + let Some(app) = + crate::ui::windows::WindowRegistry::app_for(cx, client_ws).and_then(|app| app.upgrade()) + else { + return; + }; + if adopt == Adopt::IfEmpty && !app.read(cx).tabs.is_empty() { + // The user got there first (opened a tab into the empty window). Their + // tabs win — but they have never seen the tree's, so the window stays + // additive: its edits go up, tabs it never showed stay untouched. + if was_dirty { + app.update(cx, |app, cx| sync_window(app, cx)); + } + return; + } + // An empty pull leaves an empty window empty — with the client's layout + // cache retired there is nothing to import, and the machine answering + // "no tabs" *is* the layout. + if session.tabs.is_empty() && adopt == Adopt::IfEmpty { + if was_dirty + && let Some(app) = + crate::ui::windows::WindowRegistry::app_for(cx, client_ws).and_then(|a| a.upgrade()) + { + app.update(cx, |app, cx| sync_window(app, cx)); + } + return; + } + let Some(handle) = crate::ui::windows::WindowRegistry::window_for(cx, client_ws) else { + return; + }; + log::info!( + "rebuilding {} tab(s) of workspace {client_ws} from its machine's tree", + session.tabs.len() + ); + // The window is about to display the tree (or the import that stands in + // for it); from here its diffs speak for the whole workspace. + mark_window_informed(cx, client_ws); + let _ = handle.update(cx, move |_, window, cx| { + app.update(cx, |app, cx| { + app.adopt_workspace(client_ws, session, window, cx) + }); + }); +} + +// --------------------------------------------------------------------------- +// Incremental deltas: another writer edited a workspace this client shows +// --------------------------------------------------------------------------- + +/// Land one [`LayoutDelta`] pushed by a machine: advance this client's mirror, +/// then the live window showing the workspace, if any. +/// +/// The writer never hears its own operation back (origin exclusion), so every +/// delta arriving here is *another* client's edit — and because application +/// updates the window and the mirror in the same step, the next local diff +/// sees no difference and produces no echo. +/// +/// Anything that will not apply cleanly — a tab the mirror does not know, a +/// window whose state has drifted — falls back to a full re-pull of the +/// workspace and a rebuild, the same recovery every other failure uses. +pub(crate) fn on_layout_delta(cx: &mut App, host: HostId, key: &str, delta: LayoutDelta) { + // The machine-wide mirror hears every delta, windowed workspace or not — + // it is what the picker and the menus read about workspaces no window + // shows. + crate::ui::machine_mirror::MachineMirrors::apply_delta(cx, host, key, &delta); + // The event names the machine's workspace id; translate to the client's. + let client_ws = if host.is_local() { + key.parse::().ok() + } else { + WorkspaceStore::all(cx) + .views + .iter() + .find(|w| { + w.host + .as_ref() + .is_some_and(|r| r.host_id() == host && r.workspace.to_string() == key) + }) + .map(|w| w.id) + }; + let Some(client_ws) = client_ws else { + return; + }; + + // A preempted window is read-only *and must stay passive*: applying a + // structural delta would attach to panes the usurping client just created + // — and one pane has one subscriber, so that steals the active client's + // streams as they work. The mirror goes stale instead, and taking the + // workspace back re-pulls it whole. + if crate::ui::remote_workspace::workspace_is_preempted(cx, client_ws) { + on_preempted(cx, client_ws); + return; + } + + let mirror_ok = match cx + .default_global::() + .windows + .get_mut(&client_ws) + .map(|s| &mut s.sync) + { + Some(SyncPhase::Primed(mirror)) => apply_to_mirror(mirror, &delta), + // No mirror yet: whatever pull is (or will be) in flight already + // answers with a state that includes this delta — so the *window* + // must not apply it either. A `TabCreated` landing in a window whose + // hydration is mid-flight would both duplicate the tab when the + // snapshot arrives and, worse, make `finish_hydration` read the + // no-longer-empty window as "the user got here first" and skip + // adopting the tree at all. + _ => return, + }; + + let Some(app) = + crate::ui::windows::WindowRegistry::app_for(cx, client_ws).and_then(|a| a.upgrade()) + else { + return; + }; + let Some(handle) = crate::ui::windows::WindowRegistry::window_for(cx, client_ws) else { + return; + }; + let window_ok = handle + .update(cx, |_, window, cx| { + app.update(cx, |app, cx| app.apply_layout_delta(&delta, window, cx)) + }) + .unwrap_or(true); + if !mirror_ok || !window_ok { + log::info!( + "workspace {client_ws}: delta {delta:?} did not apply cleanly; re-pulling the tree" + ); + resync_window_from_tree(cx, client_ws); + return; + } + // A clean apply may still have left the window ahead of the tree: adopting + // a tab whose pane was dead on arrival attaches nothing and spawns a fresh + // pane under a *new* id, and nothing else on this path saves — the tree + // would keep the dead leaf until the user's next structural change (and a + // relaunch would spawn a second successor beside the leaked first). One + // sync here is free when window and mirror agree (the diff is empty) and + // is exactly the `PaneReplace` that spends the dead record when they + // don't. + app.update(cx, |app, cx| sync_window(app, cx)); +} + +/// Advance the mirror by one delta. `false` means the delta names state the +/// mirror does not have — the caller re-pulls. +fn apply_to_mirror(mirror: &mut WsMirror, delta: &LayoutDelta) -> bool { + match delta { + // Workspace-level facts carry no tab structure. + LayoutDelta::WorkspaceCreated { .. } + | LayoutDelta::WorkspaceRenamed { .. } + | LayoutDelta::WorkspaceTouched { .. } + | LayoutDelta::WorkspaceDeleted + | LayoutDelta::PaneFacts { .. } => true, + LayoutDelta::ActiveTabChanged { tab } => { + mirror.active = Some(*tab); + true + } + LayoutDelta::TabCreated { at, tab } => { + // A create that straddled a re-pull arrives after the snapshot + // that already carries its tab; replace-by-id, never insert a + // second copy (same rule as the machine-wide mirror's). + mirror.tabs.retain(|t| t.id != tab.id); + let at = (*at).min(mirror.tabs.len()); + mirror.tabs.insert(at, tab.clone()); + true + } + LayoutDelta::TabClosed { tab } => { + let before = mirror.tabs.len(); + mirror.tabs.retain(|t| t.id != *tab); + if mirror.tabs.is_empty() { + mirror.active = None; + } + // The heal, when one happened, arrives as its own + // ActiveTabChanged — the server promises that. + mirror.tabs.len() != before + } + LayoutDelta::TabRenamed { tab, name } => { + let Some(t) = mirror.tabs.iter_mut().find(|t| t.id == *tab) else { + return false; + }; + t.name = name.clone(); + true + } + LayoutDelta::TabRegrouped { tab, group } => { + let Some(t) = mirror.tabs.iter_mut().find(|t| t.id == *tab) else { + return false; + }; + t.sidebar_group = group.clone(); + true + } + LayoutDelta::TabMoved { tab, to } => { + let Some(from) = mirror.tabs.iter().position(|t| t.id == *tab) else { + return false; + }; + let moved = mirror.tabs.remove(from); + mirror.tabs.insert((*to).min(mirror.tabs.len()), moved); + true + } + LayoutDelta::TabRestructured { tab, .. } => { + let Some(t) = mirror.tabs.iter_mut().find(|t| t.id == tab.id) else { + return false; + }; + *t = tab.clone(); + true + } + LayoutDelta::RatioChanged { tab, path, ratio } => { + let Some(t) = mirror.tabs.iter_mut().find(|t| t.id == *tab) else { + return false; + }; + match t.root.descend_mut(path) { + Some(PaneNode::Split { ratio: r, .. }) => { + *r = *ratio; + true + } + _ => false, + } + } + } +} + +/// Re-pull the workspace and rebuild its window from the result, replacing +/// whatever the window holds — the delta fallback. +pub(crate) fn resync_window_from_tree(cx: &mut App, client_ws: WorkspaceId) { + hydrate(cx, client_ws, Adopt::Replace); +} + +impl Tty7App { + /// Apply one delta to this window. `false` when it cannot be applied + /// cleanly, in which case the caller re-pulls and rebuilds. + pub(crate) fn apply_layout_delta( + &mut self, + delta: &LayoutDelta, + window: &mut gpui::Window, + cx: &mut gpui::Context, + ) -> bool { + let index_of = |tabs: &[crate::ui::app::Tab], id: TabId| { + tabs.iter().position(|t| t.tree_id.get() == id) + }; + let applied = match delta { + // Another client naming the workspace needs nothing from the + // window: the chip and the picker read the machine mirror, which + // already applied the delta. + LayoutDelta::WorkspaceCreated { .. } + | LayoutDelta::WorkspaceTouched { .. } + | LayoutDelta::WorkspaceRenamed { .. } + | LayoutDelta::PaneFacts { .. } => true, + // Deleting a workspace someone is looking at does not close their + // window — a window is never closed by remote control. The next + // structural edit here recreates the workspace on the machine. + LayoutDelta::WorkspaceDeleted => { + log::info!( + "workspace {} was deleted on its machine; keeping the window", + self.workspace + ); + true + } + LayoutDelta::ActiveTabChanged { tab } => { + if let Some(index) = index_of(&self.tabs, *tab) { + self.activate_from_delta(index, window, cx); + } + // A tab this window doesn't hold yet: its TabCreated may be a + // spawn still in flight. Not worth a rebuild. + true + } + LayoutDelta::TabCreated { at, tab } => { + self.insert_tab_from_tree((*at).min(self.tabs.len()), tab, window, cx) + } + LayoutDelta::TabClosed { tab } => { + if let Some(index) = index_of(&self.tabs, *tab) { + // The panes' views go; the panes themselves were the + // closing client's to kill. The active tab is tracked by + // identity, or closing a tab to its left would silently + // shift focus one tab over. + let active_id = self.tabs.get(self.active).map(|t| t.tree_id.get()); + self.tabs.remove(index); + self.active = active_id + .and_then(|id| index_of(&self.tabs, id)) + .unwrap_or_else(|| index.min(self.tabs.len().saturating_sub(1))); + self.maximized = None; + self.focus_active(window, cx); + } + true + } + LayoutDelta::TabRenamed { tab, name } => { + if let Some(index) = index_of(&self.tabs, *tab) { + self.tabs[index].name = name.clone(); + } + true + } + LayoutDelta::TabRegrouped { tab, group } => { + if let Some(index) = index_of(&self.tabs, *tab) { + *self.tabs[index].sidebar_group.borrow_mut() = + group.clone().map(std::path::PathBuf::from); + } + true + } + LayoutDelta::TabMoved { tab, to } => { + if let Some(from) = index_of(&self.tabs, *tab) { + let active_id = self.tabs.get(self.active).map(|t| t.tree_id.get()); + let moved = self.tabs.remove(from); + self.tabs.insert((*to).min(self.tabs.len()), moved); + if let Some(id) = active_id + && let Some(index) = index_of(&self.tabs, id) + { + self.active = index; + } + } + true + } + LayoutDelta::TabRestructured { tab, .. } => { + match index_of(&self.tabs, tab.id) { + Some(index) => self.rebuild_tab_from_tree(index, tab, window, cx), + // Restructure of a tab we never built — out of step. + None => false, + } + } + LayoutDelta::RatioChanged { tab, path, ratio } => { + if let Some(index) = index_of(&self.tabs, *tab) { + set_gui_ratio(&mut self.tabs[index].pane, path, *ratio) + } else { + true + } + } + }; + cx.notify(); + applied + } + + /// Activate a tab because a delta said so — the parts of `activate` that + /// move state, without the save that would echo the change back. + fn activate_from_delta( + &mut self, + index: usize, + window: &mut gpui::Window, + cx: &mut gpui::Context, + ) { + if self.active == index { + return; + } + self.maximized = None; + self.active = index; + self.focus_active(window, cx); + } + + /// Build one GUI tab from a tree tab whose panes are all live (they were + /// just created by the writer), attaching each by id. + fn insert_tab_from_tree( + &mut self, + at: usize, + tab: &TreeTab, + window: &mut gpui::Window, + cx: &mut gpui::Context, + ) -> bool { + // Already shown: the delta straddled a pull whose snapshot carried + // this tab, and the rebuild path already displayed it. Building it + // again would not just duplicate the tab — attaching to panes this + // window already streams would steal their single subscription from + // ourselves. + if self.tabs.iter().any(|t| t.tree_id.get() == tab.id) { + return true; + } + let mut existing = HashMap::new(); + let Some(pane) = self.build_pane_from_tree(&tab.root, &mut existing, window, cx) else { + return false; + }; + let gui = crate::ui::app::Tab::from_tree(tab, pane); + self.tabs.insert(at, gui); + if self.active >= at && self.tabs.len() > 1 { + self.active += 1; + } + true + } + + /// Rebuild one tab's pane tree to match the machine's, **reusing** the + /// views of panes the window already shows — re-attaching a pane this + /// window holds would steal its own stream (one pane, one subscriber). + fn rebuild_tab_from_tree( + &mut self, + index: usize, + tab: &TreeTab, + window: &mut gpui::Window, + cx: &mut gpui::Context, + ) -> bool { + let remote = WorkspaceStore::all(cx) + .get(self.workspace) + .is_some_and(|w| w.is_remote()); + let mut existing: HashMap = HashMap::new(); + // Native-SSH leaves in a remote window hold panes in *this* client's + // daemon: they are deliberately absent from the remote machine's tree + // (their ids would collide with unrelated panes there), so the tree + // this tab is rebuilt from cannot mention them. They are kept aside + // and appended back as splits below — dropping their views would + // orphan running local sessions the writer never touched. + let mut ssh_slots: Vec = Vec::new(); + for slot in self.tabs[index].pane.leaves() { + let id = match &slot { + // Matching a native-SSH leaf's *local* id against remote ids + // would rebind the SSH view onto an unrelated remote pane. + PaneSlot::Ready(view) if remote && view.read(cx).ssh_spec().is_some() => { + ssh_slots.push(slot); + continue; + } + PaneSlot::Ready(view) => Some(view.read(cx).pane_id), + PaneSlot::Connecting(pending) => pending.read(cx).spawn.restore_pane, + }; + if let Some(id) = id { + existing.insert(id, slot); + } + } + let Some(pane) = self.build_pane_from_tree(&tab.root, &mut existing, window, cx) else { + return false; + }; + // The ssh leaves' places in the old split geometry are unknowable from + // the delta (the tree never held them), so each comes back as a fresh + // half-and-half split on the right — the shape a split created it in. + let pane = ssh_slots.into_iter().fold(pane, |tree, slot| { + Pane::split_node(gpui::Axis::Horizontal, 0.5, tree, Pane::Leaf(slot)) + }); + let gui = &mut self.tabs[index]; + gui.pane = pane; + gui.name = tab.name.clone(); + *gui.sidebar_group.borrow_mut() = tab.sidebar_group.clone().map(std::path::PathBuf::from); + self.maximized = None; + // Slots left in `existing` belonged to panes the writer removed; their + // views drop with the old tree, and killing the panes was the writer's + // act, not ours. + true + } + + /// Lower a tree node into a GUI pane tree, taking views for known panes + /// from `existing` and attaching to unknown (writer-created) ones by id. + fn build_pane_from_tree( + &self, + node: &PaneNode, + existing: &mut HashMap, + window: &mut gpui::Window, + cx: &mut gpui::Context, + ) -> Option { + match node { + PaneNode::Leaf { pane } => { + if let Some(slot) = existing.remove(pane) { + return Some(Pane::Leaf(slot)); + } + match crate::ui::app::new_terminal( + self.window_workspace(cx), + Some(self.workspace), + self.font_size, + None, + Some(*pane), + None, + window, + cx, + ) { + Ok(slot) => Some(Pane::Leaf(slot)), + Err(e) => { + log::warn!("could not attach pane {pane} from a delta: {e}"); + None + } + } + } + PaneNode::Split { axis, ratio, a, b } => { + let left = self.build_pane_from_tree(a, existing, window, cx); + let right = self.build_pane_from_tree(b, existing, window, cx); + match (left, right) { + (Some(a), Some(b)) => Some(Pane::split_node( + match axis { + TreeAxis::Horizontal => gpui::Axis::Horizontal, + TreeAxis::Vertical => gpui::Axis::Vertical, + }, + *ratio, + a, + b, + )), + (one, other) => one.or(other), + } + } + } + } +} + +/// Follow `path` through the GUI tree and move that split's divider. +fn set_gui_ratio(pane: &mut Pane, path: &[Side], ratio: f32) -> bool { + match path.split_first() { + None => match pane { + Pane::Split { ratio: cell, .. } => { + // The same band the server accepts (`machine::clamp_ratio`). + // Clamping narrower here (0.1–0.9, as this once did) silently + // rewrote another client's 0.07 to 0.1 — and the next save's + // ratio diff then pushed that rewrite back at the machine. + cell.set(ratio.clamp(0.05, 0.95)); + true + } + _ => false, + }, + Some((side, rest)) => match pane { + Pane::Split { a, b, .. } => match side { + Side::A => set_gui_ratio(a, rest, ratio), + Side::B => set_gui_ratio(b, rest, ratio), + }, + _ => false, + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The tree verbs are gated on the handshake's `machine-tree` bit: a + /// connected peer that does not advertise it must classify as + /// [`TreeLink::Unserved`] — the callers' cue to say "this server does not + /// serve the tree" once, instead of paying a refused round trip per + /// operation against a server that will never answer differently. + #[cfg(unix)] + #[test] + fn a_peer_without_the_machine_tree_bit_classifies_as_unserved() { + use tty7_core::daemon::control::ControlHello; + use tty7_core::host::local::LocalHost; + use tty7_core::host::server::{Services, serve_with}; + + let connect = |services: Services| { + let (server, client) = std::os::unix::net::UnixStream::pair().unwrap(); + std::thread::spawn(move || { + let _ = serve_with(server, LocalHost::new(), services); + }); + let hello = ControlHello::host_rpc("test-token", "test-host"); + Arc::new( + tty7_core::daemon::control::ControlClient::over_unix( + client, + &hello, + Box::new(|_| {}), + ) + .unwrap(), + ) + }; + + let treeless = connect(Services::none()); + assert!(matches!( + classify_tree_link(Some(treeless)), + TreeLink::Unserved + )); + + let dir = std::env::temp_dir().join(format!("tty7-treelink-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let store = tty7_core::core::machine::MachineStore::open( + dir.join(tty7_core::core::machine::MACHINE_FILE), + ); + let serving = connect(Services::with_machine(store)); + assert!(matches!( + classify_tree_link(Some(serving)), + TreeLink::Ready(_) + )); + + assert!(matches!(classify_tree_link(None), TreeLink::Down)); + let _ = std::fs::remove_dir_all(&dir); + } + + /// Preemption must leave the window's sync with nothing to say: the + /// queued ops and the mirror describe a session that just lost the + /// workspace, and `informed` is the licence to prune — kept, it would let + /// the taken-back window's first Full diff roll the usurper's edits away. + #[gpui::test] + fn preemption_drops_the_mirror_the_queue_and_the_informed_licence( + cx: &mut gpui::TestAppContext, + ) { + cx.update(|cx| { + let ws = WorkspaceId::new(); + { + let state = cx + .default_global::() + .windows + .entry(ws) + .or_default(); + state.sync = SyncPhase::Primed(WsMirror::default()); + state.informed = true; + state.queue.push_back(ControlRequest::Ping); + } + on_preempted(cx, ws); + let state = &cx.default_global::().windows[&ws]; + assert!(matches!( + state.sync, + SyncPhase::Unprimed { + dirty: false, + priming: false, + } + )); + assert!( + state.queue.is_empty(), + "queued ops belong to the lost session" + ); + assert!( + !state.informed, + "the licence to prune must not survive a takeover" + ); + }); + } + + /// The GUI applies a `RatioChanged` delta in the same band the server + /// accepts (0.05–0.95). A narrower client-side clamp is not cosmetic: it + /// rewrites another client's ratio, and the next save's diff pushes the + /// rewrite back at the machine as an operation. + #[test] + fn a_ratio_delta_is_clamped_to_the_servers_band_not_a_narrower_one() { + let mut pane = Pane::split_node(gpui::Axis::Horizontal, 0.5, Pane::Empty, Pane::Empty); + assert!(set_gui_ratio(&mut pane, &[], 0.07)); + match &pane { + Pane::Split { ratio, .. } => assert_eq!(ratio.get(), 0.07), + _ => unreachable!("built as a split"), + } + // Out-of-band values still land clamped, exactly as the server would. + assert!(set_gui_ratio(&mut pane, &[], 0.01)); + match &pane { + Pane::Split { ratio, .. } => assert_eq!(ratio.get(), 0.05), + _ => unreachable!("built as a split"), + } + } + + /// Same overlap as the machine-wide mirror's: a `TabCreated` that + /// straddled a re-pull arrives after the snapshot that already carries + /// its tab, and must land once. + #[test] + fn a_tab_created_delta_that_straddled_a_repull_lands_once_in_the_window_mirror() { + let mut mirror = WsMirror::default(); + let delta = LayoutDelta::TabCreated { + at: 0, + tab: TreeTab::leaf(1), + }; + assert!(apply_to_mirror(&mut mirror, &delta)); + assert!(apply_to_mirror(&mut mirror, &delta)); + assert_eq!(mirror.tabs.len(), 1); + } + + /// A prime whose pull was outlived by a newer cycle (a hydration, a + /// desync, a preemption) must drop its answer: installing it would roll + /// the mirror back to older state, and the next diff would faithfully + /// re-emit the rollback as operations against the machine. + #[gpui::test] + fn a_superseded_prime_result_does_not_roll_the_mirror_back(cx: &mut gpui::TestAppContext) { + cx.update(|cx| { + let ws = WorkspaceId::new(); + let stale_epoch = { + let state = cx + .default_global::() + .windows + .entry(ws) + .or_default(); + state.sync = SyncPhase::Unprimed { + dirty: false, + priming: true, + }; + state.epoch + }; + // A hydration supersedes the prime and lands a mirror that has + // since advanced by an op. + let advanced = WsMirror { + tabs: vec![TreeTab::leaf(7)], + active: None, + }; + { + let state = cx + .default_global::() + .windows + .get_mut(&ws) + .unwrap(); + state.epoch += 1; + state.sync = SyncPhase::Primed(advanced.clone()); + } + + finish_prime(cx, ws, stale_epoch, Ok(WsMirror::default())); + + match &cx.default_global::().windows[&ws].sync { + SyncPhase::Primed(mirror) => assert_eq!( + *mirror, advanced, + "the stale pull's empty answer must not replace the advanced mirror" + ), + _ => panic!("the mirror was dropped entirely"), + } + }); + } + + fn seed(pane: u64) -> PaneSeed { + PaneSeed { + pane, + cwd: Some(format!("/work/{pane}")), + ssh_spec: None, + agent: None, + } + } + + fn leaf(pane: u64) -> DesiredNode { + DesiredNode::Leaf { + pane, + seed: seed(pane), + } + } + + fn split(axis: TreeAxis, ratio: f32, a: DesiredNode, b: DesiredNode) -> DesiredNode { + DesiredNode::Split { + axis, + ratio, + a: Box::new(a), + b: Box::new(b), + } + } + + fn tab(id: TabId, root: DesiredNode) -> DesiredTab { + DesiredTab { + id, + name: None, + group: None, + root, + } + } + + /// Apply `ops`' effect is already folded into the mirror by `diff`; this + /// asserts the mirror agrees with what the window wanted — the property + /// the whole scheme rests on. + fn assert_converged(mirror: &WsMirror, desired: &[DesiredTab]) { + assert_eq!(mirror.tabs.len(), desired.len()); + for (m, d) in mirror.tabs.iter().zip(desired) { + assert_eq!(m.id, d.id); + assert_eq!(m.name, d.name); + assert_eq!(m.sidebar_group, d.group); + assert_eq!(m.root, d.root.to_pane_node()); + } + } + + #[test] + fn opening_the_first_tab_emits_a_create_carrying_the_client_identity() { + let ws = WorkspaceId::new(); + let id = TabId::new(); + let mut mirror = WsMirror::default(); + let desired = vec![tab(id, leaf(7))]; + + let ops = diff(ws, &mut mirror, &desired, Some(id), SyncScope::Full, &[]); + assert_eq!( + ops, + vec![ControlRequest::TabCreate { + workspace: ws, + at: Some(0), + pane: seed(7), + tab: Some(id), + }], + "a created tab is active on the server, so no separate active op" + ); + assert_converged(&mirror, &desired); + assert_eq!(mirror.active, Some(id)); + } + + #[test] + fn a_split_emits_one_pane_split_against_its_sibling() { + let ws = WorkspaceId::new(); + let id = TabId::new(); + let mut mirror = WsMirror::default(); + let one = vec![tab(id, leaf(1))]; + diff(ws, &mut mirror, &one, Some(id), SyncScope::Full, &[]); + + let two = vec![tab(id, split(TreeAxis::Vertical, 0.5, leaf(1), leaf(2)))]; + let ops = diff(ws, &mut mirror, &two, Some(id), SyncScope::Full, &[]); + assert_eq!( + ops, + vec![ControlRequest::PaneSplit { + workspace: ws, + pane: 1, + axis: TreeAxis::Vertical, + ratio: 0.5, + new: seed(2), + first: false, + }] + ); + assert_converged(&mirror, &two); + } + + #[test] + fn a_new_pane_on_the_upper_side_splits_with_first_set() { + let ws = WorkspaceId::new(); + let id = TabId::new(); + let mut mirror = WsMirror::default(); + diff( + ws, + &mut mirror, + &[tab(id, leaf(1))], + Some(id), + SyncScope::Full, + &[], + ); + + let want = vec![tab(id, split(TreeAxis::Horizontal, 0.4, leaf(2), leaf(1)))]; + let ops = diff(ws, &mut mirror, &want, Some(id), SyncScope::Full, &[]); + assert_eq!( + ops, + vec![ControlRequest::PaneSplit { + workspace: ws, + pane: 1, + axis: TreeAxis::Horizontal, + ratio: 0.4, + new: seed(2), + first: true, + }] + ); + assert_converged(&mirror, &want); + } + + #[test] + fn closing_a_pane_emits_pane_close_and_the_split_collapses() { + let ws = WorkspaceId::new(); + let id = TabId::new(); + let mut mirror = WsMirror::default(); + diff( + ws, + &mut mirror, + &[tab(id, split(TreeAxis::Vertical, 0.5, leaf(1), leaf(2)))], + Some(id), + SyncScope::Full, + &[], + ); + + let want = vec![tab(id, leaf(1))]; + let ops = diff(ws, &mut mirror, &want, Some(id), SyncScope::Full, &[]); + assert_eq!( + ops, + vec![ControlRequest::PaneClose { + workspace: ws, + pane: 2 + }] + ); + assert_converged(&mirror, &want); + } + + #[test] + fn a_revived_leaf_emits_pane_replace_with_the_successors_seed() { + let ws = WorkspaceId::new(); + let id = TabId::new(); + let mut mirror = WsMirror::default(); + diff( + ws, + &mut mirror, + &[tab(id, split(TreeAxis::Vertical, 0.5, leaf(1), leaf(2)))], + Some(id), + SyncScope::Full, + &[], + ); + + let want = vec![tab(id, split(TreeAxis::Vertical, 0.5, leaf(1), leaf(9)))]; + let ops = diff(ws, &mut mirror, &want, Some(id), SyncScope::Full, &[]); + assert_eq!( + ops, + vec![ControlRequest::PaneReplace { + workspace: ws, + old: 2, + new: seed(9), + }] + ); + assert_converged(&mirror, &want); + } + + #[test] + fn a_ratio_drag_emits_set_ratio_with_the_splits_path() { + let ws = WorkspaceId::new(); + let id = TabId::new(); + let mut mirror = WsMirror::default(); + let nested = |r| { + split( + TreeAxis::Vertical, + 0.5, + leaf(1), + split(TreeAxis::Horizontal, r, leaf(2), leaf(3)), + ) + }; + diff( + ws, + &mut mirror, + &[tab(id, nested(0.5))], + Some(id), + SyncScope::Full, + &[], + ); + + let want = vec![tab(id, nested(0.7))]; + let ops = diff(ws, &mut mirror, &want, Some(id), SyncScope::Full, &[]); + assert_eq!( + ops, + vec![ControlRequest::PaneSetRatio { + workspace: ws, + tab: id, + path: vec![Side::B], + ratio: 0.7, + }] + ); + assert_converged(&mirror, &want); + } + + #[test] + fn closing_a_tab_emits_tab_close_and_heals_the_active_tab() { + let ws = WorkspaceId::new(); + let (a, b) = (TabId::new(), TabId::new()); + let mut mirror = WsMirror::default(); + diff( + ws, + &mut mirror, + &[tab(a, leaf(1)), tab(b, leaf(2))], + Some(b), + SyncScope::Full, + &[], + ); + + let want = vec![tab(a, leaf(1))]; + let ops = diff(ws, &mut mirror, &want, None, SyncScope::Full, &[]); + assert_eq!( + ops, + vec![ControlRequest::TabClose { + workspace: ws, + tab: b + }], + "the heal is the server's own rule, so no active op crosses" + ); + assert_converged(&mirror, &want); + assert_eq!(mirror.active, Some(a)); + } + + #[test] + fn a_tab_reorder_emits_moves_that_land_the_windows_order() { + let ws = WorkspaceId::new(); + let (a, b, c) = (TabId::new(), TabId::new(), TabId::new()); + let mut mirror = WsMirror::default(); + let before = [tab(a, leaf(1)), tab(b, leaf(2)), tab(c, leaf(3))]; + diff(ws, &mut mirror, &before, Some(c), SyncScope::Full, &[]); + + let want = vec![tab(c, leaf(3)), tab(a, leaf(1)), tab(b, leaf(2))]; + let ops = diff(ws, &mut mirror, &want, Some(c), SyncScope::Full, &[]); + assert_eq!( + ops, + vec![ControlRequest::TabMove { + workspace: ws, + tab: c, + to: 0 + }] + ); + assert_converged(&mirror, &want); + } + + #[test] + fn renaming_and_regrouping_emit_their_label_ops() { + let ws = WorkspaceId::new(); + let id = TabId::new(); + let mut mirror = WsMirror::default(); + diff( + ws, + &mut mirror, + &[tab(id, leaf(1))], + Some(id), + SyncScope::Full, + &[], + ); + + let mut named = tab(id, leaf(1)); + named.name = Some("build".into()); + named.group = Some("/repo".into()); + let want = vec![named]; + let ops = diff(ws, &mut mirror, &want, Some(id), SyncScope::Full, &[]); + assert_eq!( + ops, + vec![ + ControlRequest::TabRename { + workspace: ws, + tab: id, + name: Some("build".into()), + }, + ControlRequest::TabSetGroup { + workspace: ws, + tab: id, + group: Some("/repo".into()), + }, + ] + ); + assert_converged(&mirror, &want); + } + + #[test] + fn switching_tabs_emits_only_set_active_tab() { + let ws = WorkspaceId::new(); + let (a, b) = (TabId::new(), TabId::new()); + let mut mirror = WsMirror::default(); + let both = [tab(a, leaf(1)), tab(b, leaf(2))]; + diff(ws, &mut mirror, &both, Some(b), SyncScope::Full, &[]); + + let ops = diff(ws, &mut mirror, &both, Some(a), SyncScope::Full, &[]); + assert_eq!( + ops, + vec![ControlRequest::WorkspaceSetActiveTab { + workspace: ws, + tab: a + }] + ); + assert_eq!(mirror.active, Some(a)); + } + + #[test] + fn a_swap_no_single_op_expresses_rebuilds_the_tab_whole() { + let ws = WorkspaceId::new(); + let id = TabId::new(); + let mut mirror = WsMirror::default(); + diff( + ws, + &mut mirror, + &[tab(id, split(TreeAxis::Vertical, 0.5, leaf(1), leaf(2)))], + Some(id), + SyncScope::Full, + &[], + ); + + // The two panes trade places: same panes, same shape, different order. + let want = vec![tab(id, split(TreeAxis::Vertical, 0.5, leaf(2), leaf(1)))]; + let ops = diff(ws, &mut mirror, &want, Some(id), SyncScope::Full, &[]); + assert_eq!( + ops, + vec![ + ControlRequest::TabClose { + workspace: ws, + tab: id + }, + ControlRequest::TabCreate { + workspace: ws, + at: Some(0), + pane: seed(2), + tab: Some(id), + }, + ControlRequest::PaneSplit { + workspace: ws, + pane: 2, + axis: TreeAxis::Vertical, + ratio: 0.5, + new: seed(1), + first: false, + }, + ] + ); + assert_converged(&mirror, &want); + } + + #[test] + fn a_deep_tree_materializes_top_split_first_and_converges() { + let ws = WorkspaceId::new(); + let id = TabId::new(); + let mut mirror = WsMirror::default(); + // ((1 | 2) over (3 | 4)) + let want = vec![tab( + id, + split( + TreeAxis::Horizontal, + 0.6, + split(TreeAxis::Vertical, 0.3, leaf(1), leaf(2)), + split(TreeAxis::Vertical, 0.7, leaf(3), leaf(4)), + ), + )]; + let ops = diff(ws, &mut mirror, &want, Some(id), SyncScope::Full, &[]); + assert_eq!( + ops, + vec![ + ControlRequest::TabCreate { + workspace: ws, + at: Some(0), + pane: seed(1), + tab: Some(id), + }, + ControlRequest::PaneSplit { + workspace: ws, + pane: 1, + axis: TreeAxis::Horizontal, + ratio: 0.6, + new: seed(3), + first: false, + }, + ControlRequest::PaneSplit { + workspace: ws, + pane: 1, + axis: TreeAxis::Vertical, + ratio: 0.3, + new: seed(2), + first: false, + }, + ControlRequest::PaneSplit { + workspace: ws, + pane: 3, + axis: TreeAxis::Vertical, + ratio: 0.7, + new: seed(4), + first: false, + }, + ] + ); + assert_converged(&mirror, &want); + } + + #[test] + fn an_unchanged_window_emits_nothing() { + let ws = WorkspaceId::new(); + let id = TabId::new(); + let mut mirror = WsMirror::default(); + let want = vec![tab(id, split(TreeAxis::Vertical, 0.5, leaf(1), leaf(2)))]; + diff(ws, &mut mirror, &want, Some(id), SyncScope::Full, &[]); + assert_eq!( + diff(ws, &mut mirror, &want, Some(id), SyncScope::Full, &[]), + Vec::new() + ); + } + + #[test] + fn a_tab_whose_panes_are_all_still_spawning_is_held_not_closed() { + let ws = WorkspaceId::new(); + let id = TabId::new(); + let mut mirror = WsMirror::default(); + diff( + ws, + &mut mirror, + &[tab(id, leaf(1))], + Some(id), + SyncScope::Full, + &[], + ); + + // The window's copy of the tab is mid-revival: every leaf is a spawn + // with no pane id yet, so the tab is invisible in `desired` — but it + // is *held*, not gone, and closing it would spend the very record the + // landing spawn's PaneReplace needs. + let ops = diff(ws, &mut mirror, &[], None, SyncScope::Full, &[id]); + assert_eq!(ops, Vec::new()); + assert_eq!(mirror.tabs.len(), 1, "the daemon tab survives the wait"); + } + + #[test] + fn an_additive_diff_never_closes_tabs_the_window_has_not_seen() { + let ws = WorkspaceId::new(); + let (a, b) = (TabId::new(), TabId::new()); + let mut mirror = WsMirror::default(); + diff( + ws, + &mut mirror, + &[tab(a, leaf(1)), tab(b, leaf(2))], + Some(b), + SyncScope::Full, + &[], + ); + + // A window that opened empty ahead of its pull and grew one fresh tab: + // its diff may add that tab, and must touch nothing else — reading its + // ignorance as "close everything" would eat another session's layout. + let fresh = TabId::new(); + let ops = diff( + ws, + &mut mirror, + &[tab(fresh, leaf(9))], + Some(fresh), + SyncScope::Additive, + &[], + ); + assert_eq!( + ops, + vec![ControlRequest::TabCreate { + workspace: ws, + at: Some(2), + pane: seed(9), + tab: Some(fresh), + }], + "appended after the tabs it has not seen; nothing closed or moved" + ); + assert_eq!(mirror.tabs.len(), 3); + } + + #[test] + fn deltas_advance_the_mirror_exactly_as_the_writers_operations_did() { + // Writer A's mirror advances through `diff`; watcher B's advances by + // applying the equivalent deltas. Both must land on the same tree — + // that equality is what lets B mirror A without re-implementing A. + let ws = WorkspaceId::new(); + let id = TabId::new(); + let mut watcher = WsMirror::default(); + + let tree_tab = TreeTab { + id, + name: None, + sidebar_group: None, + root: PaneNode::Leaf { pane: 1 }, + }; + assert!(apply_to_mirror( + &mut watcher, + &LayoutDelta::TabCreated { + at: 0, + tab: tree_tab, + }, + )); + assert!(apply_to_mirror( + &mut watcher, + &LayoutDelta::ActiveTabChanged { tab: id }, + )); + assert!(apply_to_mirror( + &mut watcher, + &LayoutDelta::TabRestructured { + tab: TreeTab { + id, + name: None, + sidebar_group: None, + root: PaneNode::Split { + axis: TreeAxis::Vertical, + ratio: 0.5, + a: Box::new(PaneNode::Leaf { pane: 1 }), + b: Box::new(PaneNode::Leaf { pane: 2 }), + }, + }, + pane: None, + }, + )); + assert!(apply_to_mirror( + &mut watcher, + &LayoutDelta::RatioChanged { + tab: id, + path: Vec::new(), + ratio: 0.7, + }, + )); + + // The writer's own mirror, advanced by the diff for the same edits. + let mut writer = WsMirror::default(); + diff( + ws, + &mut writer, + &[tab(id, leaf(1))], + Some(id), + SyncScope::Full, + &[], + ); + let final_state = vec![tab(id, split(TreeAxis::Vertical, 0.7, leaf(1), leaf(2)))]; + diff( + ws, + &mut writer, + &final_state, + Some(id), + SyncScope::Full, + &[], + ); + + assert_eq!(watcher, writer); + } + + #[test] + fn a_delta_about_a_tab_the_mirror_does_not_hold_reports_itself() { + let mut mirror = WsMirror::default(); + assert!( + !apply_to_mirror( + &mut mirror, + &LayoutDelta::TabRenamed { + tab: TabId::new(), + name: Some("x".into()), + }, + ), + "an unappliable delta must say so, so the caller re-pulls" + ); + assert!(!apply_to_mirror( + &mut mirror, + &LayoutDelta::TabClosed { tab: TabId::new() }, + ),); + } + + #[test] + fn a_live_leaf_keeps_its_pane_id_and_a_dead_one_lowers_to_a_revival_leaf() { + use tty7_core::core::cli_agent::CLIAgent; + let tab_id = TabId::new(); + let ws = tty7_core::core::machine::Workspace { + tabs: vec![TreeTab { + id: tab_id, + name: Some("build".into()), + sidebar_group: Some("/repo".into()), + root: PaneNode::Split { + axis: TreeAxis::Vertical, + ratio: 0.3, + a: Box::new(PaneNode::Leaf { pane: 1 }), + b: Box::new(PaneNode::Leaf { pane: 2 }), + }, + }], + active_tab: Some(tab_id), + ..Default::default() + }; + let panes = vec![ + PaneRecord { + id: 1, + cwd: Some("/work".into()), + live: true, + ..PaneRecord::new(1) + }, + PaneRecord { + id: 2, + cwd: Some("/work/api".into()), + live: false, + agent: Some(AgentFacts { + agent: CLIAgent::Claude, + session_id: Some("sid".into()), + launch_argv: Some(vec!["claude".into()]), + status: None, + }), + ..PaneRecord::new(2) + }, + ]; + + let session = session_from_tree(&ws, &panes); + assert_eq!(session.tabs.len(), 1); + assert_eq!(session.active, 0); + let tab = &session.tabs[0]; + assert_eq!( + tab.tree_id, + Some(tab_id), + "the daemon tab's identity rides along" + ); + assert_eq!(tab.name.as_deref(), Some("build")); + let SessionPane::Split { ratio, a, b, .. } = &tab.pane else { + panic!("the split survives the lowering"); + }; + assert!((ratio - 0.3).abs() < 1e-6); + match &**a { + SessionPane::Leaf { pane_id, cwd, .. } => { + assert_eq!(*pane_id, Some(1), "a live pane re-attaches by its id"); + assert_eq!(cwd.as_deref(), Some(std::path::Path::new("/work"))); + } + _ => panic!("leaf"), + } + match &**b { + SessionPane::Leaf { + pane_id, + cwd, + agent, + agent_session_id, + .. + } => { + assert_eq!( + *pane_id, None, + "a dead pane's leaf takes the fresh-spawn path — that is the revival" + ); + assert_eq!(cwd.as_deref(), Some(std::path::Path::new("/work/api"))); + assert_eq!(*agent, Some(CLIAgent::Claude)); + assert_eq!(agent_session_id.as_deref(), Some("sid")); + } + _ => panic!("leaf"), + } + } + + #[test] + fn a_dangling_active_tab_in_the_pulled_tree_falls_back_to_the_first() { + let ws = tty7_core::core::machine::Workspace { + tabs: vec![TreeTab { + id: TabId::new(), + name: None, + sidebar_group: None, + root: PaneNode::Leaf { pane: 1 }, + }], + active_tab: Some(TabId::new()), + ..Default::default() + }; + assert_eq!(session_from_tree(&ws, &[]).active, 0); + } + + #[test] + fn a_pane_id_reused_in_another_tab_is_never_read_as_a_replace() { + let ws = WorkspaceId::new(); + let (a, b) = (TabId::new(), TabId::new()); + let mut mirror = WsMirror::default(); + diff( + ws, + &mut mirror, + &[tab(a, leaf(1)), tab(b, leaf(2))], + Some(b), + SyncScope::Full, + &[], + ); + + // Tab a now claims pane 2 (which tab b still holds) instead of pane 1 + // — a corrupt window state. `PaneReplace` would be refused by the + // server (pane 2 is elsewhere in the tree), so the diff must not + // choose it; the rebuild path handles it, and the server refusing + // *that* too (duplicate pane) desyncs into a fresh pull. + let want = vec![tab(a, leaf(2)), tab(b, leaf(2))]; + let ops = diff(ws, &mut mirror, &want, Some(b), SyncScope::Full, &[]); + assert!( + !ops.iter() + .any(|op| matches!(op, ControlRequest::PaneReplace { .. })), + "got {ops:?}" + ); + } +} diff --git a/src/ui/windows.rs b/src/ui/windows.rs index f56e231d..8946fee8 100644 --- a/src/ui/windows.rs +++ b/src/ui/windows.rs @@ -97,6 +97,22 @@ impl WindowRegistry { .or_else(|| registry.windows.first().map(|w| w.workspace)) } + /// The `Tty7App` rendered in `window`, if it is one of ours. + /// + /// For code that runs *inside* a window (an element's event handler) but + /// has no line to the app entity — the inverse lookup of + /// [`window_for`](Self::window_for), keyed by the handle instead of the + /// workspace. + pub fn app_in(cx: &mut App, window: &Window) -> Option> { + Self::sweep(cx); + let handle = window.window_handle(); + cx.global::() + .windows + .iter() + .find(|w| w.handle == handle) + .and_then(|w| w.app.upgrade()) + } + /// The `Tty7App` showing `workspace`, if one is open. pub fn app_for(cx: &mut App, workspace: WorkspaceId) -> Option> { Self::sweep(cx); @@ -163,8 +179,8 @@ impl WindowRegistry { } /// 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. +/// window is opening on a freshly minted workspace — a known one opens empty +/// and is filled from its machine's tree. #[derive(Clone, Copy, PartialEq, Eq)] pub enum FreshStart { /// A single default terminal, the way every previous launch of tty7 came @@ -255,8 +271,8 @@ pub const MENU_SLOTS: usize = 9; /// 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(); + let mut open: Vec<_> = all.views.iter().filter(|w| w.open).collect(); + let mut closed: Vec<_> = all.views.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() @@ -288,6 +304,11 @@ pub struct PaneCountQuery { } /// Read the inputs for [`live_pane_count`]. Cheap; UI thread only. +/// +/// `None` when the workspace's machine has never been pulled this session — +/// the ids to count live only in its tree, and a prompt about to state "N +/// running sessions will be ended" must say it could not ask rather than +/// count against a guess. pub fn pane_count_query(cx: &App, workspace: WorkspaceId) -> Option { let ws = WorkspaceStore::all(cx).get(workspace)?; Some(PaneCountQuery { @@ -296,7 +317,7 @@ pub fn pane_count_query(cx: &App, workspace: WorkspaceId) -> Option Vec { + WorkspaceStore::all(cx) + .get(workspace) + .and_then(|ws| crate::ui::machine_mirror::pane_ids(cx, ws)) + .unwrap_or_default() } -fn stop_workspace_keeping(cx: &mut App, workspace: WorkspaceId, cleared: ClearedLayout) { +fn stop_workspace_keeping(cx: &mut App, workspace: WorkspaceId, ids: Vec) { // A remote workspace's panes live on the remote server, and its pane ids are // *that* daemon's. Sending them here would not fail — it would succeed // against whatever local panes happen to hold those numbers, killing a @@ -482,10 +503,6 @@ fn stop_workspace_keeping(cx: &mut App, workspace: WorkspaceId, cleared: Cleared .get(workspace) .map(|w| w.host_id()) .unwrap_or(crate::ui::host_ops::HostId::LOCAL); - let ids = WorkspaceStore::all(cx) - .get(workspace) - .map(|ws| ws.pane_ids()) - .unwrap_or_default(); if !ids.is_empty() { // Off the UI thread: each of these dials `route`, and on a remote // workspace that is an SSH channel per pane. Stopping a four-pane @@ -524,98 +541,39 @@ fn stop_workspace_keeping(cx: &mut App, workspace: WorkspaceId, cleared: Cleared // half-finished action. close_window_for(cx, workspace); WorkspaceStore::close_window(cx, workspace); - // Last, and after the window is gone so nothing records the old layout back - // over it: the ids we just killed are dead by our own hand, and a record - // that still claims them reopens into panes that cannot be attached to. - // Locally that is invisible (`alive_panes_on` asks the daemon and gets the - // same answer); on a remote workspace nobody asks, so the stale id is the - // whole difference between reopening onto fresh shells with the agent - // conversation resumed and reopening onto `tty7 — disconnected`. - forget_killed_panes(cx, workspace, cleared); + // No client-side bookkeeping about the panes remains to correct: the kills + // above end the PTYs, the machine's own pane server observes each death, + // and the tree's records flip to `live: false` — exactly the state the + // next open reads as "revive with a fresh shell". refresh_menu(cx); } -/// Drop `workspace`'s pane ids, and tell the machine that owns the record. -/// -/// The push is not optional for a remote workspace that is being kept: design -/// The remote's `workspaces.json` is the authority, so reopening pulls -/// its copy over the client's ([`WorkspaceStore::apply_remote`]) and a -/// local-only edit would be undone by the next open — which is the open this -/// exists for. -fn forget_killed_panes(cx: &mut App, workspace: WorkspaceId, cleared: ClearedLayout) { - if !WorkspaceStore::forget_pane_ids(cx, workspace) { - return; - } - if cleared == ClearedLayout::Discard { - return; - } - let Some((host, key, record)) = WorkspaceStore::remote_payload(cx, workspace) else { - return; - }; - let Some(connection) = crate::ui::remote_workspace::connection_for(cx, workspace) else { - // Not connected, so the panes were not killed either — `kill_pane_on` - // needs the same route. The client's copy is still worth clearing: it - // is what a reconnect pushes back up. - log::info!( - "ended sessions on {} without reaching it; the cleared layout goes up on reconnect", - host.target - ); - return; - }; - cx.background_executor() - .spawn(async move { - if let Err(e) = crate::ui::remote_connect::put_remote_layout(&connection, key, record) { - log::warn!( - "could not tell {} its workspace's panes are gone: {e}", - host.target - ); - } - }) - .detach(); -} - /// 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) { - // Delete it on the machine that owns it first, while the pointer to it is - // still on file. Doing this after `WorkspaceStore::remove` would leave the - // record stranded on the remote with no way left to name it. - delete_on_remote(cx, workspace); - // …and the stop that follows must not push the emptied layout back up: the - // delete above is in flight on a background task, and a push landing after - // it would recreate the record it just removed. - stop_workspace_keeping(cx, workspace, ClearedLayout::Discard); + let doomed = delete_from_tree(cx, workspace); + stop_workspace_keeping(cx, workspace, doomed); WorkspaceStore::remove(cx, workspace); release_unused_hosts(cx); refresh_menu(cx); } -/// Forget a remote workspace on the machine that owns it (the -/// remote's `workspaces.json` is the authority, so deleting only the client's -/// pointer would leave the workspace there and reappear on the next connect). +/// The tree half of a delete, in the one order that works: read the kill list +/// off the machine mirror **before** firing `WorkspaceRemove`, because firing +/// folds the removal into that mirror on the way out and the list read +/// afterwards is empty — which is how "N running sessions will be ended" once +/// ended zero. Answers the panes the caller must kill. /// -/// A no-op for a local workspace, and for a remote one this client is not -/// currently connected to — there is no way to reach the record, and the delete -/// is a user action rather than something to queue and replay later. -fn delete_on_remote(cx: &mut App, workspace: WorkspaceId) { - let Some(host) = WorkspaceStore::remote_ref(cx, workspace) else { - return; - }; - let Some(connection) = crate::ui::remote_workspace::connection_for(cx, workspace) else { - log::info!( - "deleting the local pointer to a workspace on {} without reaching it", - host.target - ); - return; - }; - let key = host.store_key(); - cx.background_executor() - .spawn(async move { - if let Err(e) = crate::ui::remote_connect::delete_remote_workspace(&connection, key) { - log::warn!("could not delete the workspace on {}: {e}", host.target); - } - }) - .detach(); +/// The op itself still goes before `WorkspaceStore::remove`: the tree is where +/// every other client (and the next launch) lists workspaces from, and firing +/// after the entry is gone would leave it stranded with no way to name it. +fn delete_from_tree(cx: &mut App, workspace: WorkspaceId) -> Vec { + let doomed = doomed_pane_ids(cx, workspace); + crate::ui::tree_sync::fire_workspace_op(cx, workspace, |ws| { + tty7_core::daemon::control::ControlRequest::WorkspaceRemove { workspace: ws } + }); + crate::ui::tree_sync::forget(cx, workspace); + doomed } /// Drop the connection to any machine no workspace points at any more. @@ -625,14 +583,14 @@ fn delete_on_remote(cx: &mut App, workspace: WorkspaceId) { /// careful would tear down a live sibling window's host mid-call. fn release_unused_hosts(cx: &mut App) { let live: Vec<_> = WorkspaceStore::all(cx) - .workspaces + .views .iter() .filter(|w| w.is_remote()) .map(|w| w.host_id()) .collect(); for id in crate::ui::host_registry::HostRegistry::ids(cx) { if !id.is_local() && !live.contains(&id) { - crate::ui::remote_connect::RemoteConnections::remove(cx, id); + crate::ui::remote_connect::HostLinks::remove(cx, id); } } } @@ -657,11 +615,11 @@ fn close_window_for(cx: &mut App, workspace: WorkspaceId) { return; } - let (fresh, session) = WorkspaceStore::claim(cx, None); + let fresh = 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) + app.adopt_workspace(fresh, crate::core::session::Session::default(), window, cx) }); }); } @@ -845,4 +803,54 @@ mod tests { ); } } + + /// The regression the delete order guards against: `WorkspaceRemove` is + /// folded into the machine mirror synchronously on its way out, so a kill + /// list read *after* firing it is always empty — the confirm prompt said + /// "3 running sessions will be ended" and the delete then ended none. + /// `delete_from_tree` must hand back the panes the mirror listed before + /// the removal blanked it. + #[gpui::test] + fn a_delete_reads_its_kill_list_before_the_removal_blanks_the_mirror( + cx: &mut gpui::TestAppContext, + ) { + use crate::core::session::{WindowView, WindowViews}; + use tty7_core::core::machine::{Machine, PaneRecord, Tab, Workspace as TreeWorkspace}; + + cx.update(|cx| { + let view = WindowView::default(); + let id = view.id; + WorkspaceStore::install_for_test( + cx, + WindowViews { + views: vec![view], + active: None, + }, + ); + crate::ui::machine_mirror::MachineMirrors::install( + cx, + crate::ui::host_ops::HostId::LOCAL, + Machine { + workspaces: vec![TreeWorkspace { + id, + tabs: vec![Tab::leaf(1), Tab::leaf(2), Tab::leaf(3)], + ..TreeWorkspace::default() + }], + panes: vec![PaneRecord::new(1), PaneRecord::new(2), PaneRecord::new(3)], + }, + ); + + let doomed = delete_from_tree(cx, id); + assert_eq!( + doomed, + vec![1, 2, 3], + "every session the confirm prompt counted must be on the kill list" + ); + assert!( + doomed_pane_ids(cx, id).is_empty(), + "the removal has been folded into the mirror — which is exactly why \ + the list must be read first" + ); + }); + } }