From 183b5e4950420b6e0a92b00123923c4efdc1701a Mon Sep 17 00:00:00 2001 From: ayamir <61657399+ayamir@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:31:30 +0800 Subject: [PATCH 1/2] fix(terminal): detect links across wrapped and hard-newline rows (#258) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(terminal): detect links across wrapped and hard-newline rows A URL that spans terminal rows was truncated at the first row edge, so Cmd-hover underline and Cmd-click only saw the first line. Resolve links over the joined logical line instead: soft-wrapped rows were already stitched, and a new hard-wrap bridge joins rows a program split with a literal newline when the row is full to the edge with a link char that continues into the next row. Double-click smart-select keeps its word boundaries and never bridges. HoveredLink now spans grid points across rows and the underline paints every covered cell. * fix(terminal): never bridge a hard newline into a URL authority The hard-wrap bridge joins two rows when the first is filled to the right edge with a link char and the next opens with one. A hard newline carries no signal about whether the producer split a URL, so a *complete* URL ending exactly at the right edge is bridged onto the next row's first token (`.../a` + `README.md` resolves as `.../aREADME.md`). That false positive is accepted: the head of a genuinely split URL is itself a valid URL, so there is no reliable test to tell the two apart, and the address bar shows the mistake. The same accident promoting the *second* row to the authority is not acceptable. `https://good.com` + `@evil.com/x` parses as userinfo per RFC 3986, so the real host becomes `evil.com` while the hover underline still reads `good.com` — a phishing hop wearing a trusted label. Refuse to bridge into a `@`. Soft wraps are unaffected: there the terminal folded one logical line, so the continuation is certain and a userinfo URL must still resolve whole. Also drop a dead `c != ' '` guard (`is_url_char(' ')` is already false) and apply rustfmt, which CI enforces as a required check. --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> --- src/terminal/element.rs | 35 ++++++--- src/terminal/search.rs | 2 +- src/terminal/smart_select.rs | 136 ++++++++++++++++++++++++++++++-- src/terminal/view.rs | 147 +++++++++++++---------------------- 4 files changed, 211 insertions(+), 109 deletions(-) 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/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..d292cf08 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -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 { @@ -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 @@ -8279,9 +8243,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.)); From a4972d32d8a9e8d612b30fbb0661b358ae053950 Mon Sep 17 00:00:00 2001 From: l0ng-ai Date: Thu, 30 Jul 2026 12:25:38 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat(core):=20daemon-owned=20workspace=20tr?= =?UTF-8?q?ee=20=E2=80=94=20semantic=20ops,=20incremental=20deltas,=20thin?= =?UTF-8?q?=20clients=20(#260)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(daemon): share one run_daemon between tty7 and tty7-server Extract the control-listener-plus-pane-server startup from tty7-server into tty7_core::daemon::server::run_daemon, and point both binaries at it. The local daemon now serves the control dialect exactly like a remote one: one machine = one daemon, whichever binary happens to be running it. The bound control socket (and a bind failure) is still reported on stderr with the historical 'tty7-server:' prefix — a headless server's log file is off by default, and the remote_router test reads that exact line back to prove the client derivation and the server bind agree. * feat(core): daemon-owned machine tree with semantic operations Add core::machine: the workspace/tab/pane tree a machine's daemon owns outright, replacing the client-owned-schema model of the opaque record store. Leaves hold a pane id and nothing else; every fact about a pane (cwd from OSC 7, title, ssh spec, agent identity) lives once in the pane registry, which is what makes revival sound: a reopened store force-clears every live flag, so after a daemon restart the tree itself says every leaf is awaiting revival — no client-side instance stamps or id-reuse heuristics required. Operations (workspace create/rename/delete/touch/set-active-tab, tab create/close/rename/move/regroup, pane split/close/set-ratio/move/ replace) validate against the held tree, persist atomically, roll back on a failed write, and broadcast incremental LayoutDelta events with origin exclusion so a writer never hears its own echo. Persisted to machine.json beside the old store's file, serde with #[serde(default)] throughout so the daemon can keep evolving the schema, corrupt files quarantined instead of overwritten. * feat(control): machine-tree verbs and incremental Layout deltas Teach the control dialect the semantic operations the machine tree serves: MachineGet / WorkspaceTree pulls, WorkspaceCreate / Rename / Remove / Touch / SetActiveTab, TabCreate / Close / Rename / Move / SetGroup, and PaneSplit / Close / SetRatio / Move / Replace. Replies carry the daemon's own tree types (a created workspace or tab comes back whole; close operations answer the pane ids that left the tree so the caller can kill their PTYs), and every operation broadcasts a ControlEvent::Layout delta to every connection but the writer's — the same origin-exclusion mechanism the record store uses, one delta at a time instead of whole-record last-writer-wins. The server advertises a new 'machine-tree' capability bit only when it actually carries a MachineStore; both daemons now do, alongside the retired opaque record store, which keeps serving unchanged while clients migrate. Delta fan-out rides its own bounded queue and forwarder thread per connection, so a peer that stopped reading stalls nobody's edit; the drop-on-overflow tradeoff is documented against the keepalive that reaps such a peer and the full pull every reconnect starts with. The request/reply/event enums lose their Eq derive: split ratios are f32. End-to-end tests drive the shipped tty7-server binary over real pipes: capability advertisement, tree ops landing in the server's own file, dead-pane revival across a real process restart, and delta delivery between two live clients. * feat(daemon): pane facts flow from the pane server into the machine tree The tree's pane records are only worth reviving from if they hold what the machine itself observed, so the pane server now publishes into the MachineStore the daemon serves: the reader thread reports OSC 7 / probed cwd changes and the sniffer's agent facts (identity, native session id, launch argv, coarse status) after each chunk that changed them, and DeathReporter::report flips the record to live == false however the death was noticed — that flag is the client-visible 'awaiting revival' state, and it now comes from the process that owns the PTYs on the very event, not only from the next restart. The store rides a process-wide slot (installed by control_services, same shape as the control event observer) so the three pane-spawn paths need not thread it through; without one installed, observing is a no-op, which keeps unit tests and tree-less servers quiet. Facts are published outside the pane state lock and only on a real change, so the reader's hot path pays two clones and a compare. AgentFacts.status tightens from a free string to the existing AgentStatus enum while no wire client depends on it. * feat(ui): hold a supervised control link to the local daemon The GUI now dials this machine's own daemon over the control dialect, exactly as it does a remote one: one machine, one daemon, one control link. The link lives in its own global rather than RemoteConnections — inserting it there would register a wire-backed Host for this machine (local files and git must keep going through the in-process LocalHost) and would break the HostId::LOCAL-never-holds-a-control-connection invariant. No routing either: the daemon's control socket is right here, so connecting is a Unix connect plus a ControlHello. Supervised on its own forever loop at the remote pump's cadence, because that pump deliberately parks when the last remote workspace closes and a purely local session is the common case. Each turn also drains the shared control-event queue, so local pushes (Layout deltas, Preempted) are delivered under HostId::LOCAL even with the remote pump stopped; the observer install is shared with the remote supervisor so whichever comes up first, reader threads never find nobody listening. Reconnects ride the same 1/2/4/…/30s backoff a remote machine gets, with ensure_running first — the daemon is the GUI's own child, and a cold start legitimately races its listener. Unix-only like the control listener it dials; on Windows the loop compiles to a supervision no-op and the pane path is untouched. * feat(control): attachment and takeover ride the machine tree too WorkspaceAttach / WorkspaceDetach (and the hello-names-a-workspace shorthand) now record their data half on whichever workspace stores the server carries: the retired record store, the machine tree, or — on a full daemon while clients migrate — both, since they describe the same workspace. The behavioural contract is untouched and now survives the record store's retirement: newcomer always wins, the displaced session is pushed Preempted (and closed only when its link was dedicated), and a preempted session's tidy-up detach cannot evict the usurper — the token check lives in the tree's runtime-only attachment exactly as it did in the store's. A server carrying neither store answers the same refusal a store-less server always has. WorkspaceId gains FromStr (the inverse of its Display) because the attach verbs predate the typed tree and carry the id as a string. The end-to-end test drives a takeover on a server serving the tree and no record store at all, asserting the tree's own attachment record moves with it. * fix(core): review hardening for the machine-tree foundation Findings from a correctness review of the new daemon-owned tree, applied together: - A dead pane can no longer be resurrected in the tree by its own last output. On Windows the exit monitor reports the death while the reader is still draining ConPTY's buffered bytes, and the death report is latched; the reader's 'output is proof of life' publish now asserts liveness only while the pane state still says alive. - Delta delivery is ordered. Mutations were serialized by the state lock but delivered after releasing it, so one writer's deltas could overtake another's and leave every mirroring client on the losing state with no cue to re-pull. A notify-order mutex now spans each mutation and its own fan-out; cheap, because subscriber callbacks are enqueue-only by contract. - Implicit active-tab changes broadcast. tab_create's activation and the close paths' heal now emit ActiveTabChanged, so a client applying deltas never re-implements the server's heal rule; the one inexpressible case (no tabs) needs no delta because it is a fact, not surgery. - The coarse agent status no longer drives disk writes: it flips per hook event and is display-only, so it is outside the changed-facts gate and merely rides along when a load-bearing fact changes. - control_services reports which stores it serves on stderr again — tty7-server configures no log sink, and 'no machine tree' was invisible exactly where it matters, on a headless box. - The local link's first connect attempt is immediate instead of one backoff step late; the observation-slot test withdraws its store so it cannot swallow later tests' observations; and locked()'s poison rationale now says what is actually guaranteed. * feat(control): let clients mint workspace and tab identities on create A window names its workspace — in the registry, the view file, and any operation it queues — before its first round trip completes, and the same holds for a tab the moment the user opens it. Making the daemon the only minter would force every client to hold its edits until a reply carried the real id back. Ids are uuids, so a client-minted one is as unique as a daemon-minted one; WorkspaceCreate and TabCreate now carry an optional client id, keep it when it is free, and refuse a duplicate rather than adopt it. Absent (older callers, tests) the daemon mints as before. * feat(ui): windows speak semantic tree operations for every structural change The write path of the client migration: each window now keeps a mirror of what the daemon's tree holds for its workspace, and save_session — the funnel every structural change already passes through — diffs the window against that mirror and sends the recovered operations (TabCreate, PaneSplit, PaneClose, PaneReplace, TabMove, ratio and label ops) over the workspace's control link: the LocalLink for this machine, the machine's RemoteConnections entry otherwise. Consecutive saves differ by exactly one user action, so the diff recovers that action rather than re-shipping the layout; changes no single op expresses rebuild the affected tab whole, matching the delta contract's own granularity. The mirror advances by running the server's own tree surgery (PaneNode's split/remove/replace are public now), and any disagreement — a refused op, a dropped link — resolves by one shared recovery path: drop the queue, re-pull WorkspaceTree, re-diff. Fresh spawns are invisible until their pane id lands; land_pane's save is when their create goes out. GUI tabs carry a client-minted TabId, and a primed mirror re-points tabs it recognizes by their panes, so a rebuilt window adopts the daemon's tabs instead of churning them. Workspace-level facts ride along: focus touches, renames, and deletions now reach the machine's tree too, and the divider drag finally persists the ratio it lands on (it previously reached disk only as a passenger on the next structural change). session.json is still written in parallel; it retires with the read-path migration. * feat(ui): local windows restore by asking the daemon's tree The read path: opening a known local workspace no longer rebuilds from session.json synchronously. The window opens empty and a background pull (MachineGet — the workspace's structure joined with the pane registry, which is where the revival facts live) rebuilds it the moment the daemon answers; against the local daemon that is milliseconds, so the empty state is effectively one frame — the same shape a remote workspace's connect-driven rebuild has always had. The lowering from tree to window is the revival decision: a leaf whose pane record says live re-attaches by id, a dead one lowers to an id-less leaf carrying the record's cwd, SSH spec and agent resume — the exact shape that makes the existing builder spawn a successor and type the agent's --resume. The save that follows diffs the successor against the mirror and sends PaneReplace, spending the old record; revival needed no op code of its own. Restored tabs keep their daemon tab ids (SessionTab grows a never- persisted tree_id), so the first save addresses the daemon's tabs instead of churning them. A tree with nothing for the workspace falls back once to the client's cached layout, whose adoption re-populates the tree through the ordinary diff — the whole of the best-effort import. * feat(ui): live windows apply the machine's incremental layout deltas The pump's event drain now lands ControlEvent::Layout instead of debug- logging it: each delta advances this client's mirror (by the same surgery the server ran) and then the live window showing the workspace — renames, regrouping, moves, active-tab changes and ratio drags in place; TabCreated by building the tab and attaching its (writer-spawned, so live) panes; TabRestructured by rebuilding the one tab while reusing the views of panes the window already shows, because re-attaching a pane this window holds would steal its own stream. Origin exclusion means every delta arriving is another client's edit, and applying it to window and mirror in one step leaves the next local diff with nothing to echo. A delta that will not apply cleanly — a tab the mirror never heard of, a drifted window — falls back to re-pulling the workspace and rebuilding the window from the authoritative tree, the same single recovery path every other failure already uses. * feat(daemon): report panes the machine tree no longer references With the tree now populated by clients' semantic operations, the daemon can finally see panes nothing references. A periodic sweep reports them — log-only, deliberately: an unreferenced pane is not proof of a leak (a native-SSH pane opened inside a remote workspace's window runs in this daemon while belonging to the other machine's tree), and reclaiming one wrongly kills a session the user is looking at. The sweep's interval doubles as a grace period: a pane is reported only after being unreferenced across two consecutive looks, so an adoption still in flight is never flagged. Reclamation can be layered on once the log has shown the false-positive rate is zero. * feat(ui): remote workspaces read and write the machine tree like local ones Local and remote are now the same shape end to end. A remote workspace opens empty unconditionally (connected or not) and is filled by the same tree hydration a local window uses; the connect supervisor's landing replaces the opaque-record refresh with it — a blinked link relinks the pane streams and hydrates whatever opened empty meanwhile, a replaced server process resyncs the window from the tree, whose force-cleared live flags are what make every leaf revive. The remote picker lists workspaces from MachineGet, deriving names from the tree the way a local workspace derives its own; creating one lets the hydration's WorkspaceCreate mint it on the machine; the record push, pull, refresh (WorkspaceChanged) and remote delete paths are gone client-side. Windows that have not yet seen their machine's tree sync additively: a window that opened empty ahead of its pull may add tabs but never prunes ones it has not displayed, so its ignorance can no longer read as 'close everything' — the diff takes an explicit scope, and only hydration (or a deliberately authoritative open, like restore-off) grants the full one. * refactor(core): retire the client-side pane-identity defenses The machine tree made this whole family unnecessary, so it goes rather than lingers: daemon_instance stamps (a restarted daemon's tree says live=false about every pane — a fact, where the stamp was a heuristic), forget_stale_pane_ids on both layers, dedupe_pane_ids (the daemon refuses a pane appearing twice in its tree, so there is no duplicate to mop up client-side), the claim/record instance plumbing, and the whole-record halves of the storage split (to_remote_json, apply_remote_json, REMOTE_OWNED_FIELDS, CLIENT_OWNED_FIELDS, and the store's apply_remote / remote_payload), together with their tests. forget_pane_ids stays for now: it clears the client's cached copy, which still serves as the one-time import fallback until the view file slims down to pure view state. * refactor(ui): a local daemon restart rebuilds from the tree too The tree file survives the restart and the fresh daemon force-clears every pane's live flag, so the resync path already expresses exactly what the hand-rolled saved-session rebuild did: every leaf revives as a fresh shell in its recorded cwd with its agent resumed. The pull waits out the local link reconnecting to the fresh daemon. * docs(core): drop a stale reference to the retired record verbs * fix(ui): close the review findings on the tree migration Review fixes, worst first: - Pane ids never alias across daemon restarts: the pane registry seeds its counter past everything the persisted tree references. A fresh process minting from 1 handed new shells ids that dead leaves still claimed — the tree marked the wrong pane live, revival stalled forever on 'already part of this machine's tree', and an attach by the stale id stole another workspace's stream. Ids are names now, not slots. - An empty window only licenses WorkspaceRemove once it is *informed*: a window whose hydration has not answered is empty because it is waiting, and closing or swapping it mid-pull was deleting populated trees. Remote workspaces also hydrate regardless of the restore setting — their panes are running sessions, not a saved layout, and the restore-off swap used to open them empty-and-authoritative and close every tab on the machine. - Tabs whose panes are all still spawning are *held*, not pruned: they are invisible in the desired tree without being absent, and the Full diff was closing them (spending the records the landing spawns' PaneReplace needed) on every remote revival. - A preempted window stays passive under deltas: applying the usurper's TabCreated/TabRestructured attached to their fresh panes and stole the streams they were typing into. The mirror is dropped instead; taking the workspace back re-pulls it whole. - Delta TabClosed tracks the active tab by identity (closing a tab to the left no longer shifts focus and pushes the wrong active tab back). - The hydrate/resync path drops the op queue like desync does, so ops computed against an abandoned mirror cannot drain after the snapshot. - A rebuilt remote tab no longer matches a native-SSH leaf's *local* pane id against remote ids; delta-applied ratios clamp to the GUI band; async completions use get_mut so a forgotten window's sync state is not resurrected. * feat(ui): a per-machine mirror of each daemon's tree feeds the read surfaces The switcher, the Window menu, the title bar, the rename seeds, the stop/delete confirmation and the liveness sweep all answered their questions (display name, subject path, pane ids, pane count) from the client's cached copy of the layout. The machine's tree owns the layout now, so a new per-host MachineMirrors global holds each machine's last pulled tree — filled by a MachineGet whenever a control link comes up (and for free off every hydration, which already pulls the whole machine), advanced by the same Layout delta stream the windows consume, plus explicit notes for this client's own operations, which origin exclusion keeps out of that stream. The readers move over wholesale. A machine not pulled yet reads as not-knowing rather than a stale guess: pickers show the shared fallback for a beat (against the local daemon the pull lands within a frame), and the pane-count prompt says the machine could not be asked instead of counting against a cache. tree_display_name moves out of the remote picker into the mirror as display_name_of — it was always the tree flavour of Workspace::display_name, and now everything shares it. This is the read-model half of retiring the client's layout cache; the persistence shrink to pure view state follows on top of it. * refactor(ui): client persistence shrinks to pure window views The client file stops carrying layout. session.json's Workspace — id, name, a whole embedded Session, geometry, open, last_active, host — becomes WindowView { id, window, open, last_active, host } in a fresh views.json (no migration by design; an old session.json is simply ignored, and its panes revive from the machine tree like any daemon restart). Everything the embedded layout used to answer already moved to the per-machine mirror, so this deletes the write half: - WorkspaceStore::claim answers only the id; record shrinks to record_geometry. claimable_session / record_session — the reachability-gated layout cache — go entirely, and with them the one-time empty-tree import in finish_hydration: with no cached copy there is nothing to import, and the machine answering "no tabs" is the layout. - The user-set name is purely the machine's fact now. rename / rename_locally leave the store; the chip and switcher renames fire WorkspaceRename directly (tree_sync::rename_workspace), the WorkspaceRenamed delta needs nothing from the window because the mirror already applied it, and WorkspaceCreate seeds no name. - forget_pane_ids / blank_pane_ids and the layout-derived getters (display_name, dominant_repo, first_cwd, pane_count, pane_ids) are deleted with their tests — each had grown a mirror-side twin. - switch_workspace always hydrates: with the tree as the only layout source, restore-off governs what launch comes back to, not what a deliberate switcher pick shows. The retired opaque record store loses its one test that asserted its file parses as a client Workspaces document — that coupling is the thing this migration ends, and the store itself is next to go. * refactor(server): retire the opaque workspace record store Clients stopped sending WorkspaceList/Get/Put/Delete when the tree migration landed, so the coexistence scaffolding comes out: - core::workspace_store is deleted. Attachment and the data-directory resolution (TTY7_DATA_DIR, XDG fallback chain) move into core::machine, which was already their only consumer; Attachment loses its vestigial serde derives (it never crosses disk or wire). - The control dialect drops the four record verbs, the ReplyOk::Json payload they answered with, and the WorkspaceChanged event. Their serde names (and the workspace-store capability bit) are recorded as burned rather than reserved by any mechanism — the dialect has no numbered slots to hold, so a comment at each site is the guard, plus the handshake test asserting the bit never reappears. - host::server loses Services.workspaces, the verb arms, the per-connection store subscription and its WorkspaceChanged forwarder, and the store half of attach/detach/teardown. Attachment data now lives solely in the tree: a workspace the tree does not list records no data half (the registry's live handles still move, so takeover behaviour is unchanged), and it appears the moment the workspace does. Services::with_workspaces/and_machine collapse into with_machine; control_services becomes a single match. - The attach/takeover tests move onto MachineStore wholesale, attaching to workspaces created in a real tree; the record-store round-trip and fan-out tests go (tests/machine_tree.rs has carried the tree equivalents since the verbs landed), and tests/workspace_store.rs is deleted with the serde_json dev-dependency that existed only for it. machine.rs gains the two guarantees the old suite held uniquely: an attachment dies with its workspace structurally, and the default path resolution ends at the documented file. - The GUI's dead WorkspaceChanged arm and every stale doc reference go. * refactor(ui): rename RemoteConnections to HostLinks Purely mechanical, plus the doc sentences that carry the model: the table holds one control link per machine, and the local machine is a machine like any other — its link just lives in its own global (LocalLink) because it is in-process rather than wire-backed. The old name framed the table as remote-only plumbing, which the tree migration made false in spirit: local and remote windows speak the same operations over whichever link their machine answers on. * fix(ui): a tree-driven tab rebuild keeps the native-SSH split it cannot name A native-SSH pane opened inside a remote workspace's window runs in this client's own daemon and is deliberately absent from the remote machine's tree (its local id would collide with an unrelated remote pane). The TabRestructured rebuild therefore had no leaf for it and dropped its view on the floor: the local session kept running, invisible from every surface — a true orphan only the daemon's log-only sweep would ever mention. The rebuild now sets such leaves aside while harvesting reusable views and appends each back as a fresh half-and-half split on the right once the tree's own panes are built. The old split geometry is unknowable from the delta (the tree never held it), so the appended shape is the one a split created it in; the next save changes nothing, because the diff already lowers a remote window without its ssh leaves. The resync path (a delta that fails to apply, a replaced server) still rebuilds the whole window from the tree and drops such views — that path discards every view it has by design, and is left as a known residual. TerminalView grows a test-only ssh-marked pane constructor so the kept-split property is pinned by a gpui test. * docs(core): finish pointing the last session.json references at views.json * fix(ui): kick every local window's sync when the local link comes up A window built while the local control link was still dialing parks as Unprimed { dirty } — start_prime's unreachable arm leaves the retry to "the reconnect-triggered save", but the local link supervisor never triggered one. On a first launch (window built before the auto-spawned daemon binds its socket) nothing else re-enters sync_window until the next structural change, so quitting before one loses the window's layout: the machine never heard of it. Reproduced end-to-end on a scratch daemon: fresh launch, no user action, quit — the relaunch came up empty. With the link supervisor calling tree_sync::on_link_up on connect, the same launch syncs the tree within one pump tick. * fix(ui): read a deleted workspace's kill list before the removal blanks the mirror delete_workspace fired WorkspaceRemove first, and fire_workspace_op folds the removal into the machine mirror synchronously on its way out — so the kill list stop_workspace_keeping then read off that mirror was always empty, and 'Delete Workspace' ended zero of the sessions its confirm prompt promised to end. The kill list is now read before the op fires, and both destructive paths receive it explicitly so the ordering is a signature rather than a convention. * fix(control): bump both dialect versions and gate tree verbs on the machine-tree bit The tree migration deleted four control verbs and added seventeen, but CONTROL_VERSION stayed at 2 — two builds that cannot understand each other's requests would have shaken hands as equals. It is now 3, with the history entry the file's format asks for. PROTOCOL_VERSION moves to 4 for the service change underneath: a pre-tree 'tty7 --daemon' has no control listener at all, so a GUI from this build silently adopting one connects its control link into the void forever and every window hydrates from a tree that never answers. The bump routes that meeting into ensure_running's existing keep-or-restart prompt. Clients now also consume the machine-tree capability bit before any tree traffic: a connected peer without it (a server with no home directory keeps serving files and panes) classifies as a distinct 'unserved' state that is logged once and skipped, instead of a refused round trip per operation. * fix(ui): preempted windows stay passive and take-back rebuilds from the tree Two halves of the same takeover contract were broken. A preempted window kept pushing: sync_window had no preemption check, so a click on the read-only tab strip sent WorkspaceSetActiveTab against the usurper's session, and the next save Full-diffed the stale layout — rolling the usurper's edits back wholesale. sync_window now returns early for a preempted workspace, and preemption itself drops the window's queue, mirror and 'informed' licence (tree_sync::on_preempted, shared with the delta path's existing reset). Take Back never rebuilt: the recovery attach ran the ordinary IfEmpty hydration, which skips any non-empty window — and a preempted window is by definition non-empty with the pre-takeover layout. retry_now now marks the workspace as reclaiming, and finish_attempt rebuilds marked (or still-preempted) windows via Adopt::Replace, honouring the 'take back re-pulls whole' promise the delta path documents. * fix(ui): delta application survives pulls in flight Three overlap bugs between the incremental delta stream and the full pulls it has no ordering barrier with: - A TabCreated straddling a pull was applied by both — the snapshot already carried the tab, and the delta inserted a second copy into the machine mirror and the window mirror, and rebuilt a second GUI tab whose attach stole the pane's single stream from the window itself. All three application sites now replace by id. - A delta arriving while a window's prime/hydration was in flight was applied to the window even though the mirror side skipped it — a TabCreated landing in a still-empty window made finish_hydration read 'the user got here first' and skip adopting the tree, leaving the window with only the concurrently-created tab forever. Window application is now gated on the mirror being primed; the pull's snapshot carries the delta's effect. - A prime answered after a newer cycle (hydration, desync, preemption) replaced it would install its stale tree over a mirror that had since advanced, and the next diff would re-emit the rollback as operations. Every cycle now stamps an epoch, and pulls landing under an old one are dropped. * fix(ui): apply ratio deltas in the server's clamp band set_gui_ratio clamped to 0.1-0.9 while the server accepts 0.05-0.95, so another client's 0.07 arrived as 0.1 — and the next save's ratio diff pushed the rewrite back at the machine, silently moving their divider. * fix(core): machine-store hardening around seeds and unreadable files - A PaneSeed entered the registry live:true unconditionally. A pane that died between its spawn and its adopting operation had its death observation dropped (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 was never offered. The daemon now installs a liveness probe on the store (registry-backed), consulted at registration; without one (tests, clients) the seed is trusted. - seed_ids_past computed max + 1, which panics a debug daemon at startup when the persisted tree names u64::MAX. saturating_add parks the counter at the ceiling instead. - load_machine quarantined an unparseable file but not an unreadable one: a read failure logged, started empty, and the first mutation overwrote the very file that could not be read. Read failures now quarantine too — by rename, since a copy would need the read permission that just failed. Also de-flakes the pre-existing spawn_writer test: the first write into a freshly-closed socket can succeed before the kernel processes the close, so the poll loop now keeps the writer fed until a write fails. * feat(control): announce dropped layout deltas so lagged clients resync A connection whose per-link delta queue overflowed lost an edit it will never hear again — the server logged the drop, and the client mirrored a tree it was no longer looking at until something else happened to fail. The subscriber callback now flags the connection lagged, and the layout forwarder sends the new ControlEvent::LayoutResync ahead of the next delta it delivers (the flag is only ever set with a full queue behind it, so the announcement never waits on a quiet tree). The client answers by re-pulling the machine mirror and resyncing every window on that machine — the same recovery an unappliable delta already uses, announced instead of stumbled into. WatchOverflow is the precedent. * fix(ui): a pure native-SSH tab is invisible to the tree, not held forever Held means 'spawns are landing, wait before ordering' — but a remote window's tab that is native-SSH through and through can never land: its panes live in this client's daemon and are deliberately unnameable in the remote machine's tree. Filing it as held made 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 existed — and a mixed tab whose last remote pane was closed kept its dead leaf on the machine for ever, because the held id shielded the daemon tab from the close. Such tabs are now classified permanently invisible: not desired, not held. Ordering resumes, and the mixed tab's daemon twin closes when its last tree-visible pane goes. Pending leaves (a connecting spawn, an empty slot) still read as held. * docs(core): drop the dead instance helper, the stale title field, and two doc lies - local_daemon_instance() lost its last caller when the client-side pane-identity defenses were retired; deleted. - DaemonVersion::instance's doc pointed at Workspace::daemon_instance (deleted with the record store) and claimed pane ids restart from 1 — no longer true of a tree-carrying daemon, which seeds its ids past everything the tree names. Rewritten to describe what the field actually backs now. - PaneRecord::title claimed to label panes awaiting revival, but no code ever wrote it: the pane's title is a live foreground-process query at PaneInfo time, not state the facts path observes. The field is deleted (serde-compatible: unknown fields are ignored on read) and the decision recorded where it lived; revival labels derive from cwd and agent. * fix(ui): converge the tree after adopting a delta-created tab Adopting a TabCreated delta whose pane is dead on arrival attaches nothing and spawns a fresh pane under a new id — and nothing on the delta path saved afterwards, so the tree kept the dead leaf: other clients saw a dead tab, and a relaunch would spawn a second successor beside the leaked first. Reproduced end-to-end (external client creates a tab with an unspawned pane; the GUI adopted it and the tree never learned the successor's id). One sync_window after a clean apply closes it: free when window and mirror agree (the diff is empty), and exactly the PaneReplace that spends the dead record when adoption had to spawn. * fix(core): review follow-ups on the daemon-owned tree Nine findings from a review pass over the branch. One commit because they cross the same files, and splitting them would leave an intermediate that does not build on Windows. - A dropped delta announced a LayoutResync and then delivered the backlog behind it. The queue is FIFO, so everything still in it is *older* than the gap: the peer re-pulled on the notice and was then walked back through history it had already left — TabRestructured restoring the shape a tab used to have, with window and mirror agreeing on the stale answer so nothing recovered a second time. The forwarder now drops the superseded queue and sends the resync in its place. - Pane facts persisted the whole document, with an fsync, from the PTY reader thread — once per OSC 7, so once per prompt per pane — while holding the lock that orders every other client's edits. A shell looping over directories was a write per iteration. Observations (pane facts, workspace_touch) now take Persist::Soon: the delta still goes out at once, the file catches up within FACT_FLUSH_INTERVAL, and the daemon flushes on the way out. The layout itself is never deferred. - An ordinary output chunk paid two AgentFacts clones and a clone-to-compare for facts it could not have changed. Gated on the signals that can move one, and the compare no longer clones. - machine.json was created 0644, naming every workspace's directories, the SSH user and host of every native-SSH pane, and each agent's session id. It is written owner-only from the first instant the final name exists, and a second corruption no longer overwrites the rescue copy of the first. - Windows had no control listener, so on the one platform where the tree is the only layout store, tabs did not come back at all. It now serves the dialect over the transport its pane socket already uses: a loopback listener whose port and 256-bit token live in a user-private control.port beside daemon.port — its own token, not the pane endpoint's — refusing to rebind over a live one, since binding is what writes the marker. run_daemon and the GUI's local link are one code path again. - Workspace names and paths came only from the machine's mirror, so a laptop shut since Friday listed every row as "Untitled" with a blank subtitle, in the picker whose whole job is offering workspaces on machines that are asleep. WindowView carries the label and subject the machine last gave, stamped on save and on detach; the tree still wins whenever it answers. - liveness_of read "the mirror has not been pulled yet" as Stopped, which tells the user their sessions are gone on the strength of our own ignorance. Unknown is what that state is for. - A WorkspaceRemove that never reached its machine was a debug line, though the client had already forgotten the workspace. It is now a warning that says what was left where. - MachineMirrors::install landed a pull without a repaint; the two tests the record store's retirement took with it (a closed connection stops being a subscriber, concurrent connections can all write) are back against the tree; and CHANGELOG records the migration's one-time layout loss and the Windows gap this closes. Suites green: tty7-core 675, tty7 819, tty7-server 9/5/3/3/51, fmt and clippy clean. The Windows listener is unverified by a compiler here — a C dependency in the tree blocks cross-checking from macOS — so CI's Windows job is its first build. --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Co-authored-by: thomas --- CHANGELOG.md | 28 + Cargo.lock | 1 - crates/tty7-core/src/core/config.rs | 37 +- crates/tty7-core/src/core/machine.rs | 2649 +++++++++++++++ crates/tty7-core/src/core/mod.rs | 2 +- crates/tty7-core/src/core/session.rs | 1510 +-------- crates/tty7-core/src/core/window_state.rs | 8 +- crates/tty7-core/src/core/workspace_store.rs | 1191 ------- crates/tty7-core/src/daemon/control.rs | 282 +- crates/tty7-core/src/daemon/pane.rs | 176 + crates/tty7-core/src/daemon/protocol.rs | 41 +- crates/tty7-core/src/daemon/router.rs | 2 +- crates/tty7-core/src/daemon/server.rs | 257 +- crates/tty7-core/src/daemon/spawn.rs | 11 - crates/tty7-core/src/daemon/transport.rs | 154 +- crates/tty7-core/src/host/remote.rs | 2 +- crates/tty7-core/src/host/server.rs | 1110 +++--- crates/tty7-server/Cargo.toml | 5 - crates/tty7-server/src/main.rs | 59 +- crates/tty7-server/tests/machine_tree.rs | 522 +++ crates/tty7-server/tests/stdio_conformance.rs | 2 +- crates/tty7-server/tests/workspace_store.rs | 543 --- src/core/session.rs | 836 +---- src/core/update.rs | 2 +- src/core/window_state.rs | 9 +- src/main.rs | 27 +- src/terminal/pane_liveness.rs | 37 +- src/terminal/view.rs | 37 +- src/ui/app.rs | 391 ++- src/ui/hints.rs | 2 +- src/ui/home.rs | 7 + src/ui/local_link.rs | 216 ++ src/ui/machine_mirror.rs | 643 ++++ src/ui/mod.rs | 3 + src/ui/pane.rs | 13 +- src/ui/remote_connect.rs | 209 +- src/ui/remote_workspace.rs | 632 ++-- src/ui/switcher.rs | 43 +- src/ui/tab_strip.rs | 4 +- src/ui/theme.rs | 9 +- src/ui/tree_sync.rs | 2967 +++++++++++++++++ src/ui/windows.rs | 222 +- 42 files changed, 9694 insertions(+), 5207 deletions(-) create mode 100644 crates/tty7-core/src/core/machine.rs delete mode 100644 crates/tty7-core/src/core/workspace_store.rs create mode 100644 crates/tty7-server/tests/machine_tree.rs delete mode 100644 crates/tty7-server/tests/workspace_store.rs create mode 100644 src/ui/local_link.rs create mode 100644 src/ui/machine_mirror.rs create mode 100644 src/ui/tree_sync.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index c1745700..f466ab7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,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 abcd940c..f8758fbf 100644 --- a/crates/tty7-core/src/core/config.rs +++ b/crates/tty7-core/src/core/config.rs @@ -951,7 +951,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)) } @@ -976,8 +976,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 @@ -989,7 +1011,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 55e62a78..9ac4fc7f 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. /// @@ -184,10 +198,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). @@ -209,6 +233,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 // --------------------------------------------------------------------------- @@ -220,7 +251,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 --------------------------------------------------------- @@ -328,18 +360,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 @@ -363,6 +387,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 { @@ -398,13 +542,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), } } @@ -425,7 +585,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), @@ -446,7 +606,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, @@ -465,8 +625,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, @@ -474,6 +632,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. @@ -580,7 +749,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 @@ -615,9 +784,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. @@ -625,7 +820,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 @@ -826,7 +1021,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 { @@ -933,7 +1128,7 @@ impl ControlClientMsg { } /// A control frame travelling server → client. -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq)] pub enum ControlServerMsg { HelloOk(ControlHelloOk), Response { @@ -1065,7 +1260,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, @@ -1821,13 +2016,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() }, ] } @@ -1874,7 +2062,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, @@ -1922,7 +2109,6 @@ mod tests { workspace: "w1".into(), by: "other-laptop".into(), }, - ControlEvent::WorkspaceChanged { id: "w1".into() }, ] } @@ -2532,16 +2718,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(), @@ -2580,7 +2756,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 f7ec17db..42677b29 100644 --- a/crates/tty7-core/src/host/remote.rs +++ b/crates/tty7-core/src/host/remote.rs @@ -162,7 +162,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 3d66ff98..d91f1b6d 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, 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, LinkShutdown, ReplyOk, WATCH_BURST_CAP, WireError, @@ -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, @@ -403,8 +410,8 @@ where deferred_watches: Mutex::new(HashMap::new()), next_watch: AtomicU64::new(1), 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 { @@ -430,7 +437,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(); @@ -439,7 +446,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(); @@ -491,17 +498,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 { @@ -533,7 +540,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 @@ -549,17 +556,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); @@ -595,14 +615,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) } @@ -829,50 +856,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 { @@ -887,6 +870,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()) + } }) } @@ -927,11 +1061,11 @@ struct Conn { deferred_watches: Mutex>)>>, next_watch: AtomicU64, 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, @@ -943,30 +1077,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); + } } } @@ -1022,7 +1153,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 @@ -1124,45 +1255,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}"); } } @@ -1595,10 +1776,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 { @@ -1654,6 +1834,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)] @@ -1927,10 +2241,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() } // ----------------------------------------------------------------------- @@ -2411,18 +2742,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}"); } // ----------------------------------------------------------------------- @@ -2857,29 +3187,11 @@ 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, - }) - } - /// 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 } @@ -2897,256 +3209,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:?}" ); } @@ -3156,7 +3284,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:?}"), + } } // ----------------------------------------------------------------------- @@ -3174,13 +3359,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 @@ -3188,21 +3374,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 @@ -3210,7 +3396,7 @@ mod tests { let (reply, _) = round_trip( &mut desktop, 1, - ControlRequest::WorkspaceAttach { id: "w".into() }, + ControlRequest::WorkspaceAttach { id: w.clone() }, ); assert_eq!( reply, @@ -3226,11 +3412,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 @@ -3243,59 +3430,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. @@ -3308,28 +3487,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" ); @@ -3342,16 +3523,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, @@ -3363,11 +3546,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(), }) ); @@ -3376,11 +3559,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()) ); } @@ -3392,7 +3575,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(), @@ -3400,30 +3585,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 @@ -3432,22 +3620,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/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/view.rs b/src/terminal/view.rs index d292cf08..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; @@ -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 @@ -8017,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() { @@ -8075,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 — @@ -9658,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 233bb2e3..565494fa 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, @@ -893,30 +920,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 @@ -1386,12 +1442,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 @@ -1413,34 +1469,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 @@ -1457,20 +1495,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); @@ -1602,16 +1652,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 @@ -1695,6 +1764,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; @@ -1935,27 +2005,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}"); @@ -3057,8 +3117,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; @@ -4352,10 +4412,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( @@ -4381,7 +4439,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); @@ -5836,7 +5894,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))) } @@ -7123,6 +7181,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, } } @@ -7260,7 +7322,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 @@ -7326,6 +7388,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 @@ -7500,7 +7569,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, @@ -8471,6 +8540,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; @@ -8592,7 +8803,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}; @@ -8683,7 +8894,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(), }, @@ -8693,8 +8904,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 b3951904..6e58c4ad 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" + ); + }); + } }