From 9552098d09f21e86785a1f89a2b6695b0b5fd454 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 9 Sep 2026 23:41:43 -0700 Subject: [PATCH 01/36] Route every sidecar event to the window that owns its PTY The sidecar serves one process, so a second window would have seen every other window's terminal output. Rust now keeps the PTY-to-window map, minted only in `pty_spawn`, and every stdout line passes through a pure `route()` before it is emitted: terminal traffic to its owner, a `pty:list` to the window that asked, a `dor` request naming an unowned Surface to an error rather than a sibling, everything else broadcast. The same state carries the transfer suppression (with a fail-open sweep), the focus order the drag hit test uses as a z-order stand-in, and a torn-out window's pulled boot payload. The quit machine becomes vote-then-walk over per-window state, and a close is now this window's alone unless it is the last one. `main` is a real label in `tauri.conf.json`, `ws-*` windows share the default capability, and `main-only.json` keeps the updater grants where the walk tears down last. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe --- standalone/scripts/tauri-conf.test.mjs | 27 +- .../src-tauri/capabilities/default.json | 6 +- .../src-tauri/capabilities/main-only.json | 9 + standalone/src-tauri/src/lib.rs | 1163 +++++++++++++++-- standalone/src-tauri/src/quit_state.rs | 433 ++++++ standalone/src-tauri/src/routing.rs | 464 +++++++ standalone/src-tauri/tauri.conf.json | 1 + standalone/src/main.tsx | 9 +- standalone/src/quit.test.ts | 2 +- standalone/src/quit.ts | 2 +- standalone/src/tauri-adapter.ts | 17 +- standalone/src/window-label.ts | 40 + 12 files changed, 2032 insertions(+), 141 deletions(-) create mode 100644 standalone/src-tauri/capabilities/main-only.json create mode 100644 standalone/src-tauri/src/quit_state.rs create mode 100644 standalone/src-tauri/src/routing.rs create mode 100644 standalone/src/window-label.ts diff --git a/standalone/scripts/tauri-conf.test.mjs b/standalone/scripts/tauri-conf.test.mjs index bd48e6a51..00b8cc1a4 100644 --- a/standalone/scripts/tauri-conf.test.mjs +++ b/standalone/scripts/tauri-conf.test.mjs @@ -5,8 +5,11 @@ import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; const here = dirname(fileURLToPath(import.meta.url)); -const conf = JSON.parse(readFileSync(join(here, '..', 'src-tauri', 'tauri.conf.json'), 'utf8')); +const srcTauri = join(here, '..', 'src-tauri'); +const conf = JSON.parse(readFileSync(join(srcTauri, 'tauri.conf.json'), 'utf8')); const csp = conf.app.security.csp; +const capability = (name) => + JSON.parse(readFileSync(join(srcTauri, 'capabilities', `${name}.json`), 'utf8')); // The Burrow moved into the sidecar, so the webview never speaks to a relay // server and its connect-src must not be able to. The allowlist that does apply @@ -37,3 +40,25 @@ test('localhost stays allowed for dev and the loopback proxies', () => { assert.ok(csp.includes('http://localhost:*') && csp.includes('ws://localhost:*')); assert.ok(csp.startsWith("default-src 'self'")); }); + +// Every window is cloned from this config (`WebviewWindowBuilder::from_config`), +// and the first one's label is its persistence identity: the snapshot it wrote +// before multi-window shipped is `main.json`, and `restorable_labels` puts +// `main` first (docs/specs/standalone.md -> "Windows"). +test('the first window is labelled main', () => { + assert.equal(conf.app.windows[0].label, 'main'); +}); + +// Least privilege, and it is what structurally enforces that the update +// install runs in the window the quit walk tears down last +// (docs/specs/auto-update.md). +test('only the first window may check for or install an update', () => { + const dflt = capability('default'); + const mainOnly = capability('main-only'); + assert.deepEqual(dflt.windows, ['main', 'ws-*'], 'torn-out windows need the AppBar controls'); + assert.deepEqual(mainOnly.windows, ['main']); + for (const permission of ['updater:default', 'core:app:allow-version']) { + assert.ok(mainOnly.permissions.includes(permission), `main-only holds ${permission}`); + assert.ok(!dflt.permissions.includes(permission), `default does not hold ${permission}`); + } +}); diff --git a/standalone/src-tauri/capabilities/default.json b/standalone/src-tauri/capabilities/default.json index 89bddd8e5..a4356862e 100644 --- a/standalone/src-tauri/capabilities/default.json +++ b/standalone/src-tauri/capabilities/default.json @@ -1,9 +1,8 @@ { "identifier": "default", "description": "Default capability set for Dormouse", - "windows": ["main"], + "windows": ["main", "ws-*"], "permissions": [ - "core:app:allow-version", "core:event:allow-listen", "core:event:allow-unlisten", "core:window:allow-minimize", @@ -14,7 +13,6 @@ "core:window:allow-is-maximized", "core:window:allow-is-focused", "core:window:allow-start-dragging", - "shell:default", - "updater:default" + "shell:default" ] } diff --git a/standalone/src-tauri/capabilities/main-only.json b/standalone/src-tauri/capabilities/main-only.json new file mode 100644 index 000000000..741a8b8f5 --- /dev/null +++ b/standalone/src-tauri/capabilities/main-only.json @@ -0,0 +1,9 @@ +{ + "identifier": "main-only", + "description": "What only the first window may do: check for an update and install it on quit. Least privilege, and it structurally enforces that the install runs in the window the quit walk tears down last (docs/specs/auto-update.md).", + "windows": ["main"], + "permissions": [ + "core:app:allow-version", + "updater:default" + ] +} diff --git a/standalone/src-tauri/src/lib.rs b/standalone/src-tauri/src/lib.rs index 28c8baa08..e0641da67 100644 --- a/standalone/src-tauri/src/lib.rs +++ b/standalone/src-tauri/src/lib.rs @@ -2,6 +2,10 @@ use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; use serde::{Deserialize, Serialize}; use serde_json::{Map as JsonMap, Value as JsonValue}; mod log_tail; +mod quit_state; +mod routing; +use quit_state::{CloseMachine, QuitAction, QuitMachine}; +use routing::{Route, RouteView}; use std::{ collections::HashMap, env, @@ -9,14 +13,14 @@ use std::{ io::{BufRead, BufReader, Write}, path::{Path, PathBuf}, process::Stdio, - sync::atomic::{AtomicBool, AtomicU64, Ordering}, + sync::atomic::{AtomicU64, Ordering}, sync::mpsc, sync::{Arc, Mutex, MutexGuard, OnceLock}, - time::{Duration, SystemTime, UNIX_EPOCH}, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; use tauri::{ menu::{Menu, PredefinedMenuItem, Submenu}, - AppHandle, DragDropEvent, Emitter, Manager, RunEvent, WindowEvent, + AppHandle, DragDropEvent, Emitter, Manager, RunEvent, WebviewWindowBuilder, WindowEvent, }; #[cfg(target_os = "macos")] use tauri::menu::AboutMetadata; @@ -48,123 +52,389 @@ struct SidecarState { child: SharedChild, } +/// A lock taken for a short read or write, treating poisoning as recoverable: +/// every value behind one here is plain bookkeeping that a panicking thread +/// cannot leave half-written into an unusable shape. +fn guard(lock: &Mutex) -> MutexGuard<'_, T> { + lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +// ── Window ownership (docs/specs/standalone.md §Windows) ────────────────────── +// +// The sidecar has no window concept, so Rust keeps the map from PTY to window +// and routes every stdout line through `routing::route`. +#[derive(Default)] +struct WindowState { + /// ptyId -> window label. Minted only in `pty_spawn`, dropped by + /// `pty_kill`, an exit, or a window going away; reassigned by a transfer. + owners: Mutex>, + /// Ids whose output is suppressed until the replay their new owner is about + /// to be sent has been emitted, each with the instant it began. + awaiting_replay: Mutex>, + /// Window labels, most recently focused first. + focus_order: Mutex>, + /// A torn-out window's boot payload, pulled by `take_boot_payload`. Pulled, + /// never pushed: an `emit_to` a window that does not exist yet is lost. + pending_boot: Mutex>, + /// The window currently showing a cross-window drop caret, so the previous + /// one can be told to clear it. + hover_target: Mutex>, + /// Labels whose snapshot has been deliberately removed. A save arriving + /// from a webview that is going away must not put the file back. + closing: Mutex>, + /// The next `ws-`, seeded above every live and saved label at setup. + next_ws: AtomicU64, +} + +impl WindowState { + fn owned_by(&self, label: &str) -> Vec { + guard(&self.owners) + .iter() + .filter(|(_, owner)| owner.as_str() == label) + .map(|(id, _)| id.clone()) + .collect() + } + + fn mint(&self, id: &str, label: &str) { + guard(&self.owners).insert(id.to_string(), label.to_string()); + } + + /// Hand `ids` to `label` and suppress their output until each one's replay + /// has been emitted to it (docs/specs/standalone.md §Transfer). + fn reassign(&self, ids: &[String], label: &str) { + let mut owners = guard(&self.owners); + let mut awaiting = guard(&self.awaiting_replay); + let now = Instant::now(); + for id in ids { + owners.insert(id.clone(), label.to_string()); + awaiting.insert(id.clone(), now); + } + } + + /// Forget a window: its ownership, its focus entry, and any boot payload it + /// never pulled. Returns the ids it owned. + fn drop_window(&self, label: &str) -> Vec { + let owned = self.owned_by(label); + let mut owners = guard(&self.owners); + for id in &owned { + owners.remove(id); + } + drop(owners); + guard(&self.focus_order).retain(|entry| entry != label); + guard(&self.pending_boot).remove(label); + owned + } + + fn touch_focus(&self, label: &str) { + let mut order = guard(&self.focus_order); + order.retain(|entry| entry != label); + order.insert(0, label.to_string()); + } +} + +/// Route one sidecar stdout line to the window it belongs to. +fn dispatch_sidecar_event(app: &AppHandle, event: &str, data: JsonValue) { + let Some(state) = app.try_state::() else { + let _ = app.emit(event, data); + return; + }; + // Read once, ahead of the emit that moves `data`. + let id = data + .get("id") + .and_then(JsonValue::as_str) + .map(str::to_string); + + let decision = { + let owners = guard(&state.owners); + let mut awaiting = guard(&state.awaiting_replay); + for stale in routing::sweep_awaiting( + &mut awaiting, + Instant::now(), + routing::AWAITING_REPLAY_MAX, + ) { + append_log(format!( + "[window] transfer suppression for {stale} expired; releasing" + )); + } + let focus = guard(&state.focus_order); + routing::route( + event, + &data, + &RouteView { + owners: &owners, + awaiting_replay: &awaiting, + focused: focus.first().map(String::as_str), + }, + ) + }; + + match decision { + Route::Drop => {} + Route::Broadcast => { + let _ = app.emit(event, data); + } + Route::EmitTo(label) => { + let _ = app.emit_to(label.as_str(), event, data); + } + Route::UnownedSurface { + request_id, + surface_id, + } => { + // Never a sibling window: acting on the wrong terminal is worse + // than failing (docs/specs/dor-cli.md → "Control socket"). + if let Some(sidecar) = app.try_state::() { + let response = serde_json::json!({ + "event": "dor:controlResponse", + "data": { + "requestId": request_id, + "ok": false, + "error": format!("No Dormouse window owns surface '{surface_id}'"), + }, + }); + send_to_sidecar(&sidecar, response.to_string()); + } + } + } + + // Bookkeeping strictly after the emit, so a replay lifts its own + // suppression only once the new owner has actually been sent it. + if let Some(id) = id { + match event { + "pty:exit" => { + guard(&state.owners).remove(&id); + guard(&state.awaiting_replay).remove(&id); + } + "pty:replay" => { + guard(&state.awaiting_replay).remove(&id); + } + _ => {} + } + } +} + +/// Tell the sidecar's Burrow how many webviews will answer an ask +/// (docs/specs/standalone.md §Burrow service). +fn send_window_count(app: &AppHandle) { + let Some(state) = app.try_state::() else { + return; + }; + let count = app.webview_windows().len(); + send_to_sidecar( + &state, + serde_json::json!({ "event": "burrow:windows", "data": { "count": count } }).to_string(), + ); +} + // ── Quit interception ───────────────────────────────────────────────────────── // -// Every quit trigger funnels through `request_quit`, which asks the webview's -// orchestrator (standalone/src/quit.ts) to tear down and call back -// `quit_proceed`. Protocol + watchdog phases: docs/specs/standalone.md §Quit flow. +// Every quit trigger funnels through `request_quit`, which asks each window's +// orchestrator (standalone/src/quit.ts) to vote, then walks them one at a time. +// Protocol + watchdog phases: docs/specs/standalone.md §Quit flow. #[derive(Default)] struct QuitState { - // The webview acknowledged quit-requested — its listener is alive. - acked: AtomicBool, - // Teardown has actually begun (user confirmed, or there was nothing to - // confirm). Until this is set the webview may be parked on the confirmation - // dialog waiting for a human, so the teardown deadline below must stay - // suspended — a slow user must not be force-quit out from under the dialog. - tearing_down: AtomicBool, - // Bumped by `quit_progress` at each teardown phase boundary (teardown start, - // install start). The phase-3 watchdog treats a bump as "still making - // progress" and refreshes its deadline, so a long-but-live install isn't cut - // off by a long teardown — each phase gets its own budget rather than sharing - // one total. - progress: AtomicU64, - // Teardown finished (or a watchdog gave up): cleared to exit. Gates the - // CloseRequested/ExitRequested arms so the final app.exit(0) isn't re-caught. - approved: AtomicBool, - // Bumped on every request_quit and on quit_cancel. A watchdog captures the - // seq it was spawned for; if it no longer matches, a repeated trigger or a - // cancel has superseded it and the watchdog exits without acting. - seq: AtomicU64, -} - -// Phase 1: no ack within this window ⇒ webview listener is dead — exit. + machine: Mutex, + close: Mutex, +} + +// Phase 1: no ack within this window ⇒ a webview listener is dead — exit. const QUIT_ACK_TIMEOUT_MS: u64 = 2_000; -// Phase 3: per-phase budget once teardown is running. Each reported phase -// (teardown, install) refreshes it, so it bounds a single stalled phase, not the -// sum of all teardown work. Comfortably exceeds the webview's own 10 s teardown -// ceiling (docs/specs/standalone.md §Quit flow). +// Phase 3: per-phase budget once a window's teardown is running. Each reported +// phase (teardown, install) refreshes it, so it bounds a single stalled phase, +// not the sum of all teardown work. Comfortably exceeds the webview's own 10 s +// teardown ceiling (docs/specs/standalone.md §Quit flow). const QUIT_PHASE_TIMEOUT_MS: u64 = 14_000; const QUIT_POLL_STEP_MS: u64 = 500; +// A per-window close whose webview never acks: its listener is dead, so close it. +const CLOSE_ACK_TIMEOUT_MS: u64 = 2_000; fn quit_approved(app: &AppHandle) -> bool { app.try_state::() - .is_some_and(|q| q.approved.load(Ordering::SeqCst)) + .is_some_and(|state| guard(&state.machine).approved) +} + +/// Whether the windows are already being torn down, in which case a `destroy` +/// must not re-enter the quit as a fresh close. +fn quit_walking(app: &AppHandle) -> bool { + app.try_state::().is_some_and(|state| { + matches!( + guard(&state.machine).phase, + quit_state::QuitPhase::Walking { .. } + ) + }) +} + +fn window_labels(app: &AppHandle) -> Vec { + app.webview_windows().keys().cloned().collect() +} + +/// Perform what a `QuitMachine` transition asked for. +fn apply_quit_actions(app: &AppHandle, actions: Vec) { + for action in actions { + match action { + QuitAction::RequestAll => { + let _ = app.emit("dormouse://quit-requested", ()); + } + QuitAction::CancelAll => { + let _ = app.emit("dormouse://quit-cancelled", ()); + } + QuitAction::Teardown { label, last } => { + let _ = app.emit_to( + label.as_str(), + "dormouse://quit-teardown", + serde_json::json!({ "last": last }), + ); + } + QuitAction::Destroy { label } => { + if let Some(window) = app.get_webview_window(&label) { + // The snapshot stays on disk — that is what separates a + // quit from a per-window close. + if let Some(state) = app.try_state::() { + state.drop_window(&label); + } + let _ = window.destroy(); + } + } + QuitAction::Exit => { + if let Some(state) = app.try_state::() { + guard(&state.machine).approved = true; + } + app.exit(0); + } + } + } } fn request_quit(app: &AppHandle) { - let Some(quit) = app.try_state::() else { + let Some(state) = app.try_state::() else { return; }; - quit.acked.store(false, Ordering::SeqCst); - // Deliberately do NOT reset `tearing_down` here. A cancel happens before - // teardown, so it's already false for a genuinely fresh quit; and once - // teardown begins it only ever ends in `quit_proceed` (app exit), so a repeat - // trigger fired mid-teardown must keep the flag set — otherwise the fresh - // watchdog would drop into the unbounded phase-2 wait and stop bounding the - // in-flight teardown. - // fetch_add returns the prior value; our watchdog's seq is that + 1. - let my_seq = quit.seq.fetch_add(1, Ordering::SeqCst) + 1; - let _ = app.emit("dormouse://quit-requested", ()); - - // Watchdog: a cloned handle polls QuitState so a dead or wedged webview can't - // make quit hang. A repeated trigger bumps seq, so this (now-stale) watchdog - // returns and the fresh request_quit spawns a replacement. + let labels = window_labels(app); + let (my_seq, actions) = guard(&state.machine).request(&labels); + apply_quit_actions(app, actions); + + // Watchdog: a cloned handle polls the machine so a dead or wedged webview + // can't make quit hang. A repeated trigger bumps seq, so this (now-stale) + // watchdog returns and the fresh request_quit spawns a replacement. let app = app.clone(); std::thread::spawn(move || { std::thread::sleep(Duration::from_millis(QUIT_ACK_TIMEOUT_MS)); - let Some(quit) = app.try_state::() else { - return; - }; - // Superseded (seq bumped by a repeated trigger or a cancel) or already - // exiting (approved) ⇒ this watchdog has nothing to do. - let stale = |quit: &QuitState| { - quit.seq.load(Ordering::SeqCst) != my_seq || quit.approved.load(Ordering::SeqCst) + let give_up = |reason: &str| { + append_log(format!("[quit] {reason}; exiting")); + if let Some(state) = app.try_state::() { + guard(&state.machine).approved = true; + } + app.exit(0); }; - if stale(&quit) { + let Some(acked) = read_quit(&app, my_seq, QuitMachine::all_acked) else { return; - } - if !quit.acked.load(Ordering::SeqCst) { - append_log("[quit] no ack from webview; exiting"); - quit.approved.store(true, Ordering::SeqCst); - app.exit(0); + }; + if !acked { + give_up("a window never acked"); return; } - // Phase 2: acked but teardown hasn't begun. The webview may be parked on - // the confirmation dialog waiting for a human, so hold with no deadline — - // only proceed (approved) or cancel (seq bump) ends the wait. - while !quit.tearing_down.load(Ordering::SeqCst) { - std::thread::sleep(Duration::from_millis(QUIT_POLL_STEP_MS)); - if stale(&quit) { + // Phase 2: acked but no window has begun tearing down. Each may be + // parked on its confirmation dialog waiting for a human, who must never + // be force-quit out from under it — so hold with no deadline. + loop { + let Some(walking) = read_quit(&app, my_seq, |machine| { + machine.walking_progress().is_some() + }) else { return; + }; + if walking { + break; } + std::thread::sleep(Duration::from_millis(QUIT_POLL_STEP_MS)); } - // Phase 3: teardown running. Bound it, but a `quit_progress` bump (a phase - // boundary: teardown start, install start) refreshes the deadline so one - // long phase can't starve the next — each phase gets its own budget. - let mut last_progress = quit.progress.load(Ordering::SeqCst); + // Phase 3: one window is tearing down. Bound it, but a `quit_progress` + // bump (a phase boundary) or the walk advancing to the next window + // refreshes the deadline, so each phase gets its own budget. + let mut last = read_quit(&app, my_seq, QuitMachine::walking_progress); let mut elapsed = 0u64; loop { std::thread::sleep(Duration::from_millis(QUIT_POLL_STEP_MS)); - if stale(&quit) { + let Some(now) = read_quit(&app, my_seq, QuitMachine::walking_progress) else { return; - } - let progress = quit.progress.load(Ordering::SeqCst); - if progress != last_progress { - last_progress = progress; + }; + if Some(&now) != last.as_ref() { + last = Some(now); elapsed = 0; continue; } elapsed += QUIT_POLL_STEP_MS; if elapsed >= QUIT_PHASE_TIMEOUT_MS { - append_log("[quit] teardown phase stalled; exiting"); - quit.approved.store(true, Ordering::SeqCst); - app.exit(0); + give_up("teardown phase stalled"); return; } } }); } +/// Read the quit machine on behalf of a watchdog spawned for `seq`. `None` +/// means the watchdog has been superseded (a repeat trigger or a cancel) or the +/// app is already exiting, and it must stand down without acting. +fn read_quit(app: &AppHandle, seq: u64, read: impl FnOnce(&QuitMachine) -> T) -> Option { + let state = app.try_state::()?; + let machine = guard(&state.machine); + if machine.stale(seq) { + return None; + } + Some(read(&machine)) +} + +/// Ask one window to close itself (docs/specs/standalone.md §Per-window close). +/// The app keeps running; only the last window's close is a quit. +fn request_window_close(app: &AppHandle, label: &str) { + let Some(state) = app.try_state::() else { + return; + }; + let my_seq = guard(&state.close).request(label); + let _ = app.emit_to(label, "dormouse://window-close-requested", ()); + + let app = app.clone(); + let label = label.to_string(); + std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(CLOSE_ACK_TIMEOUT_MS)); + let Some(state) = app.try_state::() else { + return; + }; + let close = guard(&state.close); + if close.stale(&label, my_seq) || close.acked(&label) { + return; + } + drop(close); + append_log(format!( + "[window] {label} never acked its close; closing it anyway" + )); + finish_window_close(&app, &label); + }); +} + +/// The last step of a per-window close: forget the window's PTYs and its +/// snapshot, then destroy it. Called from `window_close_proceed`, and from the +/// ack watchdog when the webview never answered. +fn finish_window_close(app: &AppHandle, label: &str) { + if let Some(state) = app.try_state::() { + guard(&state.close).clear(label); + // Bound before the call: the guard would otherwise live for the whole + // statement, and `apply_quit_actions` takes the same lock. + let actions = guard(&state.machine).forget_window(label); + apply_quit_actions(app, actions); + } + if let Some(state) = app.try_state::() { + state.drop_window(label); + } + if let Ok(dir) = sessions_dir(app) { + if let Err(err) = remove_session_from(&dir, label) { + append_log(format!("[session] {err}")); + } + } + if let Some(window) = app.get_webview_window(label) { + let _ = window.destroy(); + } + send_window_count(app); +} + const LOG_FILE_ENV: &str = "DORMOUSE_LOG_FILE"; fn log_timestamp() -> u64 { @@ -369,8 +639,17 @@ fn request_from_sidecar_timeout( // ── Tauri commands ────────────────────────────────────────────────────────── +/// The only place PTY ownership is minted: whichever window asked for the PTY +/// owns it until a transfer moves it (docs/specs/standalone.md §Windows). #[tauri::command] -fn pty_spawn(state: tauri::State<'_, SidecarState>, id: String, options: Option) { +fn pty_spawn( + window: tauri::Window, + state: tauri::State<'_, SidecarState>, + windows: tauri::State<'_, WindowState>, + id: String, + options: Option, +) { + windows.mint(&id, window.label()); let msg = serde_json::json!({ "event": "pty:spawn", "data": { "id": id, "options": options } @@ -409,7 +688,8 @@ fn pty_theme_colors(state: tauri::State<'_, SidecarState>, colors: JsonValue) { } #[tauri::command] -fn pty_kill(state: tauri::State<'_, SidecarState>, id: String) { +fn pty_kill(state: tauri::State<'_, SidecarState>, windows: tauri::State<'_, WindowState>, id: String) { + guard(&windows.owners).remove(&id); let msg = serde_json::json!({ "event": "pty:kill", "data": { "id": id } @@ -417,9 +697,19 @@ fn pty_kill(state: tauri::State<'_, SidecarState>, id: String) { send_to_sidecar(&state, msg.to_string()); } +/// List and replay only what this window owns. The answer names the window, so +/// the `pty:list` and every `pty:replay` behind it route back to the asker +/// alone (docs/specs/standalone.md §Windows). #[tauri::command] -fn pty_request_init(state: tauri::State<'_, SidecarState>) { - let msg = serde_json::json!({ "event": "pty:requestInit" }); +fn pty_request_init( + window: tauri::Window, + state: tauri::State<'_, SidecarState>, + windows: tauri::State<'_, WindowState>, +) { + let msg = serde_json::json!({ + "event": "pty:requestInit", + "data": { "forWindow": window.label(), "ids": windows.owned_by(window.label()) }, + }); send_to_sidecar(&state, msg.to_string()); } @@ -503,18 +793,29 @@ fn pty_get_open_ports( .unwrap_or_else(|| JsonValue::Array(Vec::new()))) } -// Wait for PTY exits and their final output before shutdown. Async: waits up to -// `timeout + 1500ms` (margin for the round trip beyond the sidecar's own kill -// timer) and must not block the main thread for that long. +// Wait for PTY exits and their final output before this window goes away. +// Async: waits up to `timeout + 1500ms` (margin for the round trip beyond the +// sidecar's own kill timer) and must not block the main thread for that long. +// +// **Scoped to the caller's own PTYs**, whether it names ids or not: a window +// tearing down must never kill a sibling's terminals. #[tauri::command] -async fn pty_graceful_kill_all( +async fn pty_graceful_kill( + window: tauri::Window, state: tauri::State<'_, SidecarState>, + windows: tauri::State<'_, WindowState>, + ids: Option>, timeout: u64, ) -> Result<(), String> { + let owned = windows.owned_by(window.label()); + let targets: Vec = match ids { + Some(ids) => ids.into_iter().filter(|id| owned.contains(id)).collect(), + None => owned, + }; request_from_sidecar_timeout( &state, - "pty:gracefulKillAll", - serde_json::json!({ "timeout": timeout }), + "pty:gracefulKill", + serde_json::json!({ "ids": targets, "timeout": timeout }), Duration::from_millis(timeout + 1500), )?; Ok(()) @@ -533,10 +834,16 @@ async fn pty_graceful_kill_all( /// between the interrupt and the kill. #[tauri::command(async)] fn capture_agent_recovery( + window: tauri::Window, state: tauri::State<'_, SidecarState>, + windows: tauri::State<'_, WindowState>, ids: Option>, timeout: u64, ) -> Result<(), String> { + // Defaults to this window's own PTYs: a quit walks the windows one at a + // time, and interrupting a sibling's agents would destroy the very hint the + // sibling is about to capture. + let ids = ids.unwrap_or_else(|| windows.owned_by(window.label())); request_from_sidecar_timeout( &state, "pty:captureRecovery", @@ -1102,6 +1409,14 @@ async fn load_session(window: tauri::Window) -> Result, String> { #[tauri::command] async fn save_session(window: tauri::Window, state: String) -> Result<(), String> { + // A deliberate close removes the snapshot; a save still in flight from the + // webview that is going away must not put it back + // (docs/specs/standalone.md §Per-window close). + if let Some(windows) = window.app_handle().try_state::() { + if guard(&windows.closing).contains(window.label()) { + return Ok(()); + } + } write_session_to(&sessions_dir(window.app_handle())?, window.label(), &state) } @@ -1150,6 +1465,123 @@ fn sweep_orphan_session_temps(dir: &Path) -> Result<(), String> { first_error.map_or(Ok(()), Err) } +/// Delete everything a window leaves on disk: its snapshot, any temp write, and +/// its geometry sibling. A per-window close is deliberate, so unlike a quit it +/// takes the window off the next launch's restore list +/// (docs/specs/standalone.md §Per-window close). +fn remove_session_from(dir: &Path, label: &str) -> Result<(), String> { + let mut first_error = None; + let session = dir.join(session_file_name(label)); + for path in [temp_write_path(&session), geometry_path(dir, label), session] { + match std::fs::remove_file(&path) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) if first_error.is_none() => { + first_error = Some(format!("remove {}: {e}", path.display())); + } + Err(_) => {} + } + } + first_error.map_or(Ok(()), Err) +} + +// --- Window geometry (docs/specs/standalone.md §Windows) --------------------- +// +// A sibling of the session snapshot rather than `tauri-plugin-window-state`: +// one store answers "which windows exist", the boot enumeration is already +// Rust's job, and no new Cargo/npm dependency rides the disclosure and cooldown. + +/// Logical, not physical: a snapshot taken on one display must reopen sensibly +/// on another with a different scale factor. +#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq)] +struct WindowGeometry { + x: f64, + y: f64, + width: f64, + height: f64, +} + +/// Moves and resizes arrive per frame while a window is dragged; write at most +/// one file per window per this interval. +const GEOMETRY_DEBOUNCE_MS: u64 = 400; + +#[derive(Default)] +struct GeometryState { + pending: Mutex>, + /// Whether a debounce thread is already going to drain `pending`. + flushing: std::sync::atomic::AtomicBool, +} + +fn geometry_path(dir: &Path, label: &str) -> PathBuf { + let safe = session_file_name(label); + let stem = safe.strip_suffix(".json").unwrap_or(&safe); + dir.join(format!("{stem}.geometry.json")) +} + +fn read_geometry(dir: &Path, label: &str) -> Option { + let raw = std::fs::read_to_string(geometry_path(dir, label)).ok()?; + serde_json::from_str(&raw).ok() +} + +/// Record this window's box and schedule the debounced write. +fn note_geometry(app: &AppHandle, label: &str) { + let Some(window) = app.get_webview_window(label) else { + return; + }; + // A minimized window reports a nonsense box on some platforms; keep the + // last real one instead. + if window.is_minimized().unwrap_or(false) { + return; + } + let scale = window.scale_factor().unwrap_or(1.0); + let (Ok(position), Ok(size)) = (window.outer_position(), window.outer_size()) else { + return; + }; + let position = position.to_logical::(scale); + let size = size.to_logical::(scale); + let Some(state) = app.try_state::() else { + return; + }; + guard(&state.pending).insert( + label.to_string(), + WindowGeometry { + x: position.x, + y: position.y, + width: size.width, + height: size.height, + }, + ); + if state + .flushing + .swap(true, Ordering::SeqCst) + { + return; + } + let app = app.clone(); + std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(GEOMETRY_DEBOUNCE_MS)); + let Some(state) = app.try_state::() else { + return; + }; + let pending: HashMap = std::mem::take(&mut guard(&state.pending)); + state.flushing.store(false, Ordering::SeqCst); + let Ok(dir) = sessions_dir(&app) else { return }; + for (label, geometry) in pending { + // A window that closed inside the debounce took its geometry file + // with it; do not resurrect one for it. + if app.get_webview_window(&label).is_none() { + continue; + } + let Ok(json) = serde_json::to_string(&geometry) else { + continue; + }; + if let Err(err) = write_file_atomically(&geometry_path(&dir, &label), &json) { + append_log(format!("[window] geometry write for {label}: {err}")); + } + } + }); +} + // --- Notepad archive (docs/specs/notepad.md) --------------------------------- // // One machine-local archive per host, kept as `/notepad-archive-v1.json` @@ -1385,44 +1817,403 @@ fn reset_notepad_archive( reset_notepad_archive_at(¬epad_archive_path(&app)?, &archive.gate) } +// ── Window lifecycle (docs/specs/standalone.md §Windows) ───────────────────── + +/// Every file name in the sessions directory, for the boot enumeration. +fn session_file_names(dir: &Path) -> Vec { + let Ok(entries) = std::fs::read_dir(dir) else { + return Vec::new(); + }; + entries + .flatten() + .filter_map(|entry| entry.file_name().to_str().map(str::to_string)) + .collect() +} + +/// Give `main` back its saved box and reopen every other saved window, in the +/// order `restorable_labels` produced (`main` first). The cap is a ceiling on +/// how many windows one launch may open; the excess stays on disk untouched. +fn restore_windows(app: &AppHandle, dir: &Path, labels: &[String]) { + if let Some(window) = app.get_webview_window(routing::MAIN_LABEL) { + if let Some(geometry) = read_geometry(dir, routing::MAIN_LABEL) { + let _ = window.set_position(tauri::LogicalPosition::new(geometry.x, geometry.y)); + let _ = window.set_size(tauri::LogicalSize::new(geometry.width, geometry.height)); + } + } + let mut opened = 1usize; + for label in labels.iter().filter(|label| *label != routing::MAIN_LABEL) { + if opened >= routing::MAX_RESTORED_WINDOWS { + append_log(format!( + "[window] not reopening {label}: {} windows is the cap; its snapshot stays on disk", + routing::MAX_RESTORED_WINDOWS + )); + continue; + } + // An unreadable snapshot still opens its window: the webview boots + // fresh, which is a window the user can use rather than one they lost. + if let Err(err) = build_window(app, label, read_geometry(dir, label)) { + append_log(format!("[window] {err}")); + continue; + } + opened += 1; + } + // Last, so it comes up in front of the windows opened behind it. + if let Some(window) = app.get_webview_window(routing::MAIN_LABEL) { + let _ = window.set_focus(); + } +} + + +/// Open a window cloned from `tauri.conf.json`'s first window config, so +/// `titleBarStyle`, `hiddenTitle`, `dragDropEnabled` and the CSP carry across +/// without a second copy of any of them. +fn build_window( + app: &AppHandle, + label: &str, + geometry: Option, +) -> Result<(), String> { + let mut config = app + .config() + .app + .windows + .first() + .cloned() + .ok_or_else(|| "no window config to clone".to_string())?; + config.label = label.to_string(); + if let Some(geometry) = geometry { + config.x = Some(geometry.x); + config.y = Some(geometry.y); + config.width = geometry.width; + config.height = geometry.height; + // An explicit position and a centering request are contradictory. + config.center = false; + } + let window = WebviewWindowBuilder::from_config(app, &config) + .map_err(|err| format!("configure window {label}: {err}"))? + .build() + .map_err(|err| format!("build window {label}: {err}"))?; + // macOS keeps `titleBarStyle: "Overlay"` from the config, which preserves + // rounded corners and native traffic lights; everywhere else the title bar + // is fully custom (§AppBar). + #[cfg(not(target_os = "macos"))] + { + let _ = window.set_decorations(false); + } + #[cfg(target_os = "macos")] + let _ = &window; + Ok(()) +} + +/// The label a new window takes: `ws-` above every live and saved one. +fn next_window_label(windows: &WindowState) -> String { + format!( + "{}{}", + routing::WS_LABEL_PREFIX, + windows.next_ws.fetch_add(1, Ordering::SeqCst) + ) +} + +/// A Workspace leaving a window: the source window's own view of the departure. +fn announce_departure(app: &AppHandle, from: &str, workspace_id: &JsonValue) { + let _ = app.emit_to( + from, + "dormouse://workspace-departed", + serde_json::json!({ "workspaceId": workspace_id }), + ); +} + +fn payload_terminal_ids(payload: &JsonValue) -> Vec { + payload + .get("terminalIds") + .and_then(JsonValue::as_array) + .map(|ids| { + ids.iter() + .filter_map(|id| id.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default() +} + +/// Tear a Workspace out into a brand-new window under the cursor. +/// +/// The payload is *stored*, never emitted: an `emit_to` a window that does not +/// exist yet is lost, so the new webview pulls it with `take_boot_payload` +/// during its own boot (docs/specs/standalone.md §Tear-out). +#[tauri::command] +fn open_workspace_window( + app: AppHandle, + window: tauri::Window, + windows: tauri::State<'_, WindowState>, + payload: JsonValue, +) -> Result { + let label = next_window_label(&windows); + // Ownership moves before the window exists, so every byte from this instant + // is suppressed rather than painted in the window losing the Workspace. + windows.reassign(&payload_terminal_ids(&payload), &label); + let geometry = { + let scale = window.scale_factor().unwrap_or(1.0); + let size = window + .outer_size() + .map(|size| size.to_logical::(scale)) + .ok(); + let at = payload.get("at"); + match (at.and_then(|at| at.get("x")), at.and_then(|at| at.get("y")), size) { + (Some(x), Some(y), Some(size)) => x.as_f64().zip(y.as_f64()).map(|(x, y)| WindowGeometry { + x, + y, + width: size.width, + height: size.height, + }), + _ => None, + } + }; + guard(&windows.pending_boot).insert(label.clone(), payload.clone()); + if let Err(err) = build_window(&app, &label, geometry) { + // Nothing will ever pull the payload, and the PTYs would stay + // suppressed and ownerless: hand them straight back. + guard(&windows.pending_boot).remove(&label); + windows.reassign(&payload_terminal_ids(&payload), window.label()); + for id in payload_terminal_ids(&payload) { + guard(&windows.awaiting_replay).remove(&id); + } + return Err(err); + } + send_window_count(&app); + announce_departure( + &app, + window.label(), + payload.get("workspaceId").unwrap_or(&JsonValue::Null), + ); + Ok(label) +} + +/// Move a Workspace into a window that already exists. +/// +/// Ownership and the output suppression move synchronously here, before either +/// window is told anything: the single Rust reader thread processes sidecar +/// lines in order, so every byte after this point is either dropped (and +/// present in the replay the target is about to get) or delivered to the target +/// (docs/specs/standalone.md §Transfer). +#[tauri::command] +fn transfer_workspace( + app: AppHandle, + window: tauri::Window, + windows: tauri::State<'_, WindowState>, + to: String, + payload: JsonValue, +) -> Result<(), String> { + if app.get_webview_window(&to).is_none() { + return Err(format!("no window '{to}'")); + } + if to == window.label() { + return Err("a Workspace cannot be transferred to its own window".to_string()); + } + windows.reassign(&payload_terminal_ids(&payload), &to); + let _ = app.emit_to(to.as_str(), "dormouse://workspace-arriving", payload.clone()); + announce_departure( + &app, + window.label(), + payload.get("workspaceId").unwrap_or(&JsonValue::Null), + ); + Ok(()) +} + +/// The target has armed its collector; ask the sidecar to list and replay every +/// PTY still suppressed for it. This hop is what removes the whole +/// "arrived before armed" bug class. +#[tauri::command] +fn adopt_ready( + window: tauri::Window, + state: tauri::State<'_, SidecarState>, + windows: tauri::State<'_, WindowState>, +) { + let label = window.label(); + // Exactly the arriving set: owned by this window and still suppressed. + let ids: Vec = { + let owners = guard(&windows.owners); + guard(&windows.awaiting_replay) + .keys() + .filter(|id| owners.get(*id).map(String::as_str) == Some(label)) + .cloned() + .collect() + }; + if ids.is_empty() { + return; + } + let msg = serde_json::json!({ + "event": "pty:requestInit", + "data": { "forWindow": label, "ids": ids }, + }); + send_to_sidecar(&state, msg.to_string()); +} + +/// A torn-out window's boot payload, or null for an ordinary window. Taking it +/// consumes it: a reload must boot from the snapshot it has since written. +#[tauri::command] +fn take_boot_payload(window: tauri::Window, windows: tauri::State<'_, WindowState>) -> JsonValue { + guard(&windows.pending_boot) + .remove(window.label()) + .unwrap_or(JsonValue::Null) +} + +/// Remove this window's persisted snapshot and stop it being written again. +#[tauri::command] +async fn remove_window_session(window: tauri::Window) -> Result<(), String> { + let app = window.app_handle(); + if let Some(windows) = app.try_state::() { + guard(&windows.closing).insert(window.label().to_string()); + } + remove_session_from(&sessions_dir(app)?, window.label()) +} + +/// Which window is under the cursor, in that window's own logical client space. +/// +/// Tauri exposes no z-order, so among the windows containing the point the most +/// recently focused wins — right for a drag, and the hover caret makes a wrong +/// guess visible before release. +#[tauri::command] +fn window_at_cursor( + app: AppHandle, + windows: tauri::State<'_, WindowState>, +) -> Option { + let point = app.cursor_position().ok()?; + let rects: Vec = app + .webview_windows() + .into_iter() + .filter_map(|(label, window)| { + Some(routing::WindowRect { + label, + origin: { + let position = window.outer_position().ok()?; + (position.x, position.y) + }, + size: { + let size = window.outer_size().ok()?; + (size.width, size.height) + }, + scale: window.scale_factor().unwrap_or(1.0), + hittable: window.is_visible().unwrap_or(true) + && !window.is_minimized().unwrap_or(false), + }) + }) + .collect(); + let focus_order = guard(&windows.focus_order).clone(); + routing::window_at(&rects, &focus_order, (point.x, point.y)) +} + +/// Show (or clear) another window's drop caret while a tab is dragged over it. +/// The previously hovered window is always cleared, so a caret can never be +/// left behind in a window the pointer has since left. +#[tauri::command] +fn hover_workspace_target( + app: AppHandle, + windows: tauri::State<'_, WindowState>, + label: Option, + x: f64, + y: f64, +) { + let mut current = guard(&windows.hover_target); + if current.as_deref() != label.as_deref() { + if let Some(previous) = current.as_deref() { + let _ = app.emit_to(previous, "dormouse://workspace-drop-hover", JsonValue::Null); + } + } + *current = label.clone(); + if let Some(label) = label { + let _ = app.emit_to( + label.as_str(), + "dormouse://workspace-drop-hover", + serde_json::json!({ "x": x, "y": y }), + ); + } +} + #[tauri::command] fn kill_sidecar_now(state: tauri::State<'_, SidecarState>) { kill_sidecar_and_wait(&state.child); } // ── Quit protocol commands (docs/specs/standalone.md §Quit flow) ───────────── +// +// Every one keys by the invoking window's label: a quit is N conversations, and +// only the window that voted may be the window that tears down. -// The webview's quit orchestrator received quit-requested and its listener is +// This window's quit orchestrator received quit-requested and its listener is // alive; stand the phase-1 ack watchdog down. #[tauri::command] -fn quit_ack(state: tauri::State<'_, QuitState>) { - state.acked.store(true, Ordering::SeqCst); +fn quit_ack(window: tauri::Window, state: tauri::State<'_, QuitState>) { + guard(&state.machine).ack(window.label()); +} + +// This window is ready to be torn down: its confirmation and archive gates are +// done. The last vote starts the walk. +#[tauri::command] +fn quit_vote(app: AppHandle, window: tauri::Window, state: tauri::State<'_, QuitState>) { + let actions = guard(&state.machine).vote(window.label()); + apply_quit_actions(&app, actions); +} + +// This window has started (or advanced) its teardown: the vote wait is over, +// and this phase boundary refreshes the watchdog's per-phase deadline. Sent at +// teardown start and again before installing an update, so a long install gets +// its own budget instead of sharing the teardown clock. +#[tauri::command] +fn quit_progress(window: tauri::Window, state: tauri::State<'_, QuitState>) { + guard(&state.machine).progress(window.label()); } -// The orchestrator has started (or advanced) teardown: the confirmation wait is -// over, and this phase boundary refreshes the watchdog's per-phase deadline. The -// webview calls this at teardown start and again before installing an update, so -// a long install gets its own budget instead of sharing the teardown clock. +// A window declined the quit. Bumping seq invalidates any live watchdog so +// nothing exits, every window's dialog is told to close, and nothing has been +// destroyed — which is the whole reason the windows vote before they walk. #[tauri::command] -fn quit_progress(state: tauri::State<'_, QuitState>) { - state.tearing_down.store(true, Ordering::SeqCst); - state.progress.fetch_add(1, Ordering::SeqCst); +fn quit_cancel(app: AppHandle, state: tauri::State<'_, QuitState>) { + let actions = guard(&state.machine).cancel(); + apply_quit_actions(&app, actions); } -// The user declined the quit (confirmation cancel). Bumping seq invalidates any -// live watchdog for this quit so nothing exits; the next request_quit starts -// fresh (it re-clears `acked` itself). +// A non-last window finished its teardown: destroy it and start the next one. +// Its snapshot stays on disk, which is what a relaunch restores it from. #[tauri::command] -fn quit_cancel(state: tauri::State<'_, QuitState>) { - state.seq.fetch_add(1, Ordering::SeqCst); +fn quit_window_done(app: AppHandle, window: tauri::Window, state: tauri::State<'_, QuitState>) { + let actions = guard(&state.machine).window_done(window.label()); + apply_quit_actions(&app, actions); } -// Teardown is done (or the orchestrator bailed under its own timeout); approve so -// the app.exit(0) below re-enters ExitRequested with approved=true and proceeds. +// The last window is done (or its orchestrator bailed under its own timeout); +// approve so the app.exit(0) re-enters ExitRequested with approved=true. #[tauri::command] fn quit_proceed(app: AppHandle, state: tauri::State<'_, QuitState>) { - state.approved.store(true, Ordering::SeqCst); - app.exit(0); + let actions = guard(&state.machine).proceed(); + apply_quit_actions(&app, actions); +} + +// ── Per-window close (docs/specs/standalone.md §Per-window close) ───────────── + +// This window's close orchestrator is alive; stand its ack watchdog down. +#[tauri::command] +fn window_close_ack(window: tauri::Window, state: tauri::State<'_, QuitState>) { + guard(&state.close).ack(window.label()); +} + +// The user declined the close, or its archive gate refused it. The window stays +// exactly as it was. +#[tauri::command] +fn window_close_cancel(window: tauri::Window, state: tauri::State<'_, QuitState>) { + guard(&state.close).clear(window.label()); +} + +// The window archived its notes, removed its snapshot and killed its PTYs. +#[tauri::command] +fn window_close_proceed(app: AppHandle, window: tauri::Window) { + finish_window_close(&app, window.label()); +} + +// A window whose last Workspace moved away closes with no confirmation, no +// archive and no kill: its Surfaces are alive in another window +// (docs/specs/standalone.md §Transfer). +#[tauri::command] +fn close_window_self(app: AppHandle, window: tauri::Window) { + finish_window_close(&app, window.label()); } // Normal app quit should let the Node sidecar run its shutdown handler first: @@ -1856,7 +2647,9 @@ fn start_sidecar(app: &AppHandle) -> Result { } } - let _ = handle.emit(&event, data); + // Every line goes through the ownership map: one sidecar serves + // every window (§Windows). + dispatch_sidecar_event(&handle, &event, data); } }); @@ -1973,31 +2766,59 @@ pub fn run() { let refs: Vec<&dyn tauri::menu::IsMenuItem<_>> = items.iter().map(|b| b.as_ref()).collect(); Menu::with_items(handle, &refs) }) - // Inert while tauri.conf.json sets dragDropEnabled=false (needed for HTML5 pane drag). See diffplug/dormouse#38 and tauri-apps/tauri#14373. .on_window_event(|window, event| { - if let WindowEvent::DragDrop(DragDropEvent::Drop { paths, .. }) = event { - let payload: Vec = paths - .iter() - .map(|p| p.to_string_lossy().into_owned()) - .collect(); - let _ = window.emit("dormouse://files-dropped", serde_json::json!({ "paths": payload })); - } - // Window close funnels into the app-wide quit flow (§Quit flow). - // Multi-window seam: one window ships today, so a per-window close is - // the whole-app quit; a multi-window build would give each close a - // per-window teardown and only quit on the last one. - if let WindowEvent::CloseRequested { api, .. } = event { - let app = window.app_handle(); - if !quit_approved(app) { + let app = window.app_handle(); + match event { + // Inert while tauri.conf.json sets dragDropEnabled=false (needed for HTML5 pane drag). See diffplug/dormouse#38 and tauri-apps/tauri#14373. + WindowEvent::DragDrop(DragDropEvent::Drop { paths, .. }) => { + let payload: Vec = paths + .iter() + .map(|p| p.to_string_lossy().into_owned()) + .collect(); + let _ = window.emit("dormouse://files-dropped", serde_json::json!({ "paths": payload })); + } + // Focus order is the drag hit test's z-order stand-in and the + // fallback owner for a `dor` request naming no Surface. + WindowEvent::Focused(true) => { + if let Some(state) = app.try_state::() { + state.touch_focus(window.label()); + } + } + WindowEvent::Moved(_) | WindowEvent::Resized(_) => { + note_geometry(app, window.label()); + } + // The close button: this window alone unless it is the last one, + // which is the whole-app quit (§Per-window close). Gated on the + // quit walk so a teardown's own destroy cannot re-enter it. + WindowEvent::CloseRequested { api, .. } => { + if quit_approved(app) || quit_walking(app) { + return; + } api.prevent_close(); - request_quit(app); + if app.webview_windows().len() > 1 { + request_window_close(app, window.label()); + } else { + request_quit(app); + } } + // Backstop for a window that went away by any other route. + WindowEvent::Destroyed => { + if let Some(state) = app.try_state::() { + state.drop_window(window.label()); + } + } + _ => {} } }) .setup(|app| { init_log(); append_log("[app] setup started"); + // Managed before the sidecar starts: its stdout reader routes every + // line through this map (§Windows). + app.manage(WindowState::default()); + app.manage(GeometryState::default()); + let sidecar_state = start_sidecar(app.handle()).map_err(|err| { append_log(format!("[sidecar] {err}")); std::io::Error::new(std::io::ErrorKind::Other, err) @@ -2032,11 +2853,31 @@ pub fn run() { // rounded corners and native traffic-light buttons. #[cfg(not(target_os = "macos"))] { - if let Some(window) = app.get_webview_window("main") { + if let Some(window) = app.get_webview_window(routing::MAIN_LABEL) { let _ = window.set_decorations(false); } } + // Reopen every window the last run left behind (§Windows). `main` is + // already up from the config; the rest are cloned from it. + match sessions_dir(app.handle()) { + Ok(dir) => { + let labels = routing::restorable_labels(session_file_names(&dir)); + // Above every SAVED label too, not just the live ones: a + // torn-out window must never claim a snapshot still on disk. + app.state::() + .next_ws + .store(routing::seed_next_ws(&labels), Ordering::SeqCst); + restore_windows(app.handle(), &dir, &labels); + } + Err(e) => append_log(format!("[window] {e}")), + } + if let Some(state) = app.try_state::() { + state.touch_focus(routing::MAIN_LABEL); + } + // The Burrow fans an ask out to every window and collects N answers. + send_window_count(app.handle()); + Ok(()) }) .invoke_handler(tauri::generate_handler![ @@ -2049,7 +2890,7 @@ pub fn run() { pty_get_cwds, pty_context, pty_get_open_ports, - pty_graceful_kill_all, + pty_graceful_kill, capture_agent_recovery, take_recovery_commands, iframe_create_proxy_url, @@ -2058,9 +2899,22 @@ pub fn run() { burrow_command, kill_sidecar_now, quit_ack, + quit_vote, quit_progress, quit_cancel, + quit_window_done, quit_proceed, + window_close_ack, + window_close_cancel, + window_close_proceed, + close_window_self, + open_workspace_window, + transfer_workspace, + adopt_ready, + take_boot_payload, + remove_window_session, + window_at_cursor, + hover_workspace_target, get_available_shells, read_clipboard_file_paths, read_clipboard_image_as_file_path, @@ -2571,6 +3425,59 @@ mod tests { & 0o777, 0o600 ); + // The geometry sibling rides the same writer, so it is owner-only too + // (docs/specs/security-local.md -> "Persisted state"). + super::write_file_atomically( + &super::geometry_path(dir.path(), "main"), + r#"{"x":0.0,"y":0.0,"width":1.0,"height":1.0}"#, + ) + .unwrap(); + assert_eq!( + fs::metadata(super::geometry_path(dir.path(), "main")) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o600 + ); + } + + /// A per-window close is deliberate: everything the window left on disk + /// goes, so the next launch does not reopen it + /// (docs/specs/standalone.md -> "Per-window close"). + #[test] + fn removing_a_window_session_takes_its_temp_and_geometry_with_it() { + let dir = TempDir::new("sessions-remove"); + write_session_to(dir.path(), "ws-2", r#"{"v":1}"#).unwrap(); + write_session_to(dir.path(), "main", r#"{"v":1}"#).unwrap(); + fs::write(dir.path().join("ws-2.json.tmp"), b"orphan").unwrap(); + fs::write(super::geometry_path(dir.path(), "ws-2"), b"{}").unwrap(); + + super::remove_session_from(dir.path(), "ws-2").unwrap(); + + assert!(!dir.path().join("ws-2.json").exists()); + assert!(!dir.path().join("ws-2.json.tmp").exists()); + assert!(!super::geometry_path(dir.path(), "ws-2").exists()); + // Never a sibling window's. + assert!(dir.path().join("main.json").exists()); + // Removing what is already gone is the desired end state, not an error. + super::remove_session_from(dir.path(), "ws-2").unwrap(); + } + + /// The geometry sibling must not read back as a window: a boot that opened + /// `ws-2.geometry` would fight the real `ws-2` for its snapshot. + #[test] + fn the_geometry_sibling_is_not_a_restorable_window() { + let dir = TempDir::new("sessions-enumerate"); + write_session_to(dir.path(), "main", r#"{"v":1}"#).unwrap(); + write_session_to(dir.path(), "ws-2", r#"{"v":1}"#).unwrap(); + super::write_file_atomically(&super::geometry_path(dir.path(), "ws-2"), "{}").unwrap(); + let mut names = super::session_file_names(dir.path()); + names.sort(); + assert_eq!( + super::routing::restorable_labels(&names), + vec!["main".to_string(), "ws-2".to_string()] + ); } #[test] diff --git a/standalone/src-tauri/src/quit_state.rs b/standalone/src-tauri/src/quit_state.rs new file mode 100644 index 000000000..cff76362a --- /dev/null +++ b/standalone/src-tauri/src/quit_state.rs @@ -0,0 +1,433 @@ +//! The quit machine: every window votes, then they tear down one at a time. +//! +//! Two windows made the old "confirm then destroy" flow unsafe — a cancel in +//! the last window could not put back the ones already destroyed — so a quit is +//! now vote-then-walk (docs/specs/standalone.md -> "Quit flow"). The state +//! transitions live here, free of Tauri, and hand the caller a list of actions +//! to perform; `lib.rs` owns the emitting, destroying and exiting. + +use crate::routing::quit_order; +use std::collections::HashMap; + +/// What the caller must do after a transition, in order. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum QuitAction { + /// Emit `dormouse://quit-requested` to every window. + RequestAll, + /// Emit `dormouse://quit-cancelled` to every window; nothing was destroyed. + CancelAll, + /// Emit `dormouse://quit-teardown` to one window. `last` is what tells it to + /// install a pending update and call `quit_proceed` instead of + /// `quit_window_done`. + Teardown { label: String, last: bool }, + /// Destroy a window whose teardown finished. Its snapshot stays on disk — + /// that is the point of a quit, as against a close. + Destroy { label: String }, + /// `app.exit(0)`. + Exit, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub enum QuitPhase { + #[default] + Idle, + /// Every window is deciding; nothing has been destroyed and a cancel here + /// costs nothing. + Voting, + /// The votes are in and the windows are tearing down in `order`, `main` + /// last. A cancel from here is refused: the first window is already gone. + Walking { order: Vec, index: usize }, +} + +#[derive(Debug, Clone, Default)] +pub struct WindowQuit { + /// The webview's listener answered, so the ack watchdog stands down. + pub acked: bool, + /// It has decided to quit (its confirmation and archive gates are done). + pub voted: bool, + /// Teardown has actually begun in this window, so the deadline may run. + pub tearing_down: bool, + /// Bumped at each teardown phase boundary; the watchdog treats a bump as + /// progress and refreshes its budget. + pub progress: u64, +} + +#[derive(Debug, Default)] +pub struct QuitMachine { + /// Bumped on every trigger and every cancel. A watchdog captures the value + /// it was spawned for and exits without acting once it no longer matches. + pub seq: u64, + /// Cleared to exit: gates the `CloseRequested` / `ExitRequested` arms so the + /// flow's own `app.exit(0)` is not re-caught. + pub approved: bool, + pub phase: QuitPhase, + pub windows: HashMap, +} + +impl QuitMachine { + /// A quit trigger over the live windows. + /// + /// Never clears a window's `tearing_down`: a repeat trigger fired + /// mid-teardown must keep it set, or the fresh watchdog drops into the + /// unbounded voting wait and stops bounding the teardown in flight. + pub fn request(&mut self, labels: &[String]) -> (u64, Vec) { + self.seq += 1; + let walking = matches!(self.phase, QuitPhase::Walking { .. }); + let mut next: HashMap = HashMap::new(); + for label in labels { + let mut entry = self.windows.remove(label).unwrap_or_default(); + entry.acked = false; + if !walking { + entry.voted = false; + } + next.insert(label.clone(), entry); + } + self.windows = next; + if !walking { + self.phase = QuitPhase::Voting; + } + (self.seq, vec![QuitAction::RequestAll]) + } + + pub fn ack(&mut self, label: &str) { + self.windows.entry(label.to_string()).or_default().acked = true; + } + + pub fn progress(&mut self, label: &str) { + let entry = self.windows.entry(label.to_string()).or_default(); + entry.tearing_down = true; + entry.progress += 1; + } + + /// This window is ready to be torn down. The last vote starts the walk. + pub fn vote(&mut self, label: &str) -> Vec { + if self.phase != QuitPhase::Voting { + return Vec::new(); + } + self.windows.entry(label.to_string()).or_default().voted = true; + if !self.windows.values().all(|entry| entry.voted) { + return Vec::new(); + } + self.start_walk() + } + + /// Somebody said no. Only reachable while voting — once the walk starts the + /// first window is already gone, so there is nothing to put back. + pub fn cancel(&mut self) -> Vec { + if self.phase != QuitPhase::Voting { + return Vec::new(); + } + self.seq += 1; + self.phase = QuitPhase::Idle; + for entry in self.windows.values_mut() { + entry.voted = false; + } + vec![QuitAction::CancelAll] + } + + /// A window finished its teardown. It is destroyed and the next one begins. + pub fn window_done(&mut self, label: &str) -> Vec { + let QuitPhase::Walking { order, index } = &mut self.phase else { + return Vec::new(); + }; + if order.get(*index).map(String::as_str) != Some(label) { + return Vec::new(); + } + *index += 1; + let next = order.get(*index).cloned(); + let last = *index + 1 == order.len(); + self.windows.remove(label); + let mut actions = vec![QuitAction::Destroy { + label: label.to_string(), + }]; + match next { + Some(label) => actions.push(QuitAction::Teardown { label, last }), + // The last window calls `proceed`, not `done`; reaching here means + // it did neither, so exit rather than wait forever. + None => actions.push(QuitAction::Exit), + } + actions + } + + pub fn proceed(&mut self) -> Vec { + self.approved = true; + vec![QuitAction::Exit] + } + + /// A window left outside the quit flow (a per-window close, or a crash). + /// Its vote can never arrive, so the flow must not wait on it. + pub fn forget_window(&mut self, label: &str) -> Vec { + self.windows.remove(label); + match &mut self.phase { + QuitPhase::Idle => Vec::new(), + QuitPhase::Voting => { + if self.windows.is_empty() || !self.windows.values().all(|entry| entry.voted) { + return Vec::new(); + } + self.start_walk() + } + QuitPhase::Walking { order, index } => { + let Some(position) = order.iter().position(|entry| entry == label) else { + return Vec::new(); + }; + order.remove(position); + if position > *index { + return Vec::new(); + } + if position < *index { + *index -= 1; + return Vec::new(); + } + // It was the window being torn down: advance onto the next. + let next = order.get(*index).cloned(); + let last = *index + 1 == order.len(); + match next { + Some(label) => vec![QuitAction::Teardown { label, last }], + None => vec![QuitAction::Exit], + } + } + } + } + + /// Whether a watchdog spawned for `seq` still speaks for the live quit. + pub fn stale(&self, seq: u64) -> bool { + self.seq != seq || self.approved + } + + pub fn all_acked(&self) -> bool { + self.windows.values().all(|entry| entry.acked) + } + + /// The window currently tearing down and its progress counter, for the + /// per-phase watchdog budget. + pub fn walking_progress(&self) -> Option<(String, u64)> { + let QuitPhase::Walking { order, index } = &self.phase else { + return None; + }; + let label = order.get(*index)?; + Some(( + label.clone(), + self.windows.get(label).map_or(0, |entry| entry.progress), + )) + } + + fn start_walk(&mut self) -> Vec { + let order = quit_order(self.windows.keys()); + let Some(first) = order.first().cloned() else { + self.phase = QuitPhase::Idle; + self.approved = true; + return vec![QuitAction::Exit]; + }; + let last = order.len() == 1; + self.phase = QuitPhase::Walking { order, index: 0 }; + vec![QuitAction::Teardown { label: first, last }] + } +} + +/// The per-window close handshake (docs/specs/standalone.md -> "Per-window +/// close"). Much smaller than a quit: one window decides, nothing else waits on +/// it, and the app keeps running either way. +#[derive(Debug, Default)] +pub struct CloseMachine { + pending: HashMap, +} + +#[derive(Debug, Default, Clone)] +struct CloseEntry { + seq: u64, + acked: bool, +} + +impl CloseMachine { + /// Begin (or re-trigger) a close on `label`, returning the seq its watchdog + /// should capture. + pub fn request(&mut self, label: &str) -> u64 { + let entry = self.pending.entry(label.to_string()).or_default(); + entry.seq += 1; + entry.acked = false; + entry.seq + } + + pub fn ack(&mut self, label: &str) { + if let Some(entry) = self.pending.get_mut(label) { + entry.acked = true; + } + } + + /// The user declined, or the window is gone: forget the pending close so a + /// live watchdog stops speaking for it. + pub fn clear(&mut self, label: &str) { + self.pending.remove(label); + } + + /// Whether a watchdog spawned for `seq` still speaks for `label`'s close. + pub fn stale(&self, label: &str, seq: u64) -> bool { + self.pending.get(label).map(|entry| entry.seq) != Some(seq) + } + + pub fn acked(&self, label: &str) -> bool { + self.pending.get(label).is_some_and(|entry| entry.acked) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn labels(values: &[&str]) -> Vec { + values.iter().map(|value| (*value).to_string()).collect() + } + + #[test] + fn all_votes_walk_the_windows_with_main_last() { + let mut quit = QuitMachine::default(); + let (seq, actions) = quit.request(&labels(&["main", "ws-2"])); + assert_eq!(seq, 1); + assert_eq!(actions, vec![QuitAction::RequestAll]); + + quit.ack("main"); + quit.ack("ws-2"); + assert!(quit.all_acked()); + + // One vote is not enough; nothing has been destroyed. + assert_eq!(quit.vote("main"), Vec::new()); + assert_eq!(quit.phase, QuitPhase::Voting); + + assert_eq!( + quit.vote("ws-2"), + vec![QuitAction::Teardown { + label: "ws-2".into(), + last: false + }] + ); + assert_eq!( + quit.window_done("ws-2"), + vec![ + QuitAction::Destroy { + label: "ws-2".into() + }, + QuitAction::Teardown { + label: "main".into(), + last: true + } + ] + ); + assert_eq!(quit.proceed(), vec![QuitAction::Exit]); + assert!(quit.approved); + } + + #[test] + fn any_cancel_aborts_with_nothing_destroyed() { + let mut quit = QuitMachine::default(); + quit.request(&labels(&["main", "ws-2"])); + quit.vote("main"); + let actions = quit.cancel(); + assert_eq!(actions, vec![QuitAction::CancelAll]); + assert_eq!(quit.phase, QuitPhase::Idle); + assert!(!quit.approved); + // The earlier vote is forgotten, so a fresh quit asks again. + assert!(quit.windows.values().all(|entry| !entry.voted)); + // A vote arriving after the cancel is stale and starts nothing. + assert_eq!(quit.vote("ws-2"), Vec::new()); + assert_eq!(quit.phase, QuitPhase::Idle); + } + + #[test] + fn a_cancel_after_the_walk_started_is_refused() { + let mut quit = QuitMachine::default(); + quit.request(&labels(&["main"])); + quit.vote("main"); + assert!(matches!(quit.phase, QuitPhase::Walking { .. })); + assert_eq!(quit.cancel(), Vec::new()); + assert!(matches!(quit.phase, QuitPhase::Walking { .. })); + } + + #[test] + fn a_repeat_trigger_re_emits_without_clearing_tearing_down() { + let mut quit = QuitMachine::default(); + quit.request(&labels(&["main", "ws-2"])); + quit.vote("main"); + quit.vote("ws-2"); + quit.progress("ws-2"); + assert_eq!(quit.walking_progress(), Some(("ws-2".into(), 1))); + + let (seq, actions) = quit.request(&labels(&["main", "ws-2"])); + assert_eq!(seq, 2); + assert_eq!(actions, vec![QuitAction::RequestAll]); + // The walk survives, and so does the in-flight teardown's own flag. + assert!(matches!(quit.phase, QuitPhase::Walking { .. })); + assert!(quit.windows["ws-2"].tearing_down); + // The stale watchdog stands down; the fresh one bounds the same teardown. + assert!(quit.stale(1)); + assert!(!quit.stale(2)); + } + + #[test] + fn the_last_window_exits_and_a_destroy_cannot_re_enter() { + let mut quit = QuitMachine::default(); + quit.request(&labels(&["main"])); + assert_eq!( + quit.vote("main"), + vec![QuitAction::Teardown { + label: "main".into(), + last: true + }] + ); + // `window_done` on the last window is the defensive path: exit anyway. + assert_eq!( + quit.window_done("main"), + vec![ + QuitAction::Destroy { + label: "main".into() + }, + QuitAction::Exit + ] + ); + // A `done` for a window that is not the current one changes nothing. + assert_eq!(quit.window_done("ws-9"), Vec::new()); + } + + #[test] + fn a_window_that_leaves_mid_vote_does_not_hold_the_quit_open() { + let mut quit = QuitMachine::default(); + quit.request(&labels(&["main", "ws-2"])); + quit.vote("main"); + assert_eq!( + quit.forget_window("ws-2"), + vec![QuitAction::Teardown { + label: "main".into(), + last: true + }] + ); + } + + #[test] + fn a_window_that_leaves_mid_walk_advances_the_order() { + let mut quit = QuitMachine::default(); + quit.request(&labels(&["main", "ws-2", "ws-3"])); + quit.vote("main"); + quit.vote("ws-2"); + let actions = quit.vote("ws-3"); + let QuitAction::Teardown { label: first, .. } = &actions[0] else { + panic!("expected a teardown"); + }; + // Whoever is being torn down vanishes: the next one starts. + let next = quit.forget_window(first); + assert!(matches!(next.as_slice(), [QuitAction::Teardown { .. }])); + } + + #[test] + fn a_per_window_close_tracks_its_own_ack_and_supersedes_itself() { + let mut close = CloseMachine::default(); + let first = close.request("ws-2"); + assert!(!close.acked("ws-2")); + close.ack("ws-2"); + assert!(close.acked("ws-2")); + // A second close request supersedes the first watchdog. + let second = close.request("ws-2"); + assert!(close.stale("ws-2", first)); + assert!(!close.stale("ws-2", second)); + close.clear("ws-2"); + assert!(close.stale("ws-2", second)); + } +} diff --git a/standalone/src-tauri/src/routing.rs b/standalone/src-tauri/src/routing.rs new file mode 100644 index 000000000..31503d08f --- /dev/null +++ b/standalone/src-tauri/src/routing.rs @@ -0,0 +1,464 @@ +//! Which window a sidecar event belongs to, and the label bookkeeping around it. +//! +//! The sidecar has no window concept: it emits one stream of events for every +//! PTY in the process. Rust owns the map from PTY to window +//! (docs/specs/standalone.md -> "Windows"), and everything here is pure so the +//! whole table can be exercised without a Tauri app. + +use serde::Serialize; +use serde_json::Value as JsonValue; +use std::collections::HashMap; +use std::time::{Duration, Instant}; + +/// The first window's label, fixed in `tauri.conf.json` so a snapshot written +/// before this build still restores into the same file. +pub const MAIN_LABEL: &str = "main"; +/// Every torn-out window is `ws-`. +pub const WS_LABEL_PREFIX: &str = "ws-"; +/// How many saved windows a boot reopens. The excess stays on disk. +pub const MAX_RESTORED_WINDOWS: usize = 8; +/// How long a transfer may suppress a PTY's output before the suppression is +/// assumed lost and released (fail open: duplicated bytes beat a dead pane). +pub const AWAITING_REPLAY_MAX: Duration = Duration::from_secs(5); + +/// Where one sidecar event goes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Route { + /// To exactly this window label. + EmitTo(String), + /// To every window. Correlation is per-adapter random, so a broadcast + /// reaches the one adapter waiting on it and no other can mistake it + /// (the argument `docs/specs/vscode.md` -> "Peer surfaces across windows" + /// makes for its own fan-out). + Broadcast, + /// Suppressed: the id is mid-transfer and its bytes are already in the + /// replay the new owner is about to receive. + Drop, + /// A `dor` control request naming a Surface no window owns. Answered with + /// an error rather than handed to a sibling, which would act on the wrong + /// terminal (docs/specs/dor-cli.md -> "Control socket"). + UnownedSurface { + request_id: String, + surface_id: String, + }, +} + +/// The routing table's read-only view of `WindowState`. Borrowed, never +/// copied: this runs once per PTY chunk. +pub struct RouteView<'a> { + pub owners: &'a HashMap, + /// Ids mid-transfer, each with the instant its suppression began. + pub awaiting_replay: &'a HashMap, + /// Most recently focused window, or none while nothing has been focused. + pub focused: Option<&'a str>, +} + +fn str_field<'a>(data: &'a JsonValue, key: &str) -> Option<&'a str> { + data.get(key).and_then(JsonValue::as_str) +} + +fn owner_route(view: &RouteView, id: &str) -> Route { + match view.owners.get(id) { + Some(label) => Route::EmitTo(label.clone()), + // An id nobody minted is not a routing decision anyone can make; a + // broadcast is what the single-window build always did. + None => Route::Broadcast, + } +} + +/// The one decision every sidecar stdout line passes through. +pub fn route(event: &str, data: &JsonValue, view: &RouteView) -> Route { + match event { + // Terminal traffic, keyed by the PTY it came from. + "pty:data" | "terminal:semanticEvents" | "terminal:protocolEvents" => { + let Some(id) = str_field(data, "id") else { + return Route::Broadcast; + }; + if view.awaiting_replay.contains_key(id) { + return Route::Drop; + } + owner_route(view, id) + } + // Never suppressed: a replay is exactly what the suppression is waiting + // for, and the caller lifts the suppression after this emit. + "pty:exit" | "pty:replay" => { + let Some(id) = str_field(data, "id") else { + return Route::Broadcast; + }; + owner_route(view, id) + } + // The list answers one window's `pty:requestInit`, which named itself. + "pty:list" => match str_field(data, "forWindow") { + Some(label) => Route::EmitTo(label.to_string()), + None => Route::Broadcast, + }, + "dor:controlRequest" => { + let Some(surface_id) = str_field(data, "surfaceId") else { + // A request with no Surface (e.g. `dor list`) belongs to + // whichever window the user is looking at. + return match view.focused { + Some(label) => Route::EmitTo(label.to_string()), + None => Route::Broadcast, + }; + }; + match view.owners.get(surface_id) { + Some(label) => Route::EmitTo(label.clone()), + None => Route::UnownedSurface { + request_id: str_field(data, "requestId").unwrap_or_default().to_string(), + surface_id: surface_id.to_string(), + }, + } + } + // `alert:*` carrying an id is about one Session; the two app-global + // stores (settings, watched commands) carry none and reach everyone. + _ if event.starts_with("alert:") => match str_field(data, "id") { + Some(id) => owner_route(view, id), + None => Route::Broadcast, + }, + _ => Route::Broadcast, + } +} + +/// Release every suppression older than `max`, returning what was released. +/// +/// Fail open: a transfer whose `adopt_ready` never arrived would otherwise +/// silence its panes for the rest of the session. +pub fn sweep_awaiting( + map: &mut HashMap, + now: Instant, + max: Duration, +) -> Vec { + let stale: Vec = map + .iter() + .filter(|(_, at)| now.duration_since(**at) >= max) + .map(|(id, _)| id.clone()) + .collect(); + for id in &stale { + map.remove(id); + } + stale +} + +/// The next `ws-`, above every label given — live windows and saved +/// snapshots alike, so a torn-out window can never claim a saved window's file. +pub fn seed_next_ws(labels: impl IntoIterator>) -> u64 { + let mut max = 0u64; + for label in labels { + if let Some(n) = ws_index(label.as_ref()) { + max = max.max(n); + } + } + max + 1 +} + +/// `ws-4` -> 4; anything else -> None. +pub fn ws_index(label: &str) -> Option { + label.strip_prefix(WS_LABEL_PREFIX)?.parse::().ok() +} + +/// The windows a boot reopens, from the file names in the sessions directory: +/// `main` first, then `ws-` in numeric order. Temps and foreign names are +/// dropped; the caller caps the list and logs what it left behind. +pub fn restorable_labels(file_names: impl IntoIterator>) -> Vec { + let mut ws: Vec<(u64, String)> = Vec::new(); + let mut has_main = false; + for name in file_names { + let name = name.as_ref(); + // `.json.tmp` also ends with `.tmp`, so strip on the full suffix and a + // temp never survives to become a label. + let Some(label) = name.strip_suffix(".json") else { + continue; + }; + if label == MAIN_LABEL { + has_main = true; + } else if let Some(index) = ws_index(label) { + ws.push((index, label.to_string())); + } + } + ws.sort_by_key(|(index, _)| *index); + let mut labels: Vec = Vec::with_capacity(ws.len() + 1); + if has_main { + labels.push(MAIN_LABEL.to_string()); + } + labels.extend(ws.into_iter().map(|(_, label)| label)); + labels +} + +/// Teardown order for a quit: `main` last, because it is the only window +/// granted the updater permissions and so the only one that may install. +pub fn quit_order(labels: impl IntoIterator>) -> Vec { + let mut order: Vec = Vec::new(); + let mut has_main = false; + for label in labels { + if label.as_ref() == MAIN_LABEL { + has_main = true; + } else { + order.push(label.as_ref().to_string()); + } + } + if has_main { + order.push(MAIN_LABEL.to_string()); + } + order +} + +/// Whether `point` (physical, screen space) is inside a window's outer rect. +pub fn rect_contains(origin: (i32, i32), size: (u32, u32), point: (f64, f64)) -> bool { + let (x, y) = origin; + let (w, h) = size; + point.0 >= f64::from(x) + && point.1 >= f64::from(y) + && point.0 < f64::from(x) + f64::from(w) + && point.1 < f64::from(y) + f64::from(h) +} + +/// One window as the cursor hit test sees it. +pub struct WindowRect { + pub label: String, + pub origin: (i32, i32), + pub size: (u32, u32), + pub scale: f64, + /// Minimized or hidden windows are not under anything. + pub hittable: bool, +} + +/// Where the cursor is, in the hit window's own logical client space. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct CursorHit { + pub label: String, + pub x: f64, + pub y: f64, +} + +/// The window under `point`, preferring the most recently focused of the +/// windows containing it — Tauri exposes no z-order, and focus order is the +/// closest stand-in (the hover caret makes a wrong guess visible before +/// release). +pub fn window_at( + rects: &[WindowRect], + focus_order: &[String], + point: (f64, f64), +) -> Option { + let containing: Vec<&WindowRect> = rects + .iter() + .filter(|rect| rect.hittable && rect_contains(rect.origin, rect.size, point)) + .collect(); + let best = focus_order + .iter() + .find_map(|label| containing.iter().find(|rect| &rect.label == label).copied()) + .or_else(|| containing.first().copied())?; + Some(CursorHit { + label: best.label.clone(), + x: (point.0 - f64::from(best.origin.0)) / best.scale, + y: (point.1 - f64::from(best.origin.1)) / best.scale, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn owners(pairs: &[(&str, &str)]) -> HashMap { + pairs + .iter() + .map(|(id, label)| ((*id).to_string(), (*label).to_string())) + .collect() + } + + fn awaiting(ids: &[&str]) -> HashMap { + let now = Instant::now(); + ids.iter().map(|id| ((*id).to_string(), now)).collect() + } + + /// Every row of the routing table (docs/specs/standalone.md -> "Windows"). + #[test] + fn routes_every_sidecar_event_to_its_window() { + let owned = owners(&[("a", "main"), ("b", "ws-2")]); + let none = awaiting(&[]); + let view = RouteView { + owners: &owned, + awaiting_replay: &none, + focused: Some("ws-2"), + }; + let cases: &[(&str, JsonValue, Route)] = &[ + ("pty:data", json!({"id":"a"}), Route::EmitTo("main".into())), + ("pty:data", json!({"id":"b"}), Route::EmitTo("ws-2".into())), + // An id nobody minted falls back to the single-window behavior. + ("pty:data", json!({"id":"zz"}), Route::Broadcast), + ( + "terminal:semanticEvents", + json!({"id":"b"}), + Route::EmitTo("ws-2".into()), + ), + ( + "terminal:protocolEvents", + json!({"id":"a"}), + Route::EmitTo("main".into()), + ), + ("pty:exit", json!({"id":"b"}), Route::EmitTo("ws-2".into())), + ("pty:replay", json!({"id":"a"}), Route::EmitTo("main".into())), + ( + "pty:list", + json!({"forWindow":"ws-2","ptys":[]}), + Route::EmitTo("ws-2".into()), + ), + ("pty:list", json!({"ptys":[]}), Route::Broadcast), + ( + "alert:state", + json!({"id":"a"}), + Route::EmitTo("main".into()), + ), + ("alert:settings", json!({"speech":true}), Route::Broadcast), + ( + "dor:controlRequest", + json!({"requestId":"dor-1","surfaceId":"b"}), + Route::EmitTo("ws-2".into()), + ), + // No Surface named: the focused window answers. + ( + "dor:controlRequest", + json!({"requestId":"dor-2"}), + Route::EmitTo("ws-2".into()), + ), + ("dor:controlCancel", json!({"requestId":"dor-2"}), Route::Broadcast), + ("burrow:ask", json!({"burrowRequestId":"ask-1"}), Route::Broadcast), + ("burrow:result", json!({}), Route::Broadcast), + ("burrow:event", json!({}), Route::Broadcast), + ]; + for (event, data, expected) in cases { + assert_eq!(&route(event, data, &view), expected, "event {event} {data}"); + } + } + + #[test] + fn an_unowned_dor_surface_is_an_error_never_a_sibling() { + let owned = owners(&[("a", "main")]); + let none = awaiting(&[]); + let view = RouteView { + owners: &owned, + awaiting_replay: &none, + focused: Some("main"), + }; + assert_eq!( + route( + "dor:controlRequest", + &json!({"requestId":"dor-9","surfaceId":"gone"}), + &view + ), + Route::UnownedSurface { + request_id: "dor-9".into(), + surface_id: "gone".into() + } + ); + } + + #[test] + fn a_transferring_pty_is_suppressed_until_its_replay() { + let owned = owners(&[("a", "ws-2")]); + let held = awaiting(&["a"]); + let none = awaiting(&[]); + let suppressed = RouteView { + owners: &owned, + awaiting_replay: &held, + focused: None, + }; + assert_eq!(route("pty:data", &json!({"id":"a"}), &suppressed), Route::Drop); + // The replay itself is never suppressed — it is what is being waited for. + assert_eq!( + route("pty:replay", &json!({"id":"a"}), &suppressed), + Route::EmitTo("ws-2".into()) + ); + // Once the replay has been emitted the suppression is lifted and live + // data reaches the new owner, behind the replay it belongs after. + let released = RouteView { + owners: &owned, + awaiting_replay: &none, + focused: None, + }; + assert_eq!( + route("pty:data", &json!({"id":"a"}), &released), + Route::EmitTo("ws-2".into()) + ); + } + + #[test] + fn a_stale_suppression_fails_open() { + let mut map = HashMap::new(); + let now = Instant::now(); + map.insert("old".to_string(), now - Duration::from_secs(9)); + map.insert("fresh".to_string(), now); + let swept = sweep_awaiting(&mut map, now, AWAITING_REPLAY_MAX); + assert_eq!(swept, vec!["old".to_string()]); + assert!(map.contains_key("fresh")); + } + + #[test] + fn the_next_ws_label_clears_every_live_and_saved_one() { + assert_eq!(seed_next_ws(["main", "ws-2", "ws-7", "ws-x"]), 8); + assert_eq!(seed_next_ws(Vec::::new()), 1); + assert_eq!(seed_next_ws(["main"]), 1); + } + + #[test] + fn restorable_labels_put_main_first_and_skip_temps() { + let labels = restorable_labels([ + "ws-10.json", + "main.json.tmp", + "notepad-archive-v1.json", + "ws-2.json", + "main.json", + "ws-2.json.tmp", + ]); + assert_eq!(labels, vec!["main", "ws-2", "ws-10"]); + } + + #[test] + fn restorable_labels_without_main_still_restore() { + assert_eq!(restorable_labels(["ws-3.json"]), vec!["ws-3"]); + } + + #[test] + fn quit_walks_main_last() { + assert_eq!( + quit_order(["main", "ws-2", "ws-5"]), + vec!["ws-2", "ws-5", "main"] + ); + assert_eq!(quit_order(["ws-2"]), vec!["ws-2"]); + assert_eq!(quit_order(["main"]), vec!["main"]); + } + + fn rect(label: &str, origin: (i32, i32), size: (u32, u32), hittable: bool) -> WindowRect { + WindowRect { + label: label.to_string(), + origin, + size, + scale: 2.0, + hittable, + } + } + + #[test] + fn the_hit_test_prefers_focus_skips_minimized_and_reports_client_logical_coords() { + let rects = vec![ + rect("main", (0, 0), (800, 600), true), + rect("ws-2", (0, 0), (800, 600), true), + rect("ws-3", (0, 0), (800, 600), false), + ]; + let hit = window_at(&rects, &["ws-2".into(), "main".into()], (200.0, 100.0)).unwrap(); + assert_eq!(hit.label, "ws-2"); + // Physical screen point -> the hit window's own logical client space. + assert_eq!((hit.x, hit.y), (100.0, 50.0)); + + // Nothing focused that contains the point: the first containing window. + let hit = window_at(&rects, &["ws-3".into()], (10.0, 10.0)).unwrap(); + assert_eq!(hit.label, "main"); + + // Outside every window. + assert_eq!(window_at(&rects, &[], (5000.0, 10.0)), None); + + // A minimized window alone under the cursor is not a target. + let only_minimized = vec![rect("ws-3", (0, 0), (800, 600), false)]; + assert_eq!(window_at(&only_minimized, &[], (10.0, 10.0)), None); + } +} diff --git a/standalone/src-tauri/tauri.conf.json b/standalone/src-tauri/tauri.conf.json index 4d1dd3ef7..e9b7b5056 100644 --- a/standalone/src-tauri/tauri.conf.json +++ b/standalone/src-tauri/tauri.conf.json @@ -13,6 +13,7 @@ "app": { "windows": [ { + "label": "main", "title": "Dormouse Terminal", "titleBarStyle": "Overlay", "hiddenTitle": true, diff --git a/standalone/src/main.tsx b/standalone/src/main.tsx index 0de951acd..577aac98b 100644 --- a/standalone/src/main.tsx +++ b/standalone/src/main.tsx @@ -4,6 +4,7 @@ import { setPlatform } from "dormouse-lib/lib/platform"; import { installPeerSurfaceResponder } from "dormouse-lib/remote/burrow/peer-surfaces"; import type { PlatformAdapter } from "dormouse-lib/lib/platform/types"; import { restoreWindowOrFresh } from "./window-restore"; +import { isMainWindow, resolveWindowLabel } from "./window-label"; import { seedShellStore } from "dormouse-lib/lib/shell-store"; import { restoreActiveTheme } from "dormouse-lib/lib/themes"; import App from "dormouse-lib/App"; @@ -82,6 +83,9 @@ async function createPlatform(): Promise { // Await init() first to register event listeners before reconnecting async function bootstrap() { + // First: several modules below key off which window this is, and the Rust + // commands are all keyed by the invoking window's label. + await resolveWindowLabel(); const platform = await createPlatform(); setPlatform(platform); await platform.init(); @@ -126,7 +130,10 @@ async function bootstrap() { const initialPlans = await restoreWindowOrFresh(platform); - startUpdateCheck(); + // `main` is the only window holding `updater:*` and it is the last one the + // quit walk tears down, so it is the only one that may check or install + // (docs/specs/auto-update.md). + if (isMainWindow()) startUpdateCheck(); createRoot(document.getElementById("root")!).render( diff --git a/standalone/src/quit.test.ts b/standalone/src/quit.test.ts index 22c9dce3b..ba3e95f3b 100644 --- a/standalone/src/quit.test.ts +++ b/standalone/src/quit.test.ts @@ -77,7 +77,7 @@ function fakeAdapter(order: string[] = [], overrides: Partial { await adapter.captureAgentRecovery(DEFAULT_RECOVERY_WAIT_MS).catch((err) => console.warn("[quit] agent recovery capture failed; proceeding", err)); await adapter.requestSessionFlush(PRE_KILL_FLUSH_MS); // save while PTYs are alive - await adapter.gracefulKillAllPtys(GRACEFUL_KILL_MS); // SIGTERM; wait for exits and final output + await adapter.gracefulKillPtys(GRACEFUL_KILL_MS); // SIGTERM; wait for exits and final output // Final post-exit save. Nothing left to probe a cwd from, and each pane // keeps the one the save above recorded. await adapter.requestSessionFlush(POST_KILL_FLUSH_MS, { probeCwd: false }); diff --git a/standalone/src/tauri-adapter.ts b/standalone/src/tauri-adapter.ts index d3000ec97..26e0b83f7 100644 --- a/standalone/src/tauri-adapter.ts +++ b/standalone/src/tauri-adapter.ts @@ -350,13 +350,20 @@ export class TauriAdapter implements PlatformAdapter { return this.cwdBatch(ids); } - // Warn-and-proceed: a stalled graceful kill must not wedge a quit teardown. - // Callers own the timeout — the teardown bounds live in one place, quit.ts. - async gracefulKillAllPtys(timeoutMs: number): Promise { + /** + * SIGTERM this window's PTYs and wait for their exits and final output. + * + * Scoped to the calling window either way: Rust intersects `ids` with what + * this window owns, and omitting them takes exactly that set + * (`docs/specs/standalone.md` -> "Windows"). Warn-and-proceed, because a + * stalled kill must not wedge a teardown; callers own the timeout, so the + * bounds live in one place (`quit.ts`). + */ + async gracefulKillPtys(timeoutMs: number, ids?: string[]): Promise { try { - await rawInvoke("pty_graceful_kill_all", { timeout: timeoutMs }); + await rawInvoke("pty_graceful_kill", { ids: ids ?? null, timeout: timeoutMs }); } catch (err) { - console.warn("[tauri-adapter] gracefulKillAllPtys failed; proceeding", err); + console.warn("[tauri-adapter] gracefulKillPtys failed; proceeding", err); } } diff --git a/standalone/src/window-label.ts b/standalone/src/window-label.ts new file mode 100644 index 000000000..e0bbcc065 --- /dev/null +++ b/standalone/src/window-label.ts @@ -0,0 +1,40 @@ +/** + * Which window this webview is, resolved once at boot. + * + * The Tauri label is a Window's persistence identity (`docs/specs/glossary.md`), + * and `main` is the only window granted the updater permissions + * (`docs/specs/auto-update.md`), so several modules need the answer + * synchronously after boot. The browser-dev harness has no windows at all and + * answers `main`. + */ + +export const MAIN_WINDOW_LABEL = 'main'; + +let label = MAIN_WINDOW_LABEL; + +/** Read the host's answer. Idempotent; called once from `bootstrap()`. */ +export async function resolveWindowLabel(): Promise { + if (import.meta.env.VITE_DORMOUSE_BROWSER_DEV_HOST) return label; + try { + const { getCurrentWindow } = await import('@tauri-apps/api/window'); + label = getCurrentWindow().label; + } catch (err) { + console.error('[dormouse] could not resolve the window label; assuming main', err); + } + return label; +} + +export function currentWindowLabel(): string { + return label; +} + +/** The window the quit walk tears down last, and the only one that may install + * an update or check for one. */ +export function isMainWindow(): boolean { + return label === MAIN_WINDOW_LABEL; +} + +/** @internal Set the label directly (tests). */ +export function _setWindowLabelForTesting(next: string): void { + label = next; +} From 3e93ed3d7654a3b87b9a774359f07df735182436 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 9 Sep 2026 23:44:36 -0700 Subject: [PATCH 02/36] Scope the sidecar's list and kill, and collect one ask answer per window `list` and `gracefulKill` take an optional id set, following the same "omitted is not empty" rule `interrupt` already carries, and `list` echoes the window that asked so the host can route the list and every replay behind it back to it alone. A window tearing down can now kill exactly its own PTYs. The Burrow's asks collect until every window has answered (or the budget runs out) instead of settling on the first: each window sees only its own Workspaces, so a directory built from one answer would silently omit the rest. `burrow:windows` pushes the count, and lowering it settles asks a closed window can no longer answer. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe --- lib/src/host/remote/sidecar-entry.test.ts | 51 ++++++++- lib/src/host/remote/sidecar-entry.ts | 68 +++++++++--- standalone/sidecar/main.js | 9 +- standalone/sidecar/pty-core.js | 62 ++++++++--- standalone/sidecar/pty-core.test.js | 123 ++++++++++++++++++++++ 5 files changed, 276 insertions(+), 37 deletions(-) diff --git a/lib/src/host/remote/sidecar-entry.test.ts b/lib/src/host/remote/sidecar-entry.test.ts index 4f0cb4237..583eaec30 100644 --- a/lib/src/host/remote/sidecar-entry.test.ts +++ b/lib/src/host/remote/sidecar-entry.test.ts @@ -83,15 +83,62 @@ describe('asking the webview', () => { expect(await pending).toEqual([{ surfaceId: 's1' }]); }); - it('settles on the first answer and ignores a later one', async () => { - // Standalone ships one window, so one answerer; a second is a stale reply. + it('settles on the one answer while one window is open', async () => { const pending = bridge.provider.collectDirectory(); const ask = asks()[0]!; answer(ask, [{ surfaceId: 'first' }]); + // Settled: a later reply is stale and cannot reopen it. answer(ask, [{ surfaceId: 'second' }]); expect(await pending).toEqual([{ surfaceId: 'first' }]); }); + it('collects one answer per window and concatenates them', async () => { + // Each window sees only its own Workspaces, so a directory built from the + // first answer would list one window's panes and omit the rest. + bridge.setWindowCount(2); + const pending = bridge.provider.collectDirectory(); + const ask = asks()[0]!; + answer(ask, [{ surfaceId: 'in-main' }]); + answer(ask, [{ surfaceId: 'in-ws-2' }]); + expect(await pending).toEqual([{ surfaceId: 'in-main' }, { surfaceId: 'in-ws-2' }]); + }); + + it('answers with what it has when a window never replies', async () => { + vi.useFakeTimers(); + bridge.setWindowCount(3); + const pending = bridge.provider.collectDirectory(); + const ask = asks()[0]!; + answer(ask, [{ surfaceId: 'in-main' }]); + await vi.advanceTimersByTimeAsync(ASK_BUDGET_MS); + // A partial directory beats an empty one; the next change re-collects. + expect(await pending).toEqual([{ surfaceId: 'in-main' }]); + }); + + it('a window closing mid-fan-out settles the ask instead of holding it open', async () => { + bridge.setWindowCount(2); + const pending = bridge.provider.collectDirectory(); + const ask = asks()[0]!; + answer(ask, [{ surfaceId: 'in-main' }]); + // The second window went away without answering. + bridge.setWindowCount(1); + expect(await pending).toEqual([{ surfaceId: 'in-main' }]); + }); + + it('a window opening mid-fan-out never received the ask, so it is not waited on', async () => { + const pending = bridge.provider.collectDirectory(); + const ask = asks()[0]!; + bridge.setWindowCount(2); + answer(ask, [{ surfaceId: 'in-main' }]); + expect(await pending).toEqual([{ surfaceId: 'in-main' }]); + }); + + it('ignores a window count that is not a usable number', async () => { + for (const bad of [0, -1, Number.NaN, '2', undefined, null]) bridge.setWindowCount(bad); + const pending = bridge.provider.collectDirectory(); + answer(asks()[0]!, [{ surfaceId: 's1' }]); + expect(await pending).toEqual([{ surfaceId: 's1' }]); + }); + it('gives up at the budget rather than hanging', async () => { vi.useFakeTimers(); const pending = bridge.provider.collectDirectory(); diff --git a/lib/src/host/remote/sidecar-entry.ts b/lib/src/host/remote/sidecar-entry.ts index 20af75092..8e3a5951a 100644 --- a/lib/src/host/remote/sidecar-entry.ts +++ b/lib/src/host/remote/sidecar-entry.ts @@ -54,8 +54,11 @@ export interface SidecarSurfaceBridgeOptions { export interface SidecarSurfaceBridge { provider: BurrowSurfaceProvider; - /** An `answer` command: settles the ask it names. */ + /** An `answer` command: contributes to the ask it names. */ onAnswer(params: AnswerParams | undefined): void; + /** How many webviews will answer an ask. Pushed by the host on every window + * create and destroy (`docs/specs/standalone.md` -> "Burrow service"). */ + setWindowCount(count: unknown): void; /** A `notify` command: something the directory depends on changed. */ onNotify(): void; /** @@ -83,29 +86,46 @@ export function createSidecarSurfaceBridge( options: SidecarSurfaceBridgeOptions, ): SidecarSurfaceBridge { interface PendingAsk { - settle(results: unknown[]): void; + /** Every answering window's results, concatenated. */ + results: unknown[]; + /** How many windows have answered. A window answering nothing still counts: + * what settles the ask is having heard from everyone, not having found + * anything. */ + answered: number; + /** How many answers this ask still expects. Set from the window count when + * the ask was sent, and only ever LOWERED: a window that closed mid-fan-out + * will never answer, while one that opened never received the ask. */ + expected: number; + settle(): void; } const asks = new Map(); let askSeq = 0; + /** How many webviews the host says will answer. One until it says otherwise, + * which is also what the browser-dev harness and the tests get. */ + let windowCount = 1; function ask(op: string, params: unknown): Promise { const burrowRequestId = `ask-${++askSeq}`; return new Promise((resolve) => { + const pending: PendingAsk = { + results: [], + answered: 0, + expected: windowCount, + settle: () => { + clearTimeout(timer); + asks.delete(burrowRequestId); + resolve(pending.results); + }, + }; const timer = setTimeout(() => { // Budget spent. An attach must not hang on a webview that is reloading, // and a directory that missed a pane re-collects on the next change. - asks.delete(burrowRequestId); - resolve([]); + // Whatever did answer is still the best available snapshot. + pending.settle(); }, ASK_BUDGET_MS); // An outstanding ask must never hold the sidecar's event loop open. (timer as unknown as { unref?: () => void }).unref?.(); - asks.set(burrowRequestId, { - settle: (results) => { - clearTimeout(timer); - asks.delete(burrowRequestId); - resolve(results); - }, - }); + asks.set(burrowRequestId, pending); options.send(BURROW_ASK_EVENT, { burrowRequestId, op, params }); }); } @@ -228,10 +248,9 @@ export function createSidecarSurfaceBridge( provider, /** - * The first answer settles the ask. Standalone ships one window, so there is - * exactly one answerer today; the multi-window seam - * (docs/specs/standalone.md) is where this becomes "collect until the - * budget". + * Collect until every window has answered, or the budget runs out. Each + * window sees only its own Workspaces, so a directory built from the first + * answer would list one window's panes and silently omit the rest. */ onAnswer(params) { if (!params || typeof params.burrowRequestId !== 'string') return; @@ -246,7 +265,20 @@ export function createSidecarSurfaceBridge( notifyDirectoryChanged(); return; } - pending.settle(Array.isArray(params.results) ? params.results : []); + pending.answered += 1; + if (Array.isArray(params.results)) pending.results.push(...params.results); + if (pending.answered >= pending.expected) pending.settle(); + }, + + setWindowCount(count) { + if (typeof count !== 'number' || !Number.isFinite(count) || count < 1) return; + windowCount = Math.floor(count); + // Re-evaluate what is already out: a window that closed mid-fan-out can + // never answer, and must not hold an ask open to its whole budget. + for (const pending of [...asks.values()]) { + pending.expected = Math.min(pending.expected, windowCount); + if (pending.answered >= pending.expected) pending.settle(); + } }, onNotify() { @@ -306,7 +338,7 @@ export function createSidecarSurfaceBridge( }, dispose() { - for (const pending of [...asks.values()]) pending.settle([]); + for (const pending of [...asks.values()]) pending.settle(); asks.clear(); streams.clear(); exits.clear(); @@ -328,6 +360,7 @@ export interface SidecarBurrow { handleCommand(data: unknown): void; onPtyEvent(event: string, data: unknown): void; onPtySpawn(id: unknown): void; + setWindowCount(count: unknown): void; setThemeColors(colors: unknown): void; dispose(): void; } @@ -365,6 +398,7 @@ export function createSidecarBurrow(options: SidecarBurrowOptions): SidecarBurro }, onPtyEvent: bridge.onPtyEvent, onPtySpawn: bridge.onPtySpawn, + setWindowCount: bridge.setWindowCount, setThemeColors: bridge.setThemeColors, dispose() { service.dispose(); diff --git a/standalone/sidecar/main.js b/standalone/sidecar/main.js index 0cbf13994..4db087b59 100644 --- a/standalone/sidecar/main.js +++ b/standalone/sidecar/main.js @@ -138,7 +138,9 @@ function handleLine(line) { case 'pty:input': mgr.write(data.id, data.data); break; case 'pty:resize': mgr.resize(data.id, data.cols, data.rows); break; case 'pty:kill': mgr.kill(data.id); break; - case 'pty:requestInit': mgr.list(); break; + // One window's own PTYs, and the answer names it so the host can route + // the list and every replay behind it back (docs/specs/standalone.md). + case 'pty:requestInit': mgr.list(data?.ids, data?.forWindow); break; case 'pty:context': mgr.context(data, data.requestId); break; case 'pty:getCwd': mgr.getCwd(data.id, data.requestId); break; case 'pty:getCwds': mgr.getCwds(data.ids, data.requestId); break; @@ -173,9 +175,12 @@ function handleLine(line) { commands: recovery.take(Array.isArray(data.paneIds) ? data.paneIds : []), })); break; - case 'pty:gracefulKillAll': mgr.gracefulKillAll(data.timeout, data.requestId); break; + case 'pty:gracefulKill': mgr.gracefulKill(data.ids, data.timeout, data.requestId); break; // The webview's resolved terminal theme, so the parser here can answer // OSC 10/11/12 (docs/specs/terminal-escapes.md → Supported OSCs). + // How many webviews will answer a Burrow ask (docs/specs/standalone.md + // -> "Burrow service"). + case 'burrow:windows': burrow.setWindowCount(data?.count); break; case 'pty:themeColors': burrow.setThemeColors(data); break; case 'sidecar:shutdown': shutdown(); break; case 'dor:controlResponse': dorControl?.respond(data); break; diff --git a/standalone/sidecar/pty-core.js b/standalone/sidecar/pty-core.js index 8d1f52c5a..d6ddb304c 100644 --- a/standalone/sidecar/pty-core.js +++ b/standalone/sidecar/pty-core.js @@ -1186,6 +1186,12 @@ module.exports.create = function create(send, ptyModule, { replay = false, slice ptyShells.set(id, config.shell); p.onData((data) => { + // Appended BEFORE the send, and synchronously. Two consumers depend on + // that order: the replay a reconnecting webview reads, and a Workspace + // transfer, whose host suppresses this id's output the instant it + // reassigns ownership and then asks for `list([id])` — so the chunk it + // suppressed has to already be in the buffer the replay is built from, + // exactly once (docs/specs/transport.md -> "Transferring a Workspace"). if (replay && ptys.get(id) === p) { session.chunks.push(data); session.chars += data.length; @@ -1295,13 +1301,24 @@ module.exports.create = function create(send, ptyModule, { replay = false, slice sessions.clear(); } - function list() { - const result = []; - for (const [id] of ptys) { - result.push({ id, alive: true, shell: ptyShells.get(id), ...(helpers.has(id) ? { helper: helpers.get(id) } : {}) }); - } - send('list', { ptys: result }); - if (replay) for (const { id } of result) send('replay', { id, data: sessions.get(id).chunks.join('') }); + /** + * List (and, where this host buffers, replay) live PTYs. + * + * `ids` omitted is every live PTY; an empty array is an empty list — the same + * "omitted is not empty" rule `interrupt` carries, and for the same reason: a + * caller forwarding a computed set that came out empty must get a no-op + * rather than everything. `forWindow` is echoed on the list and on each + * replay so the host can route both back to the window that asked + * (docs/specs/standalone.md -> "Windows"). + */ + function list(ids, forWindow) { + const targets = Array.isArray(ids) ? ids.filter((id) => ptys.has(id)) : [...ptys.keys()]; + const result = targets.map((id) => ({ + id, alive: true, shell: ptyShells.get(id), ...(helpers.has(id) ? { helper: helpers.get(id) } : {}), + })); + const addressed = forWindow ? { forWindow } : {}; + send('list', { ptys: result, ...addressed }); + if (replay) for (const { id } of result) send('replay', { id, data: sessions.get(id).chunks.join(''), ...addressed }); } // Only explicit settings edits write this installation-global preference. No @@ -1423,33 +1440,46 @@ module.exports.create = function create(send, ptyModule, { replay = false, slice if (requestId !== undefined) send('interruptDone', { requestId }); } - function gracefulKillAll(timeout = 2000, requestId) { + /** + * SIGTERM `ids` (omitted: every live PTY) and resolve once they have exited. + * + * Scoped rather than blanket because one window of several tears down alone, + * and killing a sibling's terminals is unrecoverable. `ids` follows the same + * "omitted is not empty" rule as `interrupt` and `list`. + */ + function gracefulKill(ids, timeout = 2000, requestId) { const done = () => send('gracefulKillDone', { requestId }); + const targets = (Array.isArray(ids) ? ids : [...ptys.keys()]).filter((id) => ptys.has(id)); // Nothing live to SIGTERM, but a just-exited PTY can still deliver final // output shortly after onExit (notably under ConPTY). Keep the same single // grace tick used after the live map empties before the quit flush runs. - if (ptys.size === 0) { setTimeout(done, 50); return; } - for (const [, p] of ptys) { - try { p.kill('SIGTERM'); } catch { /* already dead */ } + if (targets.length === 0) { setTimeout(done, 50); return; } + for (const id of targets) { + try { ptys.get(id).kill('SIGTERM'); } catch { /* already dead */ } } - // Resolve early once every PTY has exited (onExit empties the map) instead - // of always sitting out the full timeout — but one grace tick after the map - // empties, since ConPTY can fire onExit before the final data flush and that + // Resolve early once every target has exited (onExit removes it) instead + // of always sitting out the full timeout — but one grace tick after the last + // one goes, since ConPTY can fire onExit before the final data flush and that // last output must reach the host first. const deadline = Date.now() + timeout; const tick = () => { - if (ptys.size === 0) setTimeout(done, 50); + if (!targets.some((id) => ptys.has(id))) setTimeout(done, 50); else if (Date.now() >= deadline) done(); else setTimeout(tick, 50); }; setTimeout(tick, 50); } + /** @deprecated Kept for one release so a stale bundle still tears down. */ + function gracefulKillAll(timeout = 2000, requestId) { + gracefulKill(undefined, timeout, requestId); + } + function getShells(requestId) { send('shells', { shells: detectAvailableShells(), requestId }); } return { spawn, write, resize, hasPty, kill, killAll, list, context, - getCwd, getCwds, getOpenPorts, interrupt, gracefulKillAll, getShells, + getCwd, getCwds, getOpenPorts, interrupt, gracefulKill, gracefulKillAll, getShells, liveIds, receivedChars, outputSince }; }; diff --git a/standalone/sidecar/pty-core.test.js b/standalone/sidecar/pty-core.test.js index feeb4ca4a..fafd5eb6e 100644 --- a/standalone/sidecar/pty-core.test.js +++ b/standalone/sidecar/pty-core.test.js @@ -1635,3 +1635,126 @@ test('getCwds answers a key for every requested id, null for one with no PTY', ( // A pane with no live PTY is never scanned for. assert.equal(answer.data.cwds['pane-gone'], null); }); + + +// --- Per-window list / replay / kill (docs/specs/standalone.md -> "Windows") --- + +function fakePtyModule() { + const listeners = new Map(); + const killed = []; + return { + listeners, + killed, + module: { + spawn(shell, args, opts) { + const id = opts?.env?.DORMOUSE_SURFACE_ID; + const handlers = {}; + listeners.set(id, handlers); + return { + pid: 100 + listeners.size, + onData(handler) { handlers.data = handler; }, + onExit(handler) { handlers.exit = handler; }, + resize() {}, + write() {}, + kill(signal) { killed.push([id, signal]); }, + }; + }, + }, + }; +} + +test('list(ids) lists and replays only those ids, naming the window that asked', () => { + const events = []; + const pty = fakePtyModule(); + const mgr = create((event, data) => events.push({ event, data }), pty.module, { replay: true }); + mgr.spawn('a'); + mgr.spawn('b'); + pty.listeners.get('a').data('from a'); + pty.listeners.get('b').data('from b'); + + events.length = 0; + mgr.list(['a'], 'ws-2'); + + assert.equal(events.length, 2); + assert.equal(events[0].event, 'list'); + assert.equal(events[0].data.forWindow, 'ws-2'); + assert.deepEqual(events[0].data.ptys.map((entry) => entry.id), ['a']); + assert.deepEqual(events[1], { event: 'replay', data: { id: 'a', data: 'from a', forWindow: 'ws-2' } }); +}); + +test('list omitted is every PTY; list([]) is an empty list', () => { + const events = []; + const pty = fakePtyModule(); + const mgr = create((event, data) => events.push({ event, data }), pty.module, { replay: true }); + mgr.spawn('a'); + mgr.spawn('b'); + + events.length = 0; + mgr.list(); + assert.deepEqual(events[0].data.ptys.map((p) => p.id), ['a', 'b']); + assert.equal('forWindow' in events[0].data, false); + + // "Omitted" means omitted, never "an empty list" — the same rule `interrupt` + // carries. A caller forwarding a computed set that came out empty gets a + // no-op, not every PTY in the process. + events.length = 0; + mgr.list([], 'ws-2'); + assert.deepEqual(events, [{ event: 'list', data: { ptys: [], forWindow: 'ws-2' } }]); +}); + +// The whole no-duplicate / no-loss argument for a Workspace transfer rests on +// this ordering: `onData` appends to the replay buffer synchronously before it +// emits, so a chunk the host suppressed the instant it saw the `data` event is +// already in the buffer the replay behind it is built from. +test('a chunk emitted just before list([id]) appears in the replay exactly once', () => { + const events = []; + const pty = fakePtyModule(); + let mgr; + const mgrRef = () => mgr; + mgr = create((event, data) => { + events.push({ event, data }); + // The host, on seeing this chunk, reassigns ownership (suppressing further + // output for this id) and immediately asks for the new owner's replay. + if (event === 'data' && data.data === 'mid-transfer') mgrRef().list(['a'], 'ws-2'); + }, pty.module, { replay: true }); + mgr.spawn('a'); + pty.listeners.get('a').data('before\r\n'); + pty.listeners.get('a').data('mid-transfer'); + + const replay = events.find((entry) => entry.event === 'replay'); + assert.ok(replay, 'the transfer asked for a replay'); + assert.equal(replay.data.data, 'before\r\nmid-transfer'); + // Exactly once: the chunk is in the replay, and it was emitted as `data` + // exactly once — the host drops that copy, so the pane never renders it twice. + assert.equal(replay.data.data.split('mid-transfer').length - 1, 1); +}); + +test('gracefulKill targets only the named PTYs', async () => { + const events = []; + const pty = fakePtyModule(); + let resolveDone; + const done = new Promise((resolve) => { resolveDone = resolve; }); + const mgr = create((event, data) => { + events.push({ event, data }); + if (event === 'gracefulKillDone') resolveDone(); + }, pty.module, { replay: true }); + mgr.spawn('a'); + mgr.spawn('b'); + + mgr.gracefulKill(['a'], 1, 'req-1'); + await done; + + assert.deepEqual(pty.killed, [['a', 'SIGTERM']]); + assert.deepEqual(events.at(-1), { event: 'gracefulKillDone', data: { requestId: 'req-1' } }); +}); + +test('gracefulKill([]) kills nothing and still answers', async () => { + const pty = fakePtyModule(); + let resolveDone; + const done = new Promise((resolve) => { resolveDone = resolve; }); + const mgr = create((event) => { if (event === 'gracefulKillDone') resolveDone(); }, pty.module); + mgr.spawn('a'); + mgr.gracefulKill([], 1, 'req-1'); + await done; + assert.deepEqual(pty.killed, []); +}); From 9290ddd348c0ab49d46bacc7480cf5550d0003d2 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 9 Sep 2026 23:52:38 -0700 Subject: [PATCH 03/36] Give each window its own close, and make quit vote before it walks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closing a window with siblings alive now ends that window alone: it acks, asks about its own running work, archives its own notes, removes its snapshot so the next launch does not reopen it, and kills only the PTYs it owns. Only the last window's close is still a quit. The quit itself is vote-then-walk. Every window is asked, and only once all of them agree does Rust tear them down one at a time, `main` last — so a cancel in the last window can no longer leave earlier ones destroyed. The non-last windows hand the walk on instead of exiting, and the install stays in `main`, the one window holding `updater:*`. The confirmation dialog is shared between the two endings and names the window by its visible Workspace while more than one is open. The notepad gate moves to its own module, since both endings run it and a transfer runs neither. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe --- standalone/src-tauri/src/lib.rs | 7 +- standalone/src/QuitConfirmModal.tsx | 37 ++++-- standalone/src/main.tsx | 23 +++- standalone/src/quit-confirm-store.ts | 45 ++++++- standalone/src/quit-notepad.test.ts | 6 +- standalone/src/quit.test.ts | 129 ++++++++++++++++--- standalone/src/quit.ts | 167 +++++++++++++------------ standalone/src/teardown-archive.ts | 42 +++++++ standalone/src/window-close.test.ts | 180 +++++++++++++++++++++++++++ standalone/src/window-close.ts | 123 ++++++++++++++++++ 10 files changed, 638 insertions(+), 121 deletions(-) create mode 100644 standalone/src/teardown-archive.ts create mode 100644 standalone/src/window-close.test.ts create mode 100644 standalone/src/window-close.ts diff --git a/standalone/src-tauri/src/lib.rs b/standalone/src-tauri/src/lib.rs index e0641da67..01486b416 100644 --- a/standalone/src-tauri/src/lib.rs +++ b/standalone/src-tauri/src/lib.rs @@ -272,7 +272,12 @@ fn apply_quit_actions(app: &AppHandle, actions: Vec) { for action in actions { match action { QuitAction::RequestAll => { - let _ = app.emit("dormouse://quit-requested", ()); + // The count is what tells each window whether to name itself in + // its confirmation dialog. + let _ = app.emit( + "dormouse://quit-requested", + serde_json::json!({ "windows": app.webview_windows().len() }), + ); } QuitAction::CancelAll => { let _ = app.emit("dormouse://quit-cancelled", ()); diff --git a/standalone/src/QuitConfirmModal.tsx b/standalone/src/QuitConfirmModal.tsx index 86fc2ef1d..c5b834d5c 100644 --- a/standalone/src/QuitConfirmModal.tsx +++ b/standalone/src/QuitConfirmModal.tsx @@ -12,8 +12,10 @@ import { cancelQuit, confirmQuit, getQuitArchiveError, + getQuitConfirmIntent, getQuitConfirmPhase, subscribeQuitConfirm, + type QuitConfirmIntent, } from './quit-confirm-store'; /** @@ -27,6 +29,7 @@ import { export function QuitConfirmModalHost() { const phase = useSyncExternalStore(subscribeQuitConfirm, getQuitConfirmPhase); const storedArchiveError = useSyncExternalStore(subscribeQuitConfirm, getQuitArchiveError); + const intent = useSyncExternalStore(subscribeQuitConfirm, getQuitConfirmIntent); const open = phase !== null; // Suppress the Wall's command-mode key dispatch while the dialog is up. @@ -37,6 +40,7 @@ export function QuitConfirmModalHost() { ); } @@ -46,12 +50,15 @@ export function QuitConfirmModalHost() { export function QuitConfirmModal({ confirming, archiveError = null, + intent = { kind: 'quit' }, }: { confirming: boolean; - /** The quit the notepad archive refused (docs/specs/notepad.md → "Standalone - * quit"). Set means the running-command decision is already made and this - * dialog now asks only whether to lose the notes. */ + /** The teardown the notepad archive refused (docs/specs/notepad.md → + * "Standalone quit"). Set means the running-command decision is already made + * and this dialog now asks only whether to lose the notes. */ archiveError?: string | null; + /** Whether this asks about the whole app or one window, and which one. */ + intent?: QuitConfirmIntent; }) { const cancelButtonRef = useRef(null); // Live count — the dialog stays open even if it drops to 0 (see spec). @@ -62,17 +69,27 @@ export function QuitConfirmModal({ // decision is already made and the only question left is whether to lose the // notes — so the copy changes and the default swaps to Cancel, stated once // here rather than as five ternaries through the markup. - const title = archiveError ? 'Notes could not be archived' : 'Quit Dormouse?'; + // A quit ends every window; a close ends this one alone. The count and the + // notes are this window's either way — the registry and the notepad store are + // per webview — so only the wording changes. + const closing = intent.kind === 'close-window'; + const verb = closing ? 'Close' : 'Quit'; + // Named only when several windows are open, so a lone window's dialog is not + // made to introduce itself. + const scope = intent.windowName ? `${intent.windowName}: ` : ''; + const title = archiveError + ? 'Notes could not be archived' + : closing ? 'Close this window?' : 'Quit Dormouse?'; const body = archiveError - ? `${archiveError} Quitting anyway discards them.` + ? `${archiveError} ${verb === 'Close' ? 'Closing' : 'Quitting'} anyway discards them.` : confirming - ? 'Quitting…' + ? `${closing ? 'Closing' : 'Quitting'}…` : hasRunning - ? `${runningCount} running command${runningCount === 1 ? '' : 's'} will be stopped.` - : 'No commands are still running.'; + ? `${scope}${runningCount} running command${runningCount === 1 ? '' : 's'} will be stopped.` + : `${scope}No commands are still running.`; const confirmLabel = archiveError - ? 'Quit anyway' - : hasRunning ? `Quit and stop ${runningCount}` : 'Quit'; + ? `${verb} anyway` + : hasRunning ? `${verb} and stop ${runningCount}` : verb; const [cancelTone, confirmTone] = archiveError ? (['primary', 'secondary'] as const) : (['secondary', 'primary'] as const); diff --git a/standalone/src/main.tsx b/standalone/src/main.tsx index 577aac98b..e97575e76 100644 --- a/standalone/src/main.tsx +++ b/standalone/src/main.tsx @@ -5,6 +5,7 @@ import { installPeerSurfaceResponder } from "dormouse-lib/remote/burrow/peer-sur import type { PlatformAdapter } from "dormouse-lib/lib/platform/types"; import { restoreWindowOrFresh } from "./window-restore"; import { isMainWindow, resolveWindowLabel } from "./window-label"; +import { getWorkspacesSnapshot } from "dormouse-lib/lib/workspace-store"; import { seedShellStore } from "dormouse-lib/lib/shell-store"; import { restoreActiveTheme } from "dormouse-lib/lib/themes"; import App from "dormouse-lib/App"; @@ -108,12 +109,22 @@ async function bootstrap() { // Tauri APIs. !BROWSER_DEV_HOST is exactly the createPlatform branch that // returned a TauriAdapter. if (!BROWSER_DEV_HOST) { - const [{ initQuitFlow, setQuitConfirmGate }, { openQuitConfirm }] = await Promise.all([ - import("./quit"), - import("./quit-confirm-store"), - ]); - initQuitFlow(platform as import("./tauri-adapter").TauriAdapter); - // A quit with ≥1 running command opens . + const [{ initQuitFlow, setQuitConfirmGate }, { openQuitConfirm }, { initWindowClose }] = + await Promise.all([ + import("./quit"), + import("./quit-confirm-store"), + import("./window-close"), + ]); + const adapter = platform as import("./tauri-adapter").TauriAdapter; + // The dialogs name a window by its visible Workspace, which is the only + // name a user has for one (§Quit flow, "Confirmation dialog"). + const windowName = () => { + const { workspaces, activeId } = getWorkspacesSnapshot(); + return workspaces.find((workspace) => workspace.id === activeId)?.name; + }; + initQuitFlow(adapter, { windowName }); + initWindowClose(adapter, { windowName }); + // A quit or a close with ≥1 running command opens . setQuitConfirmGate(openQuitConfirm); } const { initAlertStateReceiver } = await import("dormouse-lib/lib/terminal-registry"); diff --git a/standalone/src/quit-confirm-store.ts b/standalone/src/quit-confirm-store.ts index ad218f6da..f3ebedd68 100644 --- a/standalone/src/quit-confirm-store.ts +++ b/standalone/src/quit-confirm-store.ts @@ -9,7 +9,21 @@ import type { QuitConfirmContext } from "./quit"; export type QuitConfirmPhase = "open" | "quitting" | "archive-failed"; +/** + * What the dialog is asking about. A quit tears every window down; a + * close ends this one alone (docs/specs/standalone.md §Per-window close). The + * Workspace name is carried only while several windows are open, so a single + * window's dialog is not made to name itself. + */ +export interface QuitConfirmIntent { + kind: "quit" | "close-window"; + windowName?: string; +} + +const QUIT_INTENT: QuitConfirmIntent = { kind: "quit" }; + let phase: QuitConfirmPhase | null = null; +let intent: QuitConfirmIntent = QUIT_INTENT; // Why the archive gate refused the quit; only set alongside "archive-failed". let archiveError: string | null = null; // The orchestrator context for the open request. Nulled the instant a decision @@ -33,6 +47,11 @@ export function getQuitArchiveError(): string | null { return archiveError; } +/** What the open dialog is asking about. */ +export function getQuitConfirmIntent(): QuitConfirmIntent { + return intent; +} + function emit(): void { for (const listener of listeners) listener(); } @@ -41,9 +60,10 @@ function emit(): void { // bootstrap (order relative to `initQuitFlow` is irrelevant — the gate is read // only at quit time). The orchestrator never re-invokes it while a dialog is // up; the phase guard is belt-and-suspenders against stacking. -export function openQuitConfirm(ctx: QuitConfirmContext): void { +export function openQuitConfirm(ctx: QuitConfirmContext, next: QuitConfirmIntent = QUIT_INTENT): void { if (phase !== null) return; activeCtx = ctx; + intent = next; phase = "open"; emit(); } @@ -56,8 +76,13 @@ export function openQuitConfirm(ctx: QuitConfirmContext): void { * guarded on an empty phase: it is always a transition from a decision already * made. `ctx.confirm()` is Quit anyway (notes discarded); `ctx.cancel()` closes. */ -export function openQuitArchiveFailure(message: string, ctx: QuitConfirmContext): void { +export function openQuitArchiveFailure( + message: string, + ctx: QuitConfirmContext, + next: QuitConfirmIntent = QUIT_INTENT, +): void { activeCtx = ctx; + intent = next; archiveError = message; phase = "archive-failed"; emit(); @@ -85,9 +110,25 @@ export function cancelQuit(): void { ctx.cancel(); } +/** + * Drop the dialog because the decision was made somewhere else — another window + * cancelled the quit for everyone (docs/specs/standalone.md §Quit flow). Unlike + * `cancelQuit` it does NOT call back into the orchestrator: the cancel has + * already happened, and calling back would bounce it around the windows. + */ +export function dismissQuitConfirm(): void { + if (phase === null) return; + activeCtx = null; + archiveError = null; + phase = null; + intent = QUIT_INTENT; + emit(); +} + /** @internal Reset module state for testing. */ export function _resetQuitConfirmForTesting(): void { phase = null; + intent = QUIT_INTENT; activeCtx = null; archiveError = null; listeners.clear(); diff --git a/standalone/src/quit-notepad.test.ts b/standalone/src/quit-notepad.test.ts index 7525fa90f..267225eda 100644 --- a/standalone/src/quit-notepad.test.ts +++ b/standalone/src/quit-notepad.test.ts @@ -8,7 +8,7 @@ vi.mock('@tauri-apps/api/event', () => ({ listen: vi.fn() })); vi.mock('dormouse-lib/lib/terminal-registry', () => ({ countRunningSessions: () => 0 })); vi.mock('./updater', () => ({ hasPendingUpdate: () => false, installPendingUpdate: vi.fn() })); -import { archiveNotesBeforeQuit } from './quit'; +import { archiveNotesBeforeTeardown } from './teardown-archive'; afterEach(() => { vi.useRealTimers(); @@ -30,7 +30,7 @@ it('deletes a landed batch on the next quit after timeout, cancellation, and del await reply; return result; }); - const attempt = archiveNotesBeforeQuit(); + const attempt = archiveNotesBeforeTeardown(); const timedOut = expect(attempt).rejects.toThrow('3s'); await vi.advanceTimersByTimeAsync(3000); await timedOut; @@ -41,6 +41,6 @@ it('deletes a landed batch on the next quit after timeout, cancellation, and del // The user cancelled quit and then removed everything the timed-out save kept. deleteNote('pane-a', noteId!); expect(getNotepadSnapshot().size).toBe(0); - await archiveNotesBeforeQuit(); + await archiveNotesBeforeTeardown(); expect((await port.load())?.raw).toEqual({ version: 1, batches: [] }); }); diff --git a/standalone/src/quit.test.ts b/standalone/src/quit.test.ts index ba3e95f3b..db56dae6f 100644 --- a/standalone/src/quit.test.ts +++ b/standalone/src/quit.test.ts @@ -30,6 +30,8 @@ vi.mock("dormouse-lib/lib/terminal-registry", () => ({ vi.mock("dormouse-lib/lib/notepad/close-coordinator", () => ({ archiveSurfaceNotes: mocks.archiveSurfaceNotes, })); +// The Rust command the close path removes a snapshot with; the quit path never +// calls it (a quit keeps every window's blob, which is what a relaunch reads). vi.mock("dormouse-lib/lib/notepad/notepad-store", () => ({ notepadSurfaceIds: mocks.notepadSurfaceIds, removeSurface: mocks.removeSurface, @@ -52,15 +54,22 @@ import { confirmQuit, getQuitArchiveError, getQuitConfirmPhase, + openQuitConfirm, _resetQuitConfirmForTesting, } from "./quit-confirm-store"; /** One Surface holding notes, as `notepadSurfaceIds` reports it. */ const oneNotedSurface = () => ["pane-a"]; -// The captured `dormouse://quit-requested` listener; call it to simulate Rust -// emitting a quit request. -let quitRequested: (() => void) | null = null; +// The captured Rust event listeners, keyed by event name. Rust asks every +// window to vote (`quit-requested`), tells them all when someone declines +// (`quit-cancelled`), and walks them one at a time (`quit-teardown`). +const listeners = new Map void>(); +const fire = (event: string, payload?: unknown) => listeners.get(event)?.({ payload }); +const quitRequested = (windows = 1) => fire("dormouse://quit-requested", { windows }); +const quitTeardown = (last = true) => fire("dormouse://quit-teardown", { last }); +const quitCancelled = () => fire("dormouse://quit-cancelled"); +const voted = () => mocks.invoke.mock.calls.some((call) => call[0] === "quit_vote"); // Drain the microtask-driven teardown chain (no real timers on the happy path — // withTimeout's 10s guard is cleared when the work wins). @@ -82,10 +91,20 @@ function fakeAdapter(order: string[] = [], overrides: Partial { +/** + * Wire the orchestrator, ask this window to vote, and — once it has — run the + * walk's teardown for it. `last` is what the walk hands the final window + * (`main`), which installs and exits; every other one is destroyed instead. + */ +async function triggerQuit( + adapter: TauriAdapter, + { windows = 1, last = true }: { windows?: number; last?: boolean } = {}, +): Promise { initQuitFlow(adapter); - quitRequested!(); + quitRequested(windows); + await settle(); + if (!voted()) return; + quitTeardown(last); await settle(); } @@ -94,9 +113,9 @@ describe("quit orchestrator", () => { vi.clearAllMocks(); _resetForTesting(); _resetQuitConfirmForTesting(); - quitRequested = null; - mocks.listen.mockImplementation((event: string, cb: () => void) => { - if (event === "dormouse://quit-requested") quitRequested = cb; + listeners.clear(); + mocks.listen.mockImplementation((event: string, cb: (e: { payload?: unknown }) => void) => { + listeners.set(event, cb); return Promise.resolve(() => {}); }); mocks.countRunningSessions.mockReturnValue(0); @@ -144,6 +163,7 @@ describe("quit orchestrator", () => { // start, install start) so Rust's watchdog budgets them separately. expect(order).toEqual([ "quit_ack", + "quit_vote", "quit_progress", "captureRecovery", "flush", @@ -184,7 +204,9 @@ describe("quit orchestrator", () => { }); initQuitFlow(adapter); - quitRequested!(); + quitRequested(); + await vi.advanceTimersByTimeAsync(0); + quitTeardown(); await vi.advanceTimersByTimeAsync(30_000); expect(order).toContain("drain"); @@ -228,6 +250,9 @@ describe("quit orchestrator", () => { await triggerQuit(adapter); + // Not even a vote: a window parked on its dialog has not decided, and a + // vote is what would let the walk start destroying the others. + expect(mocks.invoke).not.toHaveBeenCalledWith("quit_vote"); expect(mocks.invoke).not.toHaveBeenCalledWith("quit_progress"); expect(mocks.invoke).toHaveBeenCalledWith("quit_ack"); }); @@ -256,9 +281,11 @@ describe("quit orchestrator", () => { }); initQuitFlow(adapter); - quitRequested!(); // starts teardown; parked at the first flush + quitRequested(); + await settle(); + quitTeardown(); // starts teardown; parked at the first flush await settle(); - quitRequested!(); // repeat trigger — must not restart teardown + quitRequested(); // repeat trigger — must not restart teardown await settle(); // Only one teardown ran: the first flush was entered exactly once. @@ -293,7 +320,7 @@ describe("quit orchestrator", () => { setQuitConfirmGate(gate); await triggerQuit(adapter); - quitRequested!(); // repeat trigger while confirming + quitRequested(); // repeat trigger while confirming await settle(); expect(gate).toHaveBeenCalledTimes(1); @@ -329,7 +356,7 @@ describe("quit orchestrator", () => { // The gate is a step before teardown, not inside it: nothing has told Rust // teardown began when the archive runs. - expect(order.slice(0, 3)).toEqual(["quit_ack", "archive", "quit_progress"]); + expect(order.slice(0, 4)).toEqual(["quit_ack", "archive", "quit_vote", "quit_progress"]); expect(mocks.archiveSurfaceNotes).toHaveBeenCalledWith(["pane-a"], expect.anything()); expect(mocks.invoke).toHaveBeenCalledWith("quit_proceed"); }); @@ -377,7 +404,7 @@ describe("quit orchestrator", () => { await triggerQuit(fakeAdapter()); mocks.archiveSurfaceNotes.mockClear(); - quitRequested!(); + quitRequested(); await settle(); // Acked (Rust's watchdog stands down) but the flow does not restart. @@ -393,6 +420,8 @@ describe("quit orchestrator", () => { confirmQuit(); await settle(); + quitTeardown(); + await settle(); expect(mocks.removeSurface).toHaveBeenCalledWith("pane-a"); expect(adapter.requestSessionFlush).toHaveBeenCalled(); @@ -416,7 +445,9 @@ describe("quit orchestrator", () => { // The flow returned to idle, so the next trigger runs the gate again. mocks.archiveSurfaceNotes.mockResolvedValue(undefined); - quitRequested!(); + quitRequested(); + await settle(); + quitTeardown(); await settle(); expect(adapter.requestSessionFlush).toHaveBeenCalled(); expect(mocks.invoke).toHaveBeenCalledWith("quit_proceed"); @@ -429,7 +460,7 @@ describe("quit orchestrator", () => { mocks.archiveSurfaceNotes.mockReturnValue(new Promise(() => {})); // never settles const adapter = fakeAdapter(); initQuitFlow(adapter); - quitRequested!(); + quitRequested(); await vi.advanceTimersByTimeAsync(3000); @@ -454,7 +485,7 @@ describe("quit orchestrator", () => { return new Promise(() => {}); // never settles }); initQuitFlow(fakeAdapter()); - quitRequested!(); + quitRequested(); await Promise.resolve(); expect(signal?.aborted).toBe(false); @@ -467,6 +498,68 @@ describe("quit orchestrator", () => { } }); + // --- Vote then walk (docs/specs/standalone.md §Quit flow) ------------------- + + it("votes and then waits: nothing is torn down until the walk reaches this window", async () => { + const adapter = fakeAdapter(); + initQuitFlow(adapter); + quitRequested(); + await settle(); + + expect(mocks.invoke).toHaveBeenCalledWith("quit_vote"); + // A vote is not a teardown: another window may still decline, and nothing + // anywhere may be destroyed until every window has agreed. + expect(adapter.requestSessionFlush).not.toHaveBeenCalled(); + expect(mocks.invoke).not.toHaveBeenCalledWith("quit_progress"); + expect(mocks.invoke).not.toHaveBeenCalledWith("quit_proceed"); + }); + + it("a window that is not last hands the walk on instead of exiting", async () => { + mocks.hasPendingUpdate.mockReturnValue(true); + const adapter = fakeAdapter(); + await triggerQuit(adapter, { last: false }); + + expect(adapter.drainSessionSaves).toHaveBeenCalled(); + expect(mocks.invoke).toHaveBeenCalledWith("quit_window_done"); + expect(mocks.invoke).not.toHaveBeenCalledWith("quit_proceed"); + // Only `main` holds `updater:*`, and it is the window the walk tears down + // last (docs/specs/auto-update.md). + expect(mocks.installPendingUpdate).not.toHaveBeenCalled(); + }); + + it("another window's cancel drops this window's dialog without cancelling again", async () => { + mocks.countRunningSessions.mockReturnValue(1); + setQuitConfirmGate(openQuitConfirm); + await triggerQuit(fakeAdapter()); + expect(getQuitConfirmPhase()).toBe("open"); + + quitCancelled(); + + expect(getQuitConfirmPhase()).toBeNull(); + // The cancel already happened elsewhere; calling back would bounce it + // around the windows. + expect(mocks.invoke).not.toHaveBeenCalledWith("quit_cancel"); + }); + + it("names the window in its dialog only when more than one is open", async () => { + mocks.countRunningSessions.mockReturnValue(1); + const gate = vi.fn(); + setQuitConfirmGate(gate); + + initQuitFlow(fakeAdapter(), { windowName: () => "Deploys" }); + quitRequested(1); + await settle(); + expect(gate.mock.calls[0]![1]).toEqual({ kind: "quit" }); + + _resetForTesting(); + gate.mockClear(); + setQuitConfirmGate(gate); + initQuitFlow(fakeAdapter(), { windowName: () => "Deploys" }); + quitRequested(2); + await settle(); + expect(gate.mock.calls[0]![1]).toEqual({ kind: "quit", windowName: "Deploys" }); + }); + it("falls through to teardown when no gate is installed even with running sessions", async () => { mocks.countRunningSessions.mockReturnValue(2); const adapter = fakeAdapter(); diff --git a/standalone/src/quit.ts b/standalone/src/quit.ts index aa65a35d8..a1e0832f2 100644 --- a/standalone/src/quit.ts +++ b/standalone/src/quit.ts @@ -1,39 +1,45 @@ import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { countRunningSessions } from "dormouse-lib/lib/terminal-registry"; -import { archiveSurfaceNotes } from "dormouse-lib/lib/notepad/close-coordinator"; import { notepadSurfaceIds, removeSurface } from "dormouse-lib/lib/notepad/notepad-store"; import { flushWindowSession } from "dormouse-lib/lib/window-session-aggregator"; import { DEFAULT_RECOVERY_WAIT_MS } from "dormouse-lib/host/recovery-capture"; import type { TauriAdapter } from "./tauri-adapter"; -import { openQuitArchiveFailure } from "./quit-confirm-store"; +import { dismissQuitConfirm, openQuitArchiveFailure, type QuitConfirmIntent } from "./quit-confirm-store"; +import { archiveNotesBeforeTeardown } from "./teardown-archive"; import { hasPendingUpdate, installPendingUpdate } from "./updater"; -import { withDeadline, withTimeout } from "./with-timeout"; +import { withTimeout } from "./with-timeout"; /** - * Quit orchestrator. Rust intercepts every quit trigger and emits - * `dormouse://quit-requested`; this module acks, runs the graceful teardown, - * and calls `quit_proceed` on every path so the app always exits. Protocol, - * teardown ordering, and rationale: docs/specs/standalone.md §Quit flow. + * Quit orchestrator — this window's half of it. + * + * Rust intercepts every quit trigger and asks every window to **vote**; only + * once they all agree does it **walk** them, one teardown at a time, `main` + * last. A cancel in any window therefore costs nothing, because nothing has + * been destroyed yet. Protocol, teardown ordering, and rationale: + * docs/specs/standalone.md §Quit flow. */ -// One quit flow at a time: repeated quit-requested events are ignored while a -// confirmation decision is outstanding, the archive gate is asking about notes -// it could not store, or a teardown is running. -let quitPhase: "idle" | "confirming" | "archive-failed" | "tearing-down" = "idle"; +// One quit flow at a time in this window: repeated quit-requested events are +// ignored while a confirmation decision is outstanding, the archive gate is +// asking about notes it could not store, this window has already voted, or its +// teardown is running. +let quitPhase: "idle" | "confirming" | "archive-failed" | "voted" | "tearing-down" = "idle"; // The adapter to tear down, captured at init. let quitAdapter: TauriAdapter | null = null; +/** Names this window in the dialog while more than one is open. */ +let describeWindow: () => string | undefined = () => undefined; // The quit-confirmation gate (docs/specs/standalone.md §Quit flow, // "Confirmation dialog"). When quit fires with ≥1 running session and a gate is // installed, the gate owns the decision and must eventually call -// `ctx.confirm()` (run the teardown) or `ctx.cancel()` (abort). With no gate -// installed the handler falls through to an immediate unconfirmed teardown. +// `ctx.confirm()` (vote to quit) or `ctx.cancel()` (abort the whole quit). With +// no gate installed the handler falls through to an immediate unconfirmed vote. export interface QuitConfirmContext { confirm: () => void; cancel: () => void; } -type QuitConfirmGate = (ctx: QuitConfirmContext) => void; +type QuitConfirmGate = (ctx: QuitConfirmContext, intent?: QuitConfirmIntent) => void; let quitConfirmGate: QuitConfirmGate | null = null; /** Register (or clear with null) the running-work confirmation gate. */ @@ -41,13 +47,25 @@ export function setQuitConfirmGate(gate: QuitConfirmGate | null): void { quitConfirmGate = gate; } -export function initQuitFlow(adapter: TauriAdapter): void { +export function initQuitFlow( + adapter: TauriAdapter, + options: { windowName?: () => string | undefined } = {}, +): void { quitAdapter = adapter; - void listen("dormouse://quit-requested", handleQuitRequested); + if (options.windowName) describeWindow = options.windowName; + void listen<{ windows?: number }>("dormouse://quit-requested", (event) => + handleQuitRequested(event.payload?.windows ?? 1)); + // Another window said no. Nothing was destroyed; drop this window's dialog + // and go back to idle so a later quit asks again. + void listen("dormouse://quit-cancelled", handleQuitCancelled); + // Every window voted yes, and it is now this window's turn. + void listen<{ last?: boolean }>("dormouse://quit-teardown", (event) => { + void runQuitTeardown(event.payload?.last === true); + }); } -function handleQuitRequested(): void { - // Ack first — stands Rust's phase-1 watchdog down even when the trigger is +function handleQuitRequested(windows: number): void { + // Ack first — stands Rust's ack watchdog down even when the trigger is // deduped below (a repeated trigger re-emits, so re-acking is expected). void invoke("quit_ack").catch(() => {}); @@ -55,75 +73,49 @@ function handleQuitRequested(): void { if (countRunningSessions() > 0 && quitConfirmGate) { quitPhase = "confirming"; - quitConfirmGate({ - confirm: () => void archiveThenTeardown(), - cancel: cancelQuit, - }); - return; - } - void archiveThenTeardown(); -} - -// The archive write is a host round trip; a wedged one must not hold the quit -// open, so it gets its own bound ahead of the teardown's. -const ARCHIVE_GATE_MS = 3000; - -/** - * The notepad's quit gate (docs/specs/notepad.md → "Standalone quit"): every - * Surface holding notes or a pending batch identity participates in one archive - * mutation, after the running-work decision and before teardown begins. - * Rejects with a user-presentable message when the write fails or outruns its - * bound — the caller turns that into Cancel / Quit anyway. - */ -export async function archiveNotesBeforeQuit(): Promise { - const ids = notepadSurfaceIds(); - if (ids.length === 0) return; - // The deadline only stops us *waiting*; the archive itself keeps running and - // may still succeed. The signal is what stops it emptying every notepad - // afterwards, behind a user who has been told their notes were not stored and - // has chosen Cancel. - const gaveUp = new AbortController(); - try { - await withDeadline( - archiveSurfaceNotes(ids, { signal: gaveUp.signal }), - ARCHIVE_GATE_MS, - `The notepad archive did not finish within ${ARCHIVE_GATE_MS / 1000}s.`, + quitConfirmGate( + { confirm: () => void archiveThenVote(), cancel: cancelQuit }, + // Named only when there is more than one window to tell apart. + { kind: "quit", ...(windows > 1 ? { windowName: describeWindow() } : {}) }, ); - } catch (err) { - gaveUp.abort(); - throw err; + return; } + void archiveThenVote(); } -// The decision is made; archive the notes, then tear down. A refused archive is -// the one thing that stops a confirmed quit, and only until the user answers. -async function archiveThenTeardown(): Promise { +// The decision is made in this window; archive its notes, then vote. A refused +// archive is the one thing that stops it, and only until the user answers. +async function archiveThenVote(): Promise { // Committed from here: the gate is an await, so without this a second trigger // arriving mid-archive would start a parallel flow. - quitPhase = "tearing-down"; + quitPhase = "voted"; try { - await archiveNotesBeforeQuit(); + await archiveNotesBeforeTeardown(); } catch (err) { - // The quit stays pending in Rust. Its phase-2 wait is unbounded precisely - // because it waits on a human (docs/specs/standalone.md → "Quit flow"), and - // cancelling here would retire the watchdog that a later Quit anyway still - // needs. Hold the flow in `archive-failed` so a repeat trigger is deduped - // exactly like a pending confirmation. + // The quit stays pending in Rust. Its wait past the ack is unbounded + // precisely because it waits on a human (docs/specs/standalone.md → "Quit + // flow"), and cancelling here would retire the watchdog that a later Quit + // anyway still needs. Hold the flow in `archive-failed` so a repeat trigger + // is deduped exactly like a pending confirmation. quitPhase = "archive-failed"; openQuitArchiveFailure(err instanceof Error ? err.message : String(err), { confirm: () => { // Quit anyway: the user accepts losing these notes, so forget them and - // take the teardown that no longer has anything to archive — watchdog - // still armed, because nothing cancelled the pending quit. + // vote — watchdog still armed, because nothing cancelled the quit. for (const id of notepadSurfaceIds()) removeSurface(id); - void runQuitTeardown(); + castVote(); }, // Cancel is the one branch that drops the pending quit in Rust. cancel: cancelQuit, }); return; } - await runQuitTeardown(); + castVote(); +} + +function castVote(): void { + quitPhase = "voted"; + void invoke("quit_vote").catch(() => {}); } // Each teardown step's own bound, and the ceiling derived from them. The two @@ -149,10 +141,13 @@ const STEP_BUDGET_TOTAL_MS = export const QUIT_TEARDOWN_CEILING_MS = STEP_BUDGET_TOTAL_MS + 1000; // Ordering and rationale: docs/specs/standalone.md §Quit flow (Teardown -// ordering). `quit_progress` tells Rust teardown has begun (ending the -// confirmation-wait suspension) and marks each phase boundary so its watchdog -// gives teardown and install separate budgets rather than one shared clock. -async function runQuitTeardown(): Promise { +// ordering). `quit_progress` marks each phase boundary so Rust's watchdog gives +// teardown and install separate budgets rather than one shared clock. +// +// Every host step here is scoped to this window by Rust — the capture, the kill +// and the snapshot are all keyed by the invoking window's label — so a window +// tearing down can neither interrupt nor kill a sibling's terminals. +async function runQuitTeardown(last: boolean): Promise { quitPhase = "tearing-down"; const adapter = quitAdapter; try { @@ -164,8 +159,6 @@ async function runQuitTeardown(): Promise { // interrupt and the kill, and it is the one thing here that cannot be // reconstructed afterwards. Losing it must never cost the save behind // it, so this step alone cannot abort the rest. - // No `ids`: a quit tears down the whole Window, so the capture takes - // every live PTY. await adapter.captureAgentRecovery(DEFAULT_RECOVERY_WAIT_MS).catch((err) => console.warn("[quit] agent recovery capture failed; proceeding", err)); await adapter.requestSessionFlush(PRE_KILL_FLUSH_MS); // save while PTYs are alive @@ -180,30 +173,42 @@ async function runQuitTeardown(): Promise { `[quit] teardown exceeded ${QUIT_TEARDOWN_CEILING_MS}ms; proceeding to exit`, ); } - // Install strictly after the completed final save. A fresh `quit_progress` - // gives install its own watchdog budget instead of the teardown remainder. - if (hasPendingUpdate()) { + // Install strictly after the completed final save, and only in the window + // the walk tears down last — `main`, the only one granted `updater:*` + // (docs/specs/auto-update.md). A fresh `quit_progress` gives install its own + // watchdog budget instead of the teardown remainder. + if (last && hasPendingUpdate()) { void invoke("quit_progress").catch(() => {}); // install phase begins await installPendingUpdate(); } } catch (err) { // A rejecting step or a failed installer must not prevent exit. - console.warn("[quit] teardown step failed; proceeding to exit", err); + console.warn("[quit] teardown step failed; proceeding", err); } finally { - void invoke("quit_proceed").catch(() => {}); + // The last window exits the app; every other one is destroyed and hands the + // walk on to the next. + void invoke(last ? "quit_proceed" : "quit_window_done").catch(() => {}); } } -// Abort a pending quit (confirmation cancel): Rust drops the pending quit and a -// later trigger starts fresh. +// Abort the whole quit from this window (confirmation cancel). Rust tells every +// window, and nothing anywhere has been destroyed. function cancelQuit(): void { quitPhase = "idle"; void invoke("quit_cancel").catch(() => {}); } +// Rust says some window declined. Drop this window's dialog without calling +// back into Rust — the cancel already happened, somewhere else. +function handleQuitCancelled(): void { + quitPhase = "idle"; + dismissQuitConfirm(); +} + /** @internal Reset module state for testing. */ export function _resetForTesting(): void { quitPhase = "idle"; quitAdapter = null; quitConfirmGate = null; + describeWindow = () => undefined; } diff --git a/standalone/src/teardown-archive.ts b/standalone/src/teardown-archive.ts new file mode 100644 index 000000000..f73b67c90 --- /dev/null +++ b/standalone/src/teardown-archive.ts @@ -0,0 +1,42 @@ +import { archiveSurfaceNotes } from "dormouse-lib/lib/notepad/close-coordinator"; +import { notepadSurfaceIds } from "dormouse-lib/lib/notepad/notepad-store"; +import { withDeadline } from "./with-timeout"; + +/** + * The notepad gate both deliberate endings share: a quit and a per-window close + * (`docs/specs/notepad.md` → "Standalone quit"). A Workspace *transfer* is not + * one of them — a move is not a closure, so it archives nothing. + * + * The registry is per webview, so `notepadSurfaceIds()` is already this + * window's Surfaces and nothing else's. + */ + +// The archive write is a host round trip; a wedged one must not hold the +// teardown open, so it gets its own bound ahead of the teardown's. +export const ARCHIVE_GATE_MS = 3000; + +/** + * Archive every Surface holding notes or a pending batch identity, in one + * mutation, after the running-work decision and before teardown begins. + * Rejects with a user-presentable message when the write fails or outruns its + * bound — the caller turns that into Cancel / proceed anyway. + */ +export async function archiveNotesBeforeTeardown(): Promise { + const ids = notepadSurfaceIds(); + if (ids.length === 0) return; + // The deadline only stops us *waiting*; the archive itself keeps running and + // may still succeed. The signal is what stops it emptying every notepad + // afterwards, behind a user who has been told their notes were not stored and + // has chosen Cancel. + const gaveUp = new AbortController(); + try { + await withDeadline( + archiveSurfaceNotes(ids, { signal: gaveUp.signal }), + ARCHIVE_GATE_MS, + `The notepad archive did not finish within ${ARCHIVE_GATE_MS / 1000}s.`, + ); + } catch (err) { + gaveUp.abort(); + throw err; + } +} diff --git a/standalone/src/window-close.test.ts b/standalone/src/window-close.test.ts new file mode 100644 index 000000000..f8afdcebb --- /dev/null +++ b/standalone/src/window-close.test.ts @@ -0,0 +1,180 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { TauriAdapter } from "./tauri-adapter"; + +/** + * Closing one window of several. Mocked exactly like `quit.test.ts`: the Tauri + * surface plus the two collaborators whose real modules pull the whole lib + * platform in behind them, so what is observable here is the ordering and the + * two things a close does that a quit does not — remove the snapshot, and + * capture no agent recovery. + */ +const mocks = vi.hoisted(() => ({ + invoke: vi.fn(async (_cmd: string) => undefined as unknown), + listen: vi.fn(), + countRunningSessions: vi.fn(() => 0), + archiveSurfaceNotes: vi.fn(async (_ids: readonly string[], _opts?: { signal?: AbortSignal }) => {}), + notepadSurfaceIds: vi.fn(() => [] as string[]), + removeSurface: vi.fn(), +})); + +vi.mock("@tauri-apps/api/core", () => ({ invoke: mocks.invoke })); +vi.mock("@tauri-apps/api/event", () => ({ listen: mocks.listen })); +vi.mock("dormouse-lib/lib/terminal-registry", () => ({ + countRunningSessions: mocks.countRunningSessions, +})); +vi.mock("dormouse-lib/lib/notepad/close-coordinator", () => ({ + archiveSurfaceNotes: mocks.archiveSurfaceNotes, +})); +vi.mock("dormouse-lib/lib/notepad/notepad-store", () => ({ + notepadSurfaceIds: mocks.notepadSurfaceIds, + removeSurface: mocks.removeSurface, +})); + +import { initWindowClose, _resetWindowCloseForTesting } from "./window-close"; +import { + cancelQuit as dismissDialog, + confirmQuit, + getQuitArchiveError, + getQuitConfirmIntent, + getQuitConfirmPhase, + _resetQuitConfirmForTesting, +} from "./quit-confirm-store"; + +const listeners = new Map void>(); +const closeRequested = () => listeners.get("dormouse://window-close-requested")?.(); +const settle = () => new Promise((r) => setTimeout(r, 0)); +const commands = () => mocks.invoke.mock.calls.map((call) => call[0]); + +function fakeAdapter(order: string[] = []): TauriAdapter { + return { + gracefulKillPtys: vi.fn(async () => void order.push("gracefulKill")), + captureAgentRecovery: vi.fn(async () => void order.push("captureRecovery")), + } as unknown as TauriAdapter; +} + +describe("per-window close", () => { + beforeEach(() => { + vi.clearAllMocks(); + _resetWindowCloseForTesting(); + _resetQuitConfirmForTesting(); + listeners.clear(); + mocks.listen.mockImplementation((event: string, cb: () => void) => { + listeners.set(event, cb); + return Promise.resolve(() => {}); + }); + mocks.countRunningSessions.mockReturnValue(0); + mocks.invoke.mockResolvedValue(undefined); + mocks.archiveSurfaceNotes.mockResolvedValue(undefined); + mocks.notepadSurfaceIds.mockReturnValue([]); + }); + + afterEach(() => _resetWindowCloseForTesting()); + + it("acks, removes the snapshot, kills, and proceeds — with no recovery capture", async () => { + const order: string[] = []; + mocks.invoke.mockImplementation(async (cmd: string) => void order.push(cmd)); + const adapter = fakeAdapter(order); + initWindowClose(adapter); + closeRequested(); + await settle(); + + expect(order).toEqual([ + "window_close_ack", + // Before the kill: a PTY exit triggers a session save, and the snapshot + // must not come back after being removed. + "remove_window_session", + "gracefulKill", + "window_close_proceed", + ]); + // A close is an ending, not a relaunch: there is nothing to resume into. + expect(adapter.captureAgentRecovery).not.toHaveBeenCalled(); + }); + + it("kills only this window's PTYs, naming no ids", async () => { + const adapter = fakeAdapter(); + initWindowClose(adapter); + closeRequested(); + await settle(); + + // Rust scopes an id-less kill to the invoking window, and a sibling's + // terminals must not be reachable from here at all. + expect(adapter.gracefulKillPtys).toHaveBeenCalledWith(expect.any(Number)); + }); + + it("asks first when the window holds running work, and Cancel leaves it alone", async () => { + mocks.countRunningSessions.mockReturnValue(2); + const adapter = fakeAdapter(); + initWindowClose(adapter, { windowName: () => "Deploys" }); + closeRequested(); + await settle(); + + expect(getQuitConfirmPhase()).toBe("open"); + // The dialog says "close", not "quit", and names the window. + expect(getQuitConfirmIntent()).toEqual({ kind: "close-window", windowName: "Deploys" }); + expect(adapter.gracefulKillPtys).not.toHaveBeenCalled(); + + dismissDialog(); + await settle(); + expect(commands()).toContain("window_close_cancel"); + expect(commands()).not.toContain("window_close_proceed"); + expect(adapter.gracefulKillPtys).not.toHaveBeenCalled(); + }); + + it("archives every Surface holding notes, because a close is deliberate", async () => { + mocks.notepadSurfaceIds.mockReturnValue(["pane-a"]); + const order: string[] = []; + mocks.invoke.mockImplementation(async (cmd: string) => void order.push(cmd)); + mocks.archiveSurfaceNotes.mockImplementation(async () => void order.push("archive")); + + initWindowClose(fakeAdapter(order)); + closeRequested(); + await settle(); + + expect(order.slice(0, 3)).toEqual(["window_close_ack", "archive", "remove_window_session"]); + expect(mocks.archiveSurfaceNotes).toHaveBeenCalledWith(["pane-a"], expect.anything()); + }); + + it("holds the close open when the archive refuses, and Close anyway discards the notes", async () => { + mocks.notepadSurfaceIds.mockReturnValue(["pane-a"]); + mocks.archiveSurfaceNotes.mockRejectedValue(new Error("disk is full")); + const adapter = fakeAdapter(); + initWindowClose(adapter); + closeRequested(); + await settle(); + + expect(getQuitConfirmPhase()).toBe("archive-failed"); + expect(getQuitArchiveError()).toBe("disk is full"); + expect(getQuitConfirmIntent().kind).toBe("close-window"); + expect(commands()).not.toContain("window_close_cancel"); + expect(adapter.gracefulKillPtys).not.toHaveBeenCalled(); + + confirmQuit(); + await settle(); + expect(mocks.removeSurface).toHaveBeenCalledWith("pane-a"); + expect(commands()).toContain("window_close_proceed"); + }); + + it("deduplicates a repeat close trigger while a decision is outstanding", async () => { + mocks.countRunningSessions.mockReturnValue(1); + initWindowClose(fakeAdapter()); + closeRequested(); + await settle(); + closeRequested(); + await settle(); + + // Acked twice (Rust's watchdog stands down each time) but asked once. + expect(commands().filter((cmd) => cmd === "window_close_ack")).toHaveLength(2); + expect(getQuitConfirmPhase()).toBe("open"); + }); + + it("closes anyway when a teardown step rejects", async () => { + const adapter = { + gracefulKillPtys: vi.fn(async () => { throw new Error("SIGTERM refused"); }), + } as unknown as TauriAdapter; + initWindowClose(adapter); + closeRequested(); + await settle(); + + expect(commands()).toContain("window_close_proceed"); + }); +}); diff --git a/standalone/src/window-close.ts b/standalone/src/window-close.ts new file mode 100644 index 000000000..3554a1b48 --- /dev/null +++ b/standalone/src/window-close.ts @@ -0,0 +1,123 @@ +import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; +import { countRunningSessions } from "dormouse-lib/lib/terminal-registry"; +import { notepadSurfaceIds, removeSurface } from "dormouse-lib/lib/notepad/notepad-store"; +import { openQuitArchiveFailure, openQuitConfirm, type QuitConfirmIntent } from "./quit-confirm-store"; +import { archiveNotesBeforeTeardown } from "./teardown-archive"; +import type { TauriAdapter } from "./tauri-adapter"; + +/** + * Closing one window of several (`docs/specs/standalone.md` → "Per-window + * close"). Rust prevents the close and emits + * `dormouse://window-close-requested`; this acks, asks, archives, kills, and + * calls back `window_close_proceed`. The last window's close is a quit instead, + * and never reaches here. + * + * A close is **deliberate**: unlike a quit it archives the notes AND takes the + * window's snapshot off disk, so the next launch does not reopen it. It runs no + * agent-recovery capture for the same reason — nothing is coming back. + */ + +const GRACEFUL_KILL_MS = 2000; +/** The whole teardown, past the human decision. Well under Rust's own budget. */ +const CLOSE_TEARDOWN_CEILING_MS = 8000; + +type ClosePhase = "idle" | "confirming" | "archive-failed" | "closing"; + +let phase: ClosePhase = "idle"; +let closeAdapter: TauriAdapter | null = null; +/** Names this window in the dialog while more than one is open. */ +let describeWindow: () => string | undefined = () => undefined; + +const CLOSE_INTENT = (): QuitConfirmIntent => ({ + kind: "close-window", + windowName: describeWindow(), +}); + +export function initWindowClose( + adapter: TauriAdapter, + options: { windowName?: () => string | undefined } = {}, +): void { + closeAdapter = adapter; + if (options.windowName) describeWindow = options.windowName; + void listen("dormouse://window-close-requested", handleCloseRequested); +} + +function handleCloseRequested(): void { + // Ack first — stands Rust's ack watchdog down even when the trigger is + // deduped below, exactly as the quit orchestrator does. + void invoke("window_close_ack").catch(() => {}); + if (phase !== "idle") return; + + // The registry is per webview, so this is already this window's running work. + if (countRunningSessions() > 0) { + phase = "confirming"; + openQuitConfirm({ confirm: () => void archiveThenClose(), cancel: cancelClose }, CLOSE_INTENT()); + return; + } + void archiveThenClose(); +} + +async function archiveThenClose(): Promise { + // Committed from here: the gate is an await, so without this a second trigger + // arriving mid-archive would start a parallel close. + phase = "closing"; + try { + await archiveNotesBeforeTeardown(); + } catch (err) { + // The close stays pending in Rust — its wait past the ack is unbounded + // precisely because it waits on a human. + phase = "archive-failed"; + openQuitArchiveFailure( + err instanceof Error ? err.message : String(err), + { + confirm: () => { + // Close anyway: the user accepts losing these notes, so forget them + // and take the teardown that has nothing left to archive. + for (const id of notepadSurfaceIds()) removeSurface(id); + void runCloseTeardown(); + }, + cancel: cancelClose, + }, + CLOSE_INTENT(), + ); + return; + } + await runCloseTeardown(); +} + +async function runCloseTeardown(): Promise { + phase = "closing"; + const adapter = closeAdapter; + try { + // Remove the snapshot BEFORE the kill, so an exit-triggered save cannot + // write it back: Rust refuses every later save for this label. + await invoke("remove_window_session").catch((err) => + console.warn("[window-close] remove_window_session failed; proceeding", err)); + // No `ids`: Rust scopes the kill to this window's own PTYs, and a sibling's + // terminals must never be reachable from here. + if (adapter) { + await Promise.race([ + adapter.gracefulKillPtys(GRACEFUL_KILL_MS), + new Promise((resolve) => setTimeout(resolve, CLOSE_TEARDOWN_CEILING_MS)), + ]); + } + } catch (err) { + // A failing step must not leave the window un-closeable. + console.warn("[window-close] teardown step failed; closing anyway", err); + } finally { + void invoke("window_close_proceed").catch(() => {}); + } +} + +function cancelClose(): void { + phase = "idle"; + void invoke("window_close_cancel").catch(() => {}); +} + +/** @internal Reset module state for testing. */ +export function _resetWindowCloseForTesting(): void { + phase = "idle"; + closeAdapter = null; + describeWindow = () => undefined; +} From c1615e71a501bd087bc3e4ef90399600e132d032 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 9 Sep 2026 23:58:10 -0700 Subject: [PATCH 04/36] Let a Workspace leave a Window without killing anything MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `disposeSession` splits into one teardown with a `kill` flag, and the new `releaseSession` takes this webview's half of a Session down while the process keeps running — the whole basis of a transfer. It is an explicit handle verb and never an unmount effect: a Wall unmounts on a reload, a StrictMode double-mount and a Workspace switch, and releasing there would strand every PTY. `releaseWorkspaceForTransfer` builds the payload the target restores from, in the one order that works: serialize with a live cwd probe while the Sessions still exist, take the notes, then detach. Nothing is archived and nothing is killed, because a move is not a closure. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe --- lib/src/components/Wall.test.tsx | 24 ++++ lib/src/components/Wall.tsx | 19 ++- .../wall/use-session-persistence.ts | 19 ++- lib/src/components/wall/wall-handles.ts | 13 ++ .../wall/workspace-transfer.test.ts | 81 ++++++++++++ lib/src/components/wall/workspace-transfer.ts | 77 ++++++++++++ lib/src/lib/notepad/notepad-store.ts | 21 +++- lib/src/lib/session-save.ts | 11 +- .../lib/terminal-lifecycle.release.test.ts | 116 ++++++++++++++++++ lib/src/lib/terminal-lifecycle.ts | 31 ++++- lib/src/lib/terminal-registry.ts | 1 + 11 files changed, 403 insertions(+), 10 deletions(-) create mode 100644 lib/src/components/wall/workspace-transfer.test.ts create mode 100644 lib/src/components/wall/workspace-transfer.ts create mode 100644 lib/src/lib/terminal-lifecycle.release.test.ts diff --git a/lib/src/components/Wall.test.tsx b/lib/src/components/Wall.test.tsx index b3a8e724f..0c865dd90 100644 --- a/lib/src/components/Wall.test.tsx +++ b/lib/src/components/Wall.test.tsx @@ -2044,6 +2044,30 @@ describe('Wall on the Lath engine', () => { expect(handle.ownsSurface('pane-a')).toBe(true); expect(handle.ownsSurface('pane-elsewhere')).toBe(false); }); + + it('unmounting leaves every PTY alive and every registry entry intact', async () => { + // The two teardown verbs are explicit handle methods, never unmount + // effects: a Wall unmounts on a reload, a StrictMode double-mount, and a + // Workspace switch, and killing or releasing there would cost the user + // every Session (`releaseSession` in `lib/src/lib/terminal-lifecycle.ts`). + const killPty = vi.spyOn(fake, 'killPty'); + const dispose = vi.spyOn(terminalRegistry, 'disposeSession'); + const release = vi.spyOn(terminalRegistry, 'releaseSession'); + await act(async () => root.render()); + await flush(); + const handle = getWallHandle(DEFAULT_WORKSPACE_ID)!; + expect(handle.surfaceIds()).toEqual(['pane-a', 'pane-b']); + + await act(async () => root.render(<>)); + await flush(); + + expect(dispose).not.toHaveBeenCalled(); + expect(release).not.toHaveBeenCalled(); + expect(killPty).not.toHaveBeenCalled(); + // The handle deregisters, so nothing addresses the gone Wall — but the + // Sessions it held are untouched. + expect(getWallHandle(DEFAULT_WORKSPACE_ID)).toBeNull(); + }); }); describe('Wall session persistence: ownership filtering', () => { diff --git a/lib/src/components/Wall.tsx b/lib/src/components/Wall.tsx index 0f66934c2..c48a266ed 100644 --- a/lib/src/components/Wall.tsx +++ b/lib/src/components/Wall.tsx @@ -66,9 +66,10 @@ import type { import { hasBrowser, hasTerminal } from 'dor/commands/types'; import { DEFAULT_WORKSPACE_ID, type PersistedSurfaceRefs, type WorkspaceId } from '../lib/session-types'; import { clearWorkspaceSurfaces, setWorkspaceSurfaces } from '../lib/workspace-surfaces'; -import { workspaceRefFor } from '../lib/workspace-store'; +import { getWorkspacesSnapshot, workspaceRefFor } from '../lib/workspace-store'; import { awaitWallEmpty } from './wall/close-all'; import { registerWallHandle, type WallHandle } from './wall/wall-handles'; +import { releaseWorkspaceForTransfer } from './wall/workspace-transfer'; import { installDorControlRouter } from './wall/dor-control-router'; import type { DropTarget, RestoreToken } from '../lib/lath/ops'; import type { Edge } from '../lib/lath/model'; @@ -940,6 +941,12 @@ export function Wall({ [lath], ); + /** Whether a member Surface has a PTY behind it, as against a browser view. */ + const surfaceHasTerminal = useCallback( + (id: string): boolean => hasTerminal(surfaceKindFromParams(lath.getMeta(id)?.params)), + [lath], + ); + /** Whether a Surface belongs to this Wall — the membership test in the hot * paths (a PTY chunk per Session per Wall), so it asks the store rather than * building a projection. Stable, so a listener can close over it. */ @@ -1547,11 +1554,19 @@ export function Wall({ hasTouchedSurfaces: () => memberSurfaceIds().some((id) => { // A browser Surface has no "untouched" notion and always holds a page, so // it counts; a terminal counts once its Session exists and has input. - if (!hasTerminal(surfaceKindFromParams(lath.getMeta(id)?.params))) return true; + if (!surfaceHasTerminal(id)) return true; return getTerminalInstance(id) !== null && !isReplaceableShell(id); }), runningCount: () => countRunningSessionsIn(memberSurfaceIds()), flushPersistence: (options) => persistence.flush(options), + releaseWorkspaceForTransfer: () => releaseWorkspaceForTransfer({ + workspaceId: effectiveWorkspaceId, + name: getWorkspacesSnapshot().workspaces + .find((workspace) => workspace.id === effectiveWorkspaceId)?.name ?? '', + serialize: persistence.serialize, + surfaceIds: memberSurfaceIds, + hasTerminal: surfaceHasTerminal, + }), closeAll, cancelClose, handleDorControl, diff --git a/lib/src/components/wall/use-session-persistence.ts b/lib/src/components/wall/use-session-persistence.ts index 5990e8c7a..cdd47e676 100644 --- a/lib/src/components/wall/use-session-persistence.ts +++ b/lib/src/components/wall/use-session-persistence.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, type RefObject } from 'react'; import { pasteFilePaths } from '../../lib/clipboard'; import { getPlatform } from '../../lib/platform'; -import { saveSession, type SaveOptions, type SaveSink } from '../../lib/session-save'; +import { buildPersistedSession, saveSession, type SaveOptions, type SaveSink } from '../../lib/session-save'; import { createSessionDirtyTracker } from '../../lib/session-dirty'; import { previousWorkspaceSession, publishWorkspaceSession, SESSION_SAVE_DEBOUNCE_MS } from '../../lib/window-session-aggregator'; import { hasWorkspace } from '../../lib/workspace-store'; @@ -13,12 +13,15 @@ import { import { surfaceKindFromParams } from './browser-surface'; import type { LathWallEngine } from './lath-wall-engine'; import type { DooredItem, WallSelectionKind } from './wall-types'; -import type { PersistedDoor, PersistedSurfaceRefs, WorkspaceId } from '../../lib/session-types'; +import type { PersistedDoor, PersistedSession, PersistedSurfaceRefs, WorkspaceId } from '../../lib/session-types'; import type { SessionFlushRequest } from '../../lib/platform/types'; export interface SessionPersistenceHandle { /** Persist immediately, awaiting the whole queued pipeline. */ flush: (options?: SaveOptions) => Promise; + /** Build this Workspace's record without publishing it — what a Workspace + * leaving for another Window carries with it. */ + serialize: (options?: SaveOptions) => Promise; } export function useSessionPersistence({ @@ -106,6 +109,16 @@ export function useSessionPersistence({ return saveSession(getPlatform(), panes, doors, lathLayout, surfaceRefs?.refs, surfaceRefs?.next, sink, options); }, [collect, sink]); + /** The same record a save would publish, handed back instead. The Workspace + * is leaving, so nothing here may touch this Window's aggregator. */ + const serialize = useCallback((options?: SaveOptions): Promise => { + const { panes, doors, lathLayout, surfaceRefs } = collect(); + return buildPersistedSession( + getPlatform(), panes, doors, lathLayout, surfaceRefs?.refs, surfaceRefs?.next, + sink?.previous() ?? null, options, + ); + }, [collect, sink]); + const persistSessionNow = useCallback(async (options?: SaveOptions): Promise => { const runSave = (): Promise => { pendingSaveNeededRef.current = false; @@ -261,5 +274,5 @@ export function useSessionPersistence({ selectedTypeRef, ]); - return { flush: flushSessionSave }; + return { flush: flushSessionSave, serialize }; } diff --git a/lib/src/components/wall/wall-handles.ts b/lib/src/components/wall/wall-handles.ts index 17b1f1f89..18fb9a45d 100644 --- a/lib/src/components/wall/wall-handles.ts +++ b/lib/src/components/wall/wall-handles.ts @@ -1,5 +1,6 @@ import type { WorkspaceId } from '../../lib/session-types'; import type { SaveOptions } from '../../lib/session-save'; +import type { WorkspaceTransferPayload } from './workspace-transfer'; import type { CloseSurfaceMode } from './wall-types'; import type { DorControlRequest } from './use-dor-control'; @@ -21,6 +22,11 @@ export interface WallHandle { runningCount(): number; /** Persist now. `probeCwd: false` skips the cwd re-read (`SessionFlushRequest`). */ flushPersistence(options?: SaveOptions): Promise; + /** Hand this Workspace to another Window: build its record, take its notes, + * and detach every Session **without killing one**. An explicit verb, never + * an unmount effect (`releaseSession` in + * `lib/src/lib/terminal-lifecycle.ts`). */ + releaseWorkspaceForTransfer(): Promise; /** Close every member Surface through the closure coordinator. Resolves null * once the Wall is empty, else the first refusal's message with the Workspace * left as it was. */ @@ -81,6 +87,13 @@ export function stubWallHandle(workspaceId: WorkspaceId, overrides: Partial false, runningCount: () => 0, flushPersistence: async () => {}, + releaseWorkspaceForTransfer: async () => ({ + workspaceId, + workspace: { id: workspaceId, name: '', session: { version: 3, panes: [] } }, + notepad: { surfaces: [], stagedDeletions: {} }, + terminalIds: [], + allIds: [], + }), closeAll: async () => null, cancelClose: () => {}, handleDorControl: () => {}, diff --git a/lib/src/components/wall/workspace-transfer.test.ts b/lib/src/components/wall/workspace-transfer.test.ts new file mode 100644 index 000000000..d1920f749 --- /dev/null +++ b/lib/src/components/wall/workspace-transfer.test.ts @@ -0,0 +1,81 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { addPlainNote, clearAllNotepads, getNotes, notepadSurfaceIds } from '../../lib/notepad/notepad-store'; +import { releaseWorkspaceForTransfer } from './workspace-transfer'; +import type { PersistedSession } from '../../lib/session-types'; + +/** + * The source half of a Workspace transfer. What matters is the order — a record + * built while the Sessions are still live, notes taken before they are + * forgotten, and the detach last — and that nothing here is a closure + * (`docs/specs/standalone.md` → "Transfer"). + */ + +const released: string[] = []; +vi.mock('../../lib/terminal-registry', () => ({ + releaseSession: (id: string) => void released.push(id), +})); + +const SESSION: PersistedSession = { version: 3, panes: [{ id: 'pane-a', title: 'a', cwd: '/tmp', untouched: false, alert: null }] }; + +beforeEach(() => { + released.length = 0; + clearAllNotepads(); +}); + +function deps(order: string[] = [], overrides: Partial[0]> = {}) { + return { + workspaceId: 'ws-id', + name: 'Deploys', + serialize: vi.fn(async () => { + order.push('serialize'); + return SESSION; + }), + surfaceIds: () => ['pane-a', 'browser-b'], + hasTerminal: (id: string) => id.startsWith('pane-'), + ...overrides, + }; +} + +describe('releaseWorkspaceForTransfer', () => { + it('serializes with a live cwd probe before anything is detached', async () => { + const order: string[] = []; + const d = deps(order, {}); + const payload = await releaseWorkspaceForTransfer({ + ...d, + serialize: vi.fn(async (options) => { + order.push(`serialize:${options?.probeCwd}`); + expect(released).toEqual([]); // still live: the probe has PTYs to ask + return SESSION; + }), + }); + + expect(order).toEqual(['serialize:true']); + expect(payload.workspace).toEqual({ id: 'ws-id', name: 'Deploys', session: SESSION }); + }); + + it('detaches only the terminal Surfaces, and never kills one', async () => { + const payload = await releaseWorkspaceForTransfer(deps()); + + expect(payload.terminalIds).toEqual(['pane-a']); + expect(payload.allIds).toEqual(['pane-a', 'browser-b']); + // A browser Surface needs nothing: its agent-browser session lives in the + // host and the target reopens from the persisted params. + expect(released).toEqual(['pane-a']); + }); + + it('carries the notes and forgets them here, archiving nothing', async () => { + addPlainNote('pane-a', 'keep me'); + addPlainNote('browser-b', 'and me'); + addPlainNote('elsewhere', 'not mine'); + + const payload = await releaseWorkspaceForTransfer(deps()); + + // The notes ride the payload… + expect(payload.notepad.surfaces.map((surface) => surface.surfaceId)).toEqual(['pane-a', 'browser-b']); + expect(payload.notepad.surfaces[0]!.notes[0]!.content).toMatchObject({ text: 'keep me' }); + // …and leave this Window, so the departed Workspace's notes do not linger. + expect(getNotes('pane-a')).toHaveLength(0); + // Another Workspace's notes are untouched. + expect(notepadSurfaceIds()).toEqual(['elsewhere']); + }); +}); diff --git a/lib/src/components/wall/workspace-transfer.ts b/lib/src/components/wall/workspace-transfer.ts new file mode 100644 index 000000000..2674ffd24 --- /dev/null +++ b/lib/src/components/wall/workspace-transfer.ts @@ -0,0 +1,77 @@ +import { snapshotNotepadForTransfer, removeSurface } from '../../lib/notepad/notepad-store'; +import { releaseSession } from '../../lib/terminal-registry'; +import type { VolatileNotepadSnapshot } from '../../lib/notepad/types'; +import type { PersistedSession, PersistedWorkspace, WorkspaceId } from '../../lib/session-types'; +import type { SaveOptions } from '../../lib/session-save'; + +/** + * Handing a Workspace to another Window (`docs/specs/standalone.md` → + * "Transfer"). The half that lives in the shared library: build the record, + * take the notes, and detach every Session **without killing it**. The host + * moves the PTY ownership and mounts the Workspace at the other end. + * + * Nothing here is a closure, so nothing is archived and nothing is killed. + */ + +export interface WorkspaceTransferPayload { + workspaceId: WorkspaceId; + /** What the target restores the Workspace from. */ + workspace: PersistedWorkspace; + /** The notes riding along; the target hydrates them. Pins do not travel — + * they are markers in xterm instances this release disposes. */ + notepad: VolatileNotepadSnapshot; + /** Member Surfaces holding a PTY: exactly what changes ownership. */ + terminalIds: string[]; + /** Every member Surface, browser ones included. */ + allIds: string[]; +} + +export interface ReleaseForTransferDeps { + workspaceId: WorkspaceId; + name: string; + /** The Workspace's record, built but not published. */ + serialize: (options?: SaveOptions) => Promise; + /** Member Surfaces: visible panes ∪ Doors. */ + surfaceIds: () => string[]; + /** Whether a member Surface has a PTY behind it. */ + hasTerminal: (id: string) => boolean; +} + +/** + * Detach a Workspace from this Window, returning everything the target needs. + * + * Order is load-bearing: + * + * 1. **Serialize first**, with a live cwd probe. The record reads the registry + * — untouched flags, retained alerts, each pane's cwd — and step 3 empties + * it. + * 2. **Take the notes, then forget them here.** A move is not a closure, so + * nothing is archived; leaving them behind would show the departed + * Workspace's notes in this Window. + * 3. **Release every Session.** Detached, never killed: the process keeps + * running and the target resumes over it. + */ +export async function releaseWorkspaceForTransfer( + deps: ReleaseForTransferDeps, +): Promise { + // The cwds are probed here and nowhere else: after step 3 the panes this + // Window could ask about are gone, and the target restores from this record. + const session = await deps.serialize({ probeCwd: true }); + const allIds = deps.surfaceIds(); + const terminalIds = allIds.filter(deps.hasTerminal); + + const notepad = snapshotNotepadForTransfer(allIds); + for (const id of allIds) removeSurface(id); + + // Browser Surfaces need nothing: their agent-browser session lives in the + // host, and the target reopens from the persisted params. + for (const id of terminalIds) releaseSession(id); + + return { + workspaceId: deps.workspaceId, + workspace: { id: deps.workspaceId, name: deps.name, session }, + notepad, + terminalIds, + allIds, + }; +} diff --git a/lib/src/lib/notepad/notepad-store.ts b/lib/src/lib/notepad/notepad-store.ts index 1457f7471..a54a29b67 100644 --- a/lib/src/lib/notepad/notepad-store.ts +++ b/lib/src/lib/notepad/notepad-store.ts @@ -454,8 +454,27 @@ export function notepadSurfaceIds(): string[] { /** Everything a close would archive for every Surface holding notes, minus the * markers (`toArchivedNote` strips them). */ export function buildVolatileSnapshot(): VolatileNotepadSnapshot { + return collectVolatile(notepadSurfaceIds()); +} + +/** + * The notes riding along with a Workspace moving to another Window + * (`docs/specs/notepad.md` → "Closure"). **A transfer archives nothing**: a + * move is not a closure, so the notes travel in this snapshot and the target + * hydrates them with `hydrateNotepadFromVolatile`. + * + * Source pins do not travel: a pin is a marker in an xterm instance, and the + * source Window's instances are disposed by the release behind this. The + * projection drops them anyway (`toArchivedNote`). + */ +export function snapshotNotepadForTransfer(surfaceIds: Iterable): VolatileNotepadSnapshot { + const wanted = new Set(surfaceIds); + return collectVolatile(notepadSurfaceIds().filter((id) => wanted.has(id))); +} + +function collectVolatile(ids: readonly string[]): VolatileNotepadSnapshot { const surfaces: VolatileSurfaceNotes[] = []; - for (const surfaceId of notepadSurfaceIds()) { + for (const surfaceId of ids) { const notes = getNotes(surfaceId); const pendingBatchId = pendingBatchIdBySurface.get(surfaceId); const meta = getNotepadSurfaceMeta(surfaceId); diff --git a/lib/src/lib/session-save.ts b/lib/src/lib/session-save.ts index ec59aa505..127405c0e 100644 --- a/lib/src/lib/session-save.ts +++ b/lib/src/lib/session-save.ts @@ -53,8 +53,15 @@ async function probeCwds( return cwds; } -/** Build one Workspace's `PersistedSession` from its live panes and Doors. */ -async function buildPersistedSession( +/** + * Build one Workspace's `PersistedSession` from its live panes and Doors. + * + * Exported for the transfer verb, which needs the record WITHOUT publishing it: + * the Workspace is leaving this Window, so its record belongs in the payload + * rather than in this Window's aggregator + * (`releaseWorkspaceForTransfer` in `lib/src/components/wall/workspace-transfer.ts`). + */ +export async function buildPersistedSession( platform: PlatformAdapter, panes: SavePaneInput[], doors: PersistedDoor[] = [], diff --git a/lib/src/lib/terminal-lifecycle.release.test.ts b/lib/src/lib/terminal-lifecycle.release.test.ts new file mode 100644 index 000000000..d98870999 --- /dev/null +++ b/lib/src/lib/terminal-lifecycle.release.test.ts @@ -0,0 +1,116 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +/** + * The two teardown verbs differ in exactly one thing — whether the process + * dies — and that difference is what makes a Workspace transfer possible + * (`docs/specs/transport.md` → "Transferring a Workspace"). + */ + +vi.mock('@xterm/addon-fit', () => ({ + FitAddon: class { + fit(): void {} + proposeDimensions(): { cols: number; rows: number } { return { cols: 80, rows: 24 }; } + }, +})); +vi.mock('@xterm/addon-image', () => ({ ImageAddon: class {} })); +vi.mock('@xterm/addon-unicode-graphemes', () => ({ UnicodeGraphemesAddon: class {} })); +vi.mock('@xterm/xterm', () => ({ + Terminal: class { + parser = { registerCsiHandler: () => ({ dispose: () => {} }) }; + modes = { mouseTrackingMode: 'none' as const, bracketedPasteMode: false }; + unicode = { activeVersion: '11' }; + disposed = false; + loadAddon(): void {} + open(): void {} + write(): void {} + focus(): void {} + blur(): void {} + onData(): { dispose: () => void } { return { dispose: () => {} }; } + onResize(): { dispose: () => void } { return { dispose: () => {} }; } + onRender(): { dispose: () => void } { return { dispose: () => {} }; } + dispose(): void { this.disposed = true; } + }, +})); + +vi.mock('./platform', async () => { + const actual = await vi.importActual('./platform'); + const fakePlatform = new actual.FakePtyAdapter(); + return { ...actual, getPlatform: () => fakePlatform, __fakePlatform: fakePlatform }; +}); + +import * as platformModule from './platform'; +import type { FakePtyAdapter } from './platform'; +import { + addPlainNote, + addTerminalNote, + getNotes, + removeSurface, +} from './notepad/notepad-store'; +import { + disposeSession, + getOrCreateTerminal, + getTerminalInstance, + releaseSession, +} from './terminal-registry'; + +const platform = (platformModule as unknown as { __fakePlatform: FakePtyAdapter }).__fakePlatform; + +let killed: string[]; + +/** A pin, without a real xterm buffer behind it: what the notepad holds is two + * markers it must be able to dispose. */ +function fakeSource(terminalId: string) { + return { + terminalId, + startMarker: { line: 0, dispose: vi.fn() }, + endMarker: { line: 0, dispose: vi.fn() }, + } as unknown as Parameters[2]; +} + +beforeEach(() => { + killed = []; + vi.spyOn(platform, 'killPty').mockImplementation((id: string) => void killed.push(id)); + for (const id of ['pane-1', 'pane-2']) removeSurface(id); +}); + +describe('releaseSession', () => { + it('never kills the PTY, unlike disposeSession', () => { + getOrCreateTerminal('pane-1'); + releaseSession('pane-1'); + expect(killed).toEqual([]); + + getOrCreateTerminal('pane-2'); + disposeSession('pane-2'); + expect(killed).toEqual(['pane-2']); + }); + + it("drops this webview's half of the Session", () => { + getOrCreateTerminal('pane-1'); + expect(getTerminalInstance('pane-1')).not.toBeNull(); + releaseSession('pane-1'); + // The registry entry and the xterm instance are gone: the target Window + // builds its own over the same, still-running PTY. + expect(getTerminalInstance('pane-1')).toBeNull(); + }); + + it('leaves the notes for the transfer payload but drops their pins', () => { + getOrCreateTerminal('pane-1'); + addPlainNote('pane-1', 'keep me'); + addTerminalNote('pane-1', [{ text: 'captured' }], fakeSource('pane-1')); + + releaseSession('pane-1'); + + const notes = getNotes('pane-1'); + expect(notes).toHaveLength(2); + expect(notes.map((note) => note.content.kind)).toEqual(['plain', 'terminal']); + // A pin is a marker in the xterm instance this release disposed, so it + // cannot survive the move; the note itself rides the payload. + expect(notes.every((note) => note.source === undefined)).toBe(true); + }); + + it('is a no-op for an id the registry does not hold', () => { + expect(() => releaseSession('never-existed')).not.toThrow(); + expect(killed).toEqual([]); + }); +}); diff --git a/lib/src/lib/terminal-lifecycle.ts b/lib/src/lib/terminal-lifecycle.ts index ffe115a8e..042393048 100644 --- a/lib/src/lib/terminal-lifecycle.ts +++ b/lib/src/lib/terminal-lifecycle.ts @@ -629,7 +629,15 @@ export function disposeAllSessions(): void { } } -export function disposeSession(id: string): void { +/** + * Tear this webview's half of a Session down: the alert, the notepad pins, the + * listeners, the element and the xterm instance, plus the registry, pane, + * selection and activity state keyed to it. + * + * `kill` is the only difference between the two verbs below, and it is the + * whole difference between ending a Session and letting another Window take it. + */ +function teardownSession(id: string, { kill }: { kill: boolean }): void { const entry = registry.get(id); if (!entry) return; getPlatform().alertRemove(id); @@ -637,7 +645,7 @@ export function disposeSession(id: string): void { // a disposed marker cannot be dropped cleanly afterwards. The notes stay. dropSourcesForTerminal(id); entry.cleanup(); - getPlatform().killPty(id); + if (kill) getPlatform().killPty(id); entry.element.remove(); entry.terminal.dispose(); registry.delete(id); @@ -646,6 +654,25 @@ export function disposeSession(id: string): void { clearTerminalActivity(id); } +/** End a Session: the process goes with it. */ +export function disposeSession(id: string): void { + teardownSession(id, { kill: true }); +} + +/** + * Detach a Session from this Window WITHOUT killing it — the process keeps + * running and another Window resumes over it + * (`docs/specs/transport.md` → "Transferring a Workspace"). + * + * **Never reachable from a Wall unmount.** A Wall unmounts on a reload, a + * StrictMode double-mount, and a Workspace switch, and releasing there would + * silently strand every PTY the Window still owns. The only caller is the + * explicit transfer verb on the Wall's handle. + */ +export function releaseSession(id: string): void { + teardownSession(id, { kill: false }); +} + export function refitSession(id: string): void { const entry = registry.get(id); if (!entry) return; diff --git a/lib/src/lib/terminal-registry.ts b/lib/src/lib/terminal-registry.ts index 779d2131e..0c02a15a9 100644 --- a/lib/src/lib/terminal-registry.ts +++ b/lib/src/lib/terminal-registry.ts @@ -50,6 +50,7 @@ export { mountElement, refitSession, registerSurfaceFocusHandle, + releaseSession, restoreTerminal, resumeTerminal, setPendingShellOpts, From fcb7d6e3222b61b1af011f30e83e55fbe06ef3b5 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 10 Sep 2026 00:04:19 -0700 Subject: [PATCH 05/36] Move a Workspace between windows without losing or duplicating output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The source releases the Workspace and hands Rust its record, its notes and its PTY ids; Rust reassigns ownership synchronously and suppresses those PTYs until each one's replay has reached the target. The target arms its collector first and only then calls `adopt_ready`, which is what removes the whole "arrived before armed" bug class — nothing is listed or replayed until something is listening for it. `collectLivePtys` gained a trigger and an id filter so an arrival asks for exactly the PTYs that just moved instead of the whole Window. A Workspace created after first render reads its boot plan from a parking store, since creating it mounts the Wall that needs it. A window whose last Workspace leaves closes itself: nothing ended, so nothing is confirmed, archived or killed. A torn-out window pulls its boot payload, because an emit to a window that does not exist yet is lost. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe --- lib/src/components/WorkspaceWindow.tsx | 7 +- .../components/wall/workspace-boot-plans.ts | 42 +++ lib/src/lib/reconnect.ts | 27 +- standalone/src-tauri/src/lib.rs | 6 + standalone/src/main.tsx | 11 +- standalone/src/window-restore.ts | 21 +- standalone/src/workspace-move.test.ts | 266 ++++++++++++++++++ standalone/src/workspace-move.ts | 200 +++++++++++++ 8 files changed, 569 insertions(+), 11 deletions(-) create mode 100644 lib/src/components/wall/workspace-boot-plans.ts create mode 100644 standalone/src/workspace-move.test.ts create mode 100644 standalone/src/workspace-move.ts diff --git a/lib/src/components/WorkspaceWindow.tsx b/lib/src/components/WorkspaceWindow.tsx index ba2151358..0c0fec22f 100644 --- a/lib/src/components/WorkspaceWindow.tsx +++ b/lib/src/components/WorkspaceWindow.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useSyncExternalStore, type ReactNode } from 'react'; import { clsx } from 'clsx'; import { Wall } from './Wall'; import { listWallHandles } from './wall/wall-handles'; +import { getWorkspaceBootPlan } from './wall/workspace-boot-plans'; import { getPlatform } from '../lib/platform'; import { getWorkspacesSnapshot, subscribeToWorkspaces } from '../lib/workspace-store'; import type { SessionFlushRequest } from '../lib/platform/types'; @@ -35,6 +36,10 @@ export function WorkspaceWindow({ // exactly one default-shell pane. const plansRef = useRef(null); const plans = (plansRef.current ??= initialPlans ?? { [activeId]: boot }); + // A Workspace created after first render — one arriving from another Window — + // reads its plan from the parking store instead. Latched here so its Wall + // sees the same record on every later render, and never a fresh one. + const planFor = (id: string) => (plans[id] ??= getWorkspaceBootPlan(id) ?? {}); // The Window, not each Wall, answers the host's flush request: the adapter // completes on the FIRST notification, so a per-Wall answer would let a quit @@ -59,7 +64,7 @@ export function WorkspaceWindow({ > {workspaces.map((workspace) => { const isActive = workspace.id === activeId; - const plan = plans[workspace.id] ?? {}; + const plan = planFor(workspace.id); return (
(); + +/** + * Park the plan a Workspace's Wall will mount from. **Must be set before the + * Workspace is created**: `createWorkspace` renders the Wall synchronously, and + * a Wall with no plan takes the fresh branch and spawns a default pane over the + * Sessions that just arrived. + */ +export function setWorkspaceBootPlan(workspaceId: WorkspaceId, plan: WallBootProps): void { + // Self-cleaning: a plan for a Workspace this Window no longer holds can never + // be read again. Never prunes the one being set — it is created next. + const live = new Set(getWorkspacesSnapshot().workspaces.map((workspace) => workspace.id)); + for (const id of plans.keys()) { + if (id !== workspaceId && !live.has(id)) plans.delete(id); + } + plans.set(workspaceId, plan); +} + +/** The parked plan, or undefined. Non-destructive: `WorkspaceWindow` may render + * twice before it mounts (StrictMode), and both renders must see the same one. */ +export function getWorkspaceBootPlan(workspaceId: WorkspaceId): WallBootProps | undefined { + return plans.get(workspaceId); +} + +/** Forget every parked plan (tests). */ +export function resetWorkspaceBootPlans(): void { + plans.clear(); +} diff --git a/lib/src/lib/reconnect.ts b/lib/src/lib/reconnect.ts index c13058ff4..073721590 100644 --- a/lib/src/lib/reconnect.ts +++ b/lib/src/lib/reconnect.ts @@ -58,6 +58,19 @@ export async function resumeOrRestore(platform: PlatformAdapter): Promise void; + /** Which ids this collection is about; everything else in the answer is + * another Workspace's and must not be taken for it. */ + accept?: (id: string) => boolean; + /** The ceiling on waiting for replays. */ + timeoutMs?: number; +} + /** * Ask the host for its PTYs and gather the replay each one sends back. * @@ -65,21 +78,26 @@ export async function resumeOrRestore(platform: PlatformAdapter): Promise { +export function collectLivePtys( + platform: PlatformAdapter, + options: CollectPtysOptions = {}, +): Promise { + const accept = options.accept ?? (() => true); return new Promise((resolve) => { const replay = new Map(); let ptyList: PtyInfo[] | null = null; - const timeout = setTimeout(() => finish(), 500); + const timeout = setTimeout(() => finish(), options.timeoutMs ?? 500); const handleList = (detail: { ptys: PtyInfo[] }) => { - ptyList = detail.ptys; + ptyList = detail.ptys.filter((pty) => accept(pty.id)); if (ptyList.length === 0) { finish(); } }; const handleReplay = (detail: { id: string; data: string }) => { + if (!accept(detail.id)) return; replay.set(detail.id, detail.data); if (ptyList && replay.size >= ptyList.length) { finish(); @@ -98,7 +116,8 @@ export function collectLivePtys(platform: PlatformAdapter): Promise { platform.onPtyList(handleList); platform.onPtyReplay(handleReplay); - platform.requestInit(); + // Last: the handlers must be armed before anything can answer. + (options.trigger ?? (() => platform.requestInit()))(); }); } diff --git a/standalone/src-tauri/src/lib.rs b/standalone/src-tauri/src/lib.rs index 01486b416..f226a8818 100644 --- a/standalone/src-tauri/src/lib.rs +++ b/standalone/src-tauri/src/lib.rs @@ -2014,6 +2014,12 @@ fn transfer_workspace( return Err("a Workspace cannot be transferred to its own window".to_string()); } windows.reassign(&payload_terminal_ids(&payload), &to); + // Forward before the content lands: the user dropped here, so this is the + // window they are now looking at, and a background webview may be throttled + // out of answering `adopt_ready` promptly. + if let Some(target) = app.get_webview_window(&to) { + let _ = target.set_focus(); + } let _ = app.emit_to(to.as_str(), "dormouse://workspace-arriving", payload.clone()); announce_departure( &app, diff --git a/standalone/src/main.tsx b/standalone/src/main.tsx index e97575e76..b21371298 100644 --- a/standalone/src/main.tsx +++ b/standalone/src/main.tsx @@ -139,7 +139,16 @@ async function bootstrap() { // omits `shell` and the sidecar resolves the OS default itself. seedShellStore(await shellsPromise); - const initialPlans = await restoreWindowOrFresh(platform); + // A window Rust just built for a torn-out Workspace boots from the payload it + // parked, not from disk: it has no snapshot yet (§Tear-out). Everything else + // restores what the last run left. + let initialPlans: Awaited> | null = null; + if (!BROWSER_DEV_HOST) { + const { bootFromTearOut, initWorkspaceMoves } = await import("./workspace-move"); + initialPlans = await bootFromTearOut(platform); + initWorkspaceMoves(platform); + } + initialPlans ??= await restoreWindowOrFresh(platform); // `main` is the only window holding `updater:*` and it is the last one the // quit walk tears down, so it is the only one that may check or install diff --git a/standalone/src/window-restore.ts b/standalone/src/window-restore.ts index 46332ecc1..cc50e4c19 100644 --- a/standalone/src/window-restore.ts +++ b/standalone/src/window-restore.ts @@ -100,13 +100,17 @@ export async function restoreWindowOrFresh(platform: PlatformAdapter): Promise { - // Before any Wall mounts: a Workspace's first save compares against its own - // record, and a snapshot taken mid-boot must not replace a restored Workspace - // with a blank one. +): void { + // A Workspace's first save compares against its own record, and a snapshot + // taken mid-boot must not replace a restored Workspace with a blank one. seedWindowSession(saved); if (saved) { setWorkspaces({ @@ -117,6 +121,13 @@ async function restoreWindow( // After `setWorkspaces`, so installing does not immediately write back what was // just read. installWindowSessionWriter((snapshot) => platform.saveWindowState?.(snapshot)); +} + +async function restoreWindow( + platform: PlatformAdapter, + saved: PersistedWindow | null, +): Promise { + installWindowPersistence(platform, saved); const live: LivePtys = await collectLivePtys(platform); const restoring: Array<{ id: WorkspaceId; session: PersistedSession | null }> = diff --git a/standalone/src/workspace-move.test.ts b/standalone/src/workspace-move.test.ts new file mode 100644 index 000000000..8e34ab80f --- /dev/null +++ b/standalone/src/workspace-move.test.ts @@ -0,0 +1,266 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { PlatformAdapter, PtyInfo } from "dormouse-lib/lib/platform/types"; +import type { WorkspaceTransferPayload } from "dormouse-lib/components/wall/workspace-transfer"; + +/** + * The two halves of a Workspace move. What is observable here is the ordering — + * the source releases before it invokes, the target arms before it says it is + * ready — and the two rules that keep the Sessions intact: nothing is killed, + * and the plan is parked before the Workspace is created + * (`docs/specs/standalone.md` → "Transfer"). + */ + +const mocks = vi.hoisted(() => ({ + invoke: vi.fn(async (_cmd: string, _args?: unknown) => undefined as unknown), + listen: vi.fn(async () => () => {}), +})); +vi.mock("@tauri-apps/api/core", () => ({ invoke: mocks.invoke })); +vi.mock("@tauri-apps/api/event", () => ({ listen: mocks.listen })); + +// The resume builds real xterm instances; jsdom has no canvas, and what this +// file is about is the protocol around them. +vi.mock("@xterm/addon-fit", () => ({ + FitAddon: class { + fit(): void {} + proposeDimensions(): { cols: number; rows: number } { return { cols: 80, rows: 24 }; } + }, +})); +vi.mock("@xterm/addon-image", () => ({ ImageAddon: class {} })); +vi.mock("@xterm/addon-unicode-graphemes", () => ({ UnicodeGraphemesAddon: class {} })); +vi.mock("@xterm/xterm", () => ({ + Terminal: class { + parser = { registerCsiHandler: () => ({ dispose: () => {} }) }; + modes = { mouseTrackingMode: "none" as const, bracketedPasteMode: false }; + loadAddon(): void {} + open(): void {} + write(): void {} + focus(): void {} + blur(): void {} + onData(): { dispose: () => void } { return { dispose: () => {} }; } + onResize(): { dispose: () => void } { return { dispose: () => {} }; } + onRender(): { dispose: () => void } { return { dispose: () => {} }; } + dispose(): void {} + }, +})); + +import { + bootFromTearOut, + initWorkspaceMoves, + tearOutWorkspace, + transferWorkspaceTo, + workspaceDropIndex, +} from "./workspace-move"; +import { registerWallHandle, resetWallHandles, stubWallHandle } from "dormouse-lib/components/wall/wall-handles"; +import { + getWorkspaceBootPlan, + resetWorkspaceBootPlans, +} from "dormouse-lib/components/wall/workspace-boot-plans"; +import { getWorkspacesSnapshot, resetWorkspaces } from "dormouse-lib/lib/workspace-store"; +import { getNotes, clearAllNotepads } from "dormouse-lib/lib/notepad/notepad-store"; +import { resetWindowSessionAggregator } from "dormouse-lib/lib/window-session-aggregator"; +import { setPlatform } from "dormouse-lib/lib/platform"; +import { FakePtyAdapter } from "dormouse-lib/lib/platform/fake-adapter"; + +const WORKSPACE_ID = "ws-moving"; + +function payload(overrides: Partial = {}): WorkspaceTransferPayload { + return { + workspaceId: WORKSPACE_ID, + workspace: { + id: WORKSPACE_ID, + name: "Deploys", + session: { + version: 3, + panes: [{ id: "pane-a", title: "a", cwd: "/tmp", untouched: false, alert: null }], + }, + }, + notepad: { + surfaces: [{ + surfaceId: "pane-a", + surfaceTitle: "a", + surfaceKind: "terminal", + cwd: null, + terminalId: "pane-a", + notes: [{ id: "n1", createdAt: 1, content: { kind: "plain", text: "keep me" } }], + }], + stagedDeletions: {}, + }, + terminalIds: ["pane-a"], + allIds: ["pane-a"], + ...overrides, + }; +} + +/** What `take_boot_payload` answers, for the tear-out boot. */ +let parkedPayload: WorkspaceTransferPayload | null = null; + +/** + * A real adapter whose `pty:list` / `pty:replay` answer only once something + * asks — which is the property the `adopt_ready` hop exists to guarantee. + */ +function fakePlatform(order: string[] = []): PlatformAdapter { + const platform = new FakePtyAdapter(); + let listHandler: ((detail: { ptys: PtyInfo[] }) => void) | null = null; + let replayHandler: ((detail: { id: string; data: string }) => void) | null = null; + vi.spyOn(platform, "onPtyList").mockImplementation((handler) => { listHandler = handler; }); + vi.spyOn(platform, "offPtyList").mockImplementation(() => { listHandler = null; }); + vi.spyOn(platform, "onPtyReplay").mockImplementation((handler) => { replayHandler = handler; }); + vi.spyOn(platform, "offPtyReplay").mockImplementation(() => { replayHandler = null; }); + vi.spyOn(platform, "requestInit").mockImplementation(() => { + throw new Error("an arrival must never ask for the whole Window"); + }); + // The fake adapter has no AlertManager, so give it the optional hook the + // arrival seeds a persisted TODO through. + (platform as unknown as { alertSeed: unknown }).alertSeed = vi.fn(); + mocks.invoke.mockImplementation(async (cmd: string) => { + order.push(cmd); + if (cmd === "take_boot_payload") return parkedPayload; + if (cmd === "adopt_ready") { + order.push("answered"); + listHandler?.({ ptys: [{ id: "pane-a", alive: true } as PtyInfo] }); + replayHandler?.({ id: "pane-a", data: "scrollback" }); + } + return undefined; + }); + setPlatform(platform); + return platform; +} + +beforeEach(() => { + vi.clearAllMocks(); + parkedPayload = null; + mocks.invoke.mockResolvedValue(undefined); + mocks.listen.mockResolvedValue(() => {}); + resetWallHandles(); + resetWorkspaceBootPlans(); + resetWorkspaces(); + resetWindowSessionAggregator(); + clearAllNotepads(); +}); + +describe("the source half", () => { + it("releases the Workspace before it tells the host, and never kills a Session", async () => { + const order: string[] = []; + mocks.invoke.mockImplementation(async (cmd: string) => void order.push(cmd)); + registerWallHandle(stubWallHandle(WORKSPACE_ID, { + releaseWorkspaceForTransfer: async () => { + order.push("release"); + return payload(); + }, + })); + + await transferWorkspaceTo(WORKSPACE_ID, "ws-2", { x: 10, y: 4 }); + + // Ownership moves in the invoke, so the Sessions must already be detached: + // an xterm still attached would take input the target now owns. + expect(order).toEqual(["release", "transfer_workspace"]); + const [, args] = mocks.invoke.mock.calls[0]!; + expect(args).toMatchObject({ to: "ws-2", payload: { at: { x: 10, y: 4 }, terminalIds: ["pane-a"] } }); + }); + + it("tears out into a new window with the drop point", async () => { + registerWallHandle(stubWallHandle(WORKSPACE_ID, { releaseWorkspaceForTransfer: async () => payload() })); + await tearOutWorkspace(WORKSPACE_ID, { x: 900, y: 300 }); + expect(mocks.invoke).toHaveBeenCalledWith("open_workspace_window", { + payload: expect.objectContaining({ at: { x: 900, y: 300 } }), + }); + }); + + it("does nothing when the Workspace has no mounted Wall", async () => { + await transferWorkspaceTo("gone", "ws-2", { x: 0, y: 0 }); + expect(mocks.invoke).not.toHaveBeenCalled(); + }); +}); + +describe("the target half", () => { + /** Fire the listener `initWorkspaceMoves` registered for `event`. */ + const emit = async (event: string, data: unknown) => { + const calls = mocks.listen.mock.calls as unknown as Array<[string, (e: { payload: unknown }) => void]>; + calls.find(([name]) => name === event)![1]({ payload: data }); + await new Promise((r) => setTimeout(r, 0)); + }; + + it("arms its collector, then asks — and mounts the Workspace it gets back", async () => { + const order: string[] = []; + const platform = fakePlatform(order); + initWorkspaceMoves(platform); + + await emit("dormouse://workspace-arriving", payload()); + + // The `adopt_ready` hop is what removes the "arrived before armed" bug + // class: nothing is listed or replayed until the collector is listening. + expect(order).toEqual(["adopt_ready", "answered"]); + // The plan is parked before the Workspace exists, because creating it + // mounts the Wall that reads it. + expect(getWorkspaceBootPlan(WORKSPACE_ID)).toBeTruthy(); + const { workspaces, activeId } = getWorkspacesSnapshot(); + expect(workspaces.map((workspace) => workspace.name)).toContain("Deploys"); + expect(activeId).toBe(WORKSPACE_ID); + // The notes travelled in the payload; nothing was archived. + expect(getNotes("pane-a").map((note) => note.content)).toEqual([{ kind: "plain", text: "keep me" }]); + }); + + it("seeds a persisted TODO into this window's own AlertManager", async () => { + const platform = fakePlatform(); + initWorkspaceMoves(platform); + const alert = { kind: "todo" } as never; + const moving = payload(); + moving.workspace.session.panes[0]!.alert = alert; + + await emit("dormouse://workspace-arriving", moving); + + expect(platform.alertSeed).toHaveBeenCalledWith("pane-a", alert); + }); + + it("closes the window when its last Workspace leaves, instead of emptying it", async () => { + initWorkspaceMoves(fakePlatform()); + mocks.invoke.mockImplementation(async () => undefined); + + await emit("dormouse://workspace-departed", { workspaceId: getWorkspacesSnapshot().activeId }); + + // Nothing ended — the Surfaces are alive in another window — so this is a + // close with no confirmation, no archive and no kill. + expect(mocks.invoke).toHaveBeenCalledWith("close_window_self"); + expect(getWorkspacesSnapshot().workspaces).toHaveLength(1); + }); +}); + +describe("a torn-out window's boot", () => { + it("boots from the parked payload rather than from disk", async () => { + const order: string[] = []; + parkedPayload = payload(); + const platform = fakePlatform(order); + + const plans = await bootFromTearOut(platform); + + expect(order.slice(0, 2)).toEqual(["take_boot_payload", "adopt_ready"]); + expect(Object.keys(plans ?? {})).toEqual([WORKSPACE_ID]); + // The window has no snapshot yet; its Workspace comes from the payload. + expect(getWorkspacesSnapshot().workspaces.map((workspace) => workspace.name)).toEqual(["Deploys"]); + expect(getNotes("pane-a")).toHaveLength(1); + }); + + it("returns null for an ordinary window", async () => { + const platform = fakePlatform(); + expect(await bootFromTearOut(platform)).toBeNull(); + }); +}); + +describe("workspaceDropIndex", () => { + it("inserts before the first tab whose center the drop is left of", () => { + document.body.innerHTML = ""; + for (const [index, left] of [0, 100, 200].entries()) { + const tab = document.createElement("div"); + tab.dataset.workspaceTab = `w${index}`; + tab.getBoundingClientRect = () => ({ left, width: 100 }) as DOMRect; + document.body.append(tab); + } + expect(workspaceDropIndex({ x: 10, y: 0 })).toBe(0); + // Between tab 1's center (150) and tab 2's (250): it takes index 2. + expect(workspaceDropIndex({ x: 160, y: 0 })).toBe(2); + // Past the last tab's center: appended, which is what undefined means. + expect(workspaceDropIndex({ x: 900, y: 0 })).toBeUndefined(); + expect(workspaceDropIndex(undefined)).toBeUndefined(); + }); +}); diff --git a/standalone/src/workspace-move.ts b/standalone/src/workspace-move.ts new file mode 100644 index 000000000..2ab53968f --- /dev/null +++ b/standalone/src/workspace-move.ts @@ -0,0 +1,200 @@ +import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; +import { collectLivePtys, resumeOrRestoreFrom } from "dormouse-lib/lib/reconnect"; +import { hydrateNotepadFromVolatile } from "dormouse-lib/lib/notepad/notepad-store"; +import { getWallHandle } from "dormouse-lib/components/wall/wall-handles"; +import { setWorkspaceBootPlan } from "dormouse-lib/components/wall/workspace-boot-plans"; +import { wallBootFromResult, type WallBootPlans } from "dormouse-lib/components/wall/wall-types"; +import type { WorkspaceTransferPayload } from "dormouse-lib/components/wall/workspace-transfer"; +import { + forgetWorkspaceSession, + publishWorkspaceSession, +} from "dormouse-lib/lib/window-session-aggregator"; +import { + closeWorkspace, + createWorkspace, + getWorkspacesSnapshot, + moveWorkspace, + setActiveWorkspace, +} from "dormouse-lib/lib/workspace-store"; +import type { PlatformAdapter } from "dormouse-lib/lib/platform/types"; +import type { WorkspaceId } from "dormouse-lib/lib/session-types"; +import { installWindowPersistence } from "./window-restore"; + +/** + * Moving a Workspace between Windows (`docs/specs/standalone.md` → "Transfer" + * and "Tear-out"). Both halves live here, because they are one protocol: + * + * - **Source**: release the Workspace (its record, its notes, its Sessions + * detached but alive) and hand the payload to Rust. + * - **Target**: arm a collector, tell Rust it is ready, resume over the PTYs + * whose ownership already moved, and mount the Workspace. + * + * Rust reassigns ownership *synchronously* when the source invokes, and + * suppresses those PTYs' output until each one's replay has been emitted to the + * target — so between the two halves no byte is painted twice and none is lost. + */ + +/** Wire the payload up as one drop point, so both invokes carry the same shape. */ +interface MovePayload extends WorkspaceTransferPayload { + /** Where the pointer released, in the target window's logical client space. + * The target turns it into a strip index; it alone knows its own tabs. */ + at?: { x: number; y: number }; +} + +/** + * A replay is a whole 200k-char buffer per PTY crossing the sidecar's stdio, so + * give an arrival more room than boot's 500 ms before giving up on one. + */ +const ARRIVAL_TIMEOUT_MS = 3000; + +// --- Source ------------------------------------------------------------------ + +async function release(workspaceId: WorkspaceId): Promise { + const handle = getWallHandle(workspaceId); + if (!handle) return null; + return handle.releaseWorkspaceForTransfer(); +} + +/** Hand this Workspace to a window that already exists. */ +export async function transferWorkspaceTo( + workspaceId: WorkspaceId, + to: string, + at: { x: number; y: number }, +): Promise { + const payload = await release(workspaceId); + if (!payload) return; + try { + await invoke("transfer_workspace", { to, payload: { ...payload, at } satisfies MovePayload }); + } catch (err) { + console.error("[workspace-move] transfer failed", err); + } +} + +/** Tear this Workspace out into a new window under the cursor. */ +export async function tearOutWorkspace( + workspaceId: WorkspaceId, + at: { x: number; y: number }, +): Promise { + const payload = await release(workspaceId); + if (!payload) return; + try { + await invoke("open_workspace_window", { payload: { ...payload, at } satisfies MovePayload }); + } catch (err) { + console.error("[workspace-move] tear-out failed", err); + } +} + +// --- Target ------------------------------------------------------------------ + +/** + * Resume the arriving Workspace's Sessions and build the plan its Wall mounts + * from. `adopt_ready` is the hop that removes the "arrived before armed" bug + * class: the host does not list or replay anything until the collector below is + * listening. + */ +async function planArrival( + platform: PlatformAdapter, + payload: MovePayload, +): Promise { + const ptyIds = new Set(payload.terminalIds); + const live = await collectLivePtys(platform, { + trigger: () => void invoke("adopt_ready").catch((err) => + console.error("[workspace-move] adopt_ready failed", err)), + accept: (id) => ptyIds.has(id), + timeoutMs: ARRIVAL_TIMEOUT_MS, + }); + const result = resumeOrRestoreFrom(platform, live, { + savedSession: payload.workspace.session, + ptyIds, + }); + // The notes travelled in the payload rather than through the archive: a move + // is not a closure (`docs/specs/notepad.md` → "Closure"). + hydrateNotepadFromVolatile(payload.notepad, payload.allIds); + // The AlertManager is per webview, so a persisted TODO has to be seeded into + // this one — the source's went with its window. + for (const pane of payload.workspace.session.panes) { + if (pane.alert) platform.alertSeed?.(pane.id, pane.alert); + } + return wallBootFromResult(result); +} + +/** + * Where a drop lands in this window's strip: the index its tab takes. Undefined + * appends, which is also what a drop past the last tab means. + */ +export function workspaceDropIndex(at: { x: number; y: number } | undefined): number | undefined { + if (!at) return undefined; + const tabs = [...document.querySelectorAll("[data-workspace-tab]")]; + for (const [index, tab] of tabs.entries()) { + const rect = tab.getBoundingClientRect(); + if (at.x < rect.left + rect.width / 2) return index; + } + return undefined; +} + +/** Mount an arriving Workspace and bring this window forward. */ +async function adoptWorkspace(platform: PlatformAdapter, payload: MovePayload): Promise { + const { id, name, session } = payload.workspace; + const plan = await planArrival(platform, payload); + // Before `createWorkspace`, which mounts the Wall that reads it. + setWorkspaceBootPlan(id, plan); + // Before the store change too, so the Window blob it triggers already carries + // the arriving Workspace's record rather than an empty one. + publishWorkspaceSession(id, session); + const index = workspaceDropIndex(payload.at); + createWorkspace({ id, name }); + if (index !== undefined) moveWorkspace(id, index); + setActiveWorkspace(id); +} + +/** + * The source's view of the departure. Rust emits it whichever way the Workspace + * left, so this is the one place the source drops it. + */ +function handleDeparted(workspaceId: WorkspaceId): void { + // Moving a Window's last Workspace away closes it — without confirming, + // archiving or killing, because nothing ended: the Surfaces are alive + // somewhere else (`docs/specs/standalone.md` → "Transfer"). + if (getWorkspacesSnapshot().workspaces.length <= 1) { + forgetWorkspaceSession(workspaceId); + void invoke("close_window_self").catch((err) => + console.error("[workspace-move] close_window_self failed", err)); + return; + } + closeWorkspace(workspaceId); + forgetWorkspaceSession(workspaceId); +} + +/** Listen for Workspaces arriving in, and leaving, this window. */ +export function initWorkspaceMoves(platform: PlatformAdapter): void { + void listen("dormouse://workspace-arriving", (event) => { + void adoptWorkspace(platform, event.payload).catch((err) => + console.error("[workspace-move] adoption failed", err)); + }); + void listen<{ workspaceId: WorkspaceId }>("dormouse://workspace-departed", (event) => { + handleDeparted(event.payload.workspaceId); + }); +} + +/** + * Boot a window that was just torn out. Its payload is *pulled* rather than + * pushed: an `emit_to` a window that does not exist yet is lost, so Rust parks + * it and the new webview takes it here. Returns null for an ordinary window. + */ +export async function bootFromTearOut(platform: PlatformAdapter): Promise { + let payload: MovePayload | null = null; + try { + payload = await invoke("take_boot_payload"); + } catch (err) { + console.error("[workspace-move] take_boot_payload failed", err); + } + if (!payload?.workspace) return null; + const { id, name, session } = payload.workspace; + // Nothing on disk yet: this window's first aggregator flush writes its + // snapshot, and from there it is an ordinary restorable window. + installWindowPersistence(platform, { version: 1, workspaces: [{ id, name, session }], activeWorkspaceId: id }); + const plan = await planArrival(platform, payload); + publishWorkspaceSession(id, session); + return { [id]: plan }; +} From 6b53e5bf99b11b41c4768e8fd31d0211bc406ad9 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 10 Sep 2026 00:08:31 -0700 Subject: [PATCH 06/36] Drag a Workspace out of its window, and into another one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The strip's tear-out seams are wired up: past the strip edge the host throttles a cursor probe and lights a caret in whichever window is under the pointer, and the release either transfers there or tears out into a new window positioned so the tab lands under the cursor. A spike settled the one unverified platform assumption — a pointer captured on a tab keeps delivering `pointermove` and `pointerup` far outside the window in WKWebView, with client coordinates that run past the edges rather than clamping — so the gesture stays the webview's and Rust is only asked where the cursor is. Tauri exposes no z-order, so among stacked windows the most recently focused wins, and the caret is what makes a wrong guess visible before the release. The browser-dev harness gets no window ops: it has no windows. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01QBf4rQbwuxV6n6v7E3twYe --- lib/src/components/WorkspaceStrip.tsx | 6 +- standalone/src-tauri/src/lib.rs | 16 ++- standalone/src/AppBar.tsx | 36 ++++++- standalone/src/main.tsx | 6 +- standalone/src/workspace-drag.test.ts | 142 +++++++++++++++++++++++++ standalone/src/workspace-drag.ts | 121 +++++++++++++++++++++ standalone/src/workspace-drop-caret.ts | 55 ++++++++++ standalone/src/workspace-move.test.ts | 8 +- standalone/src/workspace-move.ts | 8 +- 9 files changed, 383 insertions(+), 15 deletions(-) create mode 100644 standalone/src/workspace-drag.test.ts create mode 100644 standalone/src/workspace-drag.ts create mode 100644 standalone/src/workspace-drop-caret.ts diff --git a/lib/src/components/WorkspaceStrip.tsx b/lib/src/components/WorkspaceStrip.tsx index ad218b441..c2adbfcc7 100644 --- a/lib/src/components/WorkspaceStrip.tsx +++ b/lib/src/components/WorkspaceStrip.tsx @@ -162,7 +162,11 @@ export function WorkspaceStrip({ }; return ( -
+
{workspaces.map((workspace) => { const isActive = workspace.id === activeId; return ( diff --git a/standalone/src-tauri/src/lib.rs b/standalone/src-tauri/src/lib.rs index f226a8818..5812919f1 100644 --- a/standalone/src-tauri/src/lib.rs +++ b/standalone/src-tauri/src/lib.rs @@ -1955,17 +1955,23 @@ fn open_workspace_window( // Ownership moves before the window exists, so every byte from this instant // is suppressed rather than painted in the window losing the Workspace. windows.reassign(&payload_terminal_ids(&payload), &label); + // Positioned so the dragged tab lands under the cursor, at the source + // window's size. Only Rust knows where the cursor is on screen, so the + // webview sends the offset the tab should keep inside the new window. let geometry = { let scale = window.scale_factor().unwrap_or(1.0); let size = window .outer_size() .map(|size| size.to_logical::(scale)) .ok(); - let at = payload.get("at"); - match (at.and_then(|at| at.get("x")), at.and_then(|at| at.get("y")), size) { - (Some(x), Some(y), Some(size)) => x.as_f64().zip(y.as_f64()).map(|(x, y)| WindowGeometry { - x, - y, + let grab = payload.get("grab"); + let offset = grab + .and_then(|grab| grab.get("x")?.as_f64().zip(grab.get("y")?.as_f64())) + .unwrap_or((0.0, 0.0)); + match (app.cursor_position().ok(), size) { + (Some(cursor), Some(size)) => Some(WindowGeometry { + x: cursor.x / scale - offset.0, + y: cursor.y / scale - offset.1, width: size.width, height: size.height, }), diff --git a/standalone/src/AppBar.tsx b/standalone/src/AppBar.tsx index 04b4fa291..9b325df1b 100644 --- a/standalone/src/AppBar.tsx +++ b/standalone/src/AppBar.tsx @@ -1,8 +1,10 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useSyncExternalStore } from 'react'; import { MinusIcon, CornersOutIcon, CornersInIcon, XIcon } from '@phosphor-icons/react'; import { PopupButtonRow, chromeButton } from '../../lib/src/components/design'; import { WorkspaceStrip } from '../../lib/src/components/WorkspaceStrip'; import { IS_MAC } from '../../lib/src/lib/platform'; +import { onDragOutsideWindow, onDropOnOtherWindow } from './workspace-drag'; +import { getDropCaretX, subscribeDropCaret } from './workspace-drop-caret'; type AppWindow = { isFocused(): Promise; @@ -14,10 +16,15 @@ type AppWindow = { close(): Promise; }; +/** The browser-dev harness has no windows at all, so it gets no window ops and + * no cross-window drag (docs/specs/transport.md → "Standalone browser-dev + * harness"). */ +const BROWSER_DEV = !!import.meta.env.VITE_DORMOUSE_BROWSER_DEV_HOST; + let appWindowPromise: Promise | null = null; function getAppWindow(): Promise { - if (import.meta.env.VITE_DORMOUSE_BROWSER_DEV_HOST) { + if (BROWSER_DEV) { return Promise.resolve(null); } appWindowPromise ??= import('@tauri-apps/api/window') @@ -160,8 +167,13 @@ export function AppBar() { button may carry it — that is what leaves a press on a tab free to activate, rename, or reorder. */}
- +
+ {/* Theme and shell selection live in the Settings dialog at the bottom-right of the window (docs/specs/theme.md, @@ -171,3 +183,21 @@ export function AppBar() {
); } + +/** + * Where a Workspace dragged from another window would land. Fixed-positioned + * because the caret's x arrives in viewport coordinates + * (`standalone/src/workspace-drop-caret.ts`). + */ +function DropCaret() { + const x = useSyncExternalStore(subscribeDropCaret, getDropCaretX); + if (x === null) return null; + return ( +