{workspaces.map((workspace) => {
const isActive = workspace.id === activeId;
return (
diff --git a/lib/src/components/WorkspaceWindow.test.tsx b/lib/src/components/WorkspaceWindow.test.tsx
index 9fc9dcf48..044b0d599 100644
--- a/lib/src/components/WorkspaceWindow.test.tsx
+++ b/lib/src/components/WorkspaceWindow.test.tsx
@@ -18,6 +18,7 @@ import { clearAllNotepads, addPlainNote } from '../lib/notepad/notepad-store';
import { __resetArchiveServiceForTests } from '../lib/notepad/archive-service';
import { getActivitySnapshot, setTerminalActivity } from '../lib/terminal-registry';
import { getWallHandle, listWallHandles, resetWallHandles } from './wall/wall-handles';
+import { resetWorkspaceBootPlans, setWorkspaceBootPlan } from './wall/workspace-boot-plans';
import { mountWallHarness, type WallHarness } from './wall/wall-test-utils';
import { getWorkspaceSurfacesSnapshot, resetWorkspaceSurfaces } from '../lib/workspace-surfaces';
import { previousWorkspaceSession, publishWorkspaceSession, resetWindowSessionAggregator, seedWindowSession } from '../lib/window-session-aggregator';
@@ -52,6 +53,7 @@ beforeEach(() => {
resetWorkspaceSurfaces();
resetWorkspaceUi();
resetWindowSessionAggregator();
+ resetWorkspaceBootPlans();
fake = new FakePtyAdapter();
setPlatform(fake);
harness = mountWallHarness();
@@ -115,6 +117,32 @@ describe('WorkspaceWindow', () => {
expect(wallFor(second).hasAttribute('inert')).toBe(false);
});
+ it('mounts a returning Workspace from the record it brought, not the one it first booted with', async () => {
+ // A Workspace can leave this Window and come back — dragged out and dragged
+ // in again (docs/specs/standalone.md → "Transfer"). Its first mount latched
+ // a plan; re-using that one would put a fresh default pane over the Sessions
+ // that just arrived, and the running work would be gone.
+ const first = getWorkspacesSnapshot().workspaces[0].id;
+ await render();
+
+ const moving = await act(async () => createWorkspace({ id: 'ws-travelling' }).id);
+ await flush();
+ const bootPane = leafIdsIn(moving)[0]!;
+
+ // It leaves.
+ await act(async () => { closeWorkspace(moving); });
+ await flush();
+ expect(walls().map((wall) => wall.dataset.workspaceWall)).toEqual([first]);
+
+ // …and comes back, carrying its own record.
+ setWorkspaceBootPlan(moving, { initialPaneIds: ['pane-arrived'] });
+ await act(async () => { createWorkspace({ id: moving }); });
+ await flush();
+
+ expect(leafIdsIn(moving)).toEqual(['pane-arrived']);
+ expect(leafIdsIn(moving)).not.toContain(bootPane);
+ });
+
it('registers one handle per Workspace, publishing membership, even under StrictMode', async () => {
const first = getWorkspacesSnapshot().workspaces[0].id;
await render(
);
diff --git a/lib/src/components/WorkspaceWindow.tsx b/lib/src/components/WorkspaceWindow.tsx
index 451d22352..47f78e96f 100644
--- a/lib/src/components/WorkspaceWindow.tsx
+++ b/lib/src/components/WorkspaceWindow.tsx
@@ -1,7 +1,8 @@
-import { useEffect, useRef, useSyncExternalStore, type ReactNode } from 'react';
+import { useEffect, useSyncExternalStore, type ReactNode } from 'react';
import { clsx } from 'clsx';
import { Wall } from './Wall';
import { listWallHandles } from './wall/wall-handles';
+import { getWorkspaceBootPlan, seedWorkspaceBootPlans } from './wall/workspace-boot-plans';
import { getPlatform } from '../lib/platform';
import { getWorkspacesSnapshot, subscribeToWorkspaces } from '../lib/workspace-store';
import type { SessionFlushRequest } from '../lib/platform/types';
@@ -32,10 +33,12 @@ export function WorkspaceWindow({
const { workspaces, activeId } = useSyncExternalStore(subscribeToWorkspaces, getWorkspacesSnapshot);
// One shape for both callers, fixed at first render: without per-Workspace
// plans the single boot record belongs to the Workspace that was active then.
- // Either way a Workspace with no entry takes Lath's fresh branch and spawns
- // exactly one default-shell pane.
- const plansRef = useRef
(null);
- const plans = (plansRef.current ??= initialPlans ?? { [activeId]: boot });
+ // Every later read — including a Workspace arriving from another Window, which
+ // parks its own plan before it is created — goes to the same store, so a
+ // Workspace that leaves and comes back mounts from the record it brought.
+ // A Workspace with no entry takes Lath's fresh branch and spawns exactly one
+ // default-shell pane.
+ seedWorkspaceBootPlans(initialPlans ?? { [activeId]: boot });
// 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
@@ -60,7 +63,7 @@ export function WorkspaceWindow({
>
{workspaces.map((workspace) => {
const isActive = workspace.id === activeId;
- const plan = plans[workspace.id] ?? {};
+ const plan = getWorkspaceBootPlan(workspace.id);
return (
void> = [];
@@ -75,11 +82,26 @@ describe('dor control routing', () => {
.toEqual({ kind: 'error', message: "unknown workspace target 'workspace:9'" });
expect(resolveDorControlRoute(request({ params: { window: 'window:2' } })))
.toEqual({ kind: 'error', message: "unknown window target 'window:2'" });
- // The only Window this build addresses still resolves, in both spellings.
+ // A Window that never names itself is `window:1`, in both spellings.
expect(resolveDorControlRoute(request({ params: { window: 'window:1' } })).kind).toBe('handle');
expect(resolveDorControlRoute(request({ params: { window: '1' } })).kind).toBe('handle');
});
+ it('answers to the label the host gave it, and to no other Window', () => {
+ // A host with several Windows names each one, so `dor list` reports a ref a
+ // caller can hand straight back (docs/specs/dor-cli.md -> "Handle Model").
+ handleFor(getWorkspacesSnapshot().workspaces[0].id);
+ setWindowLabel('ws-3');
+ expect(currentWindowRef()).toBe('window:ws-3');
+ expect(resolveDorControlRoute(request({ params: { window: 'window:ws-3' } })).kind).toBe('handle');
+ expect(resolveDorControlRoute(request({ params: { window: 'ws-3' } })).kind).toBe('handle');
+ // Another Window's ref is not this Window's to act on — including the one
+ // this Window answered to before it was named.
+ expect(resolveDorControlRoute(request({ params: { window: 'window:main' } })))
+ .toEqual({ kind: 'error', message: "unknown window target 'window:main'" });
+ expect(resolveDorControlRoute(request({ params: { window: 'window:1' } })).kind).toBe('error');
+ });
+
it('does nothing when no Wall is mounted', () => {
expect(resolveDorControlRoute(request())).toEqual({ kind: 'none' });
});
diff --git a/lib/src/components/wall/use-dor-control.ts b/lib/src/components/wall/use-dor-control.ts
index 447f9ac86..a3dea0ad0 100644
--- a/lib/src/components/wall/use-dor-control.ts
+++ b/lib/src/components/wall/use-dor-control.ts
@@ -1,6 +1,6 @@
import { useCallback, type MutableRefObject } from 'react';
import { getPlatform, PLATFORM_STRING } from '../../lib/platform';
-import { WINDOW_REF } from '../../lib/workspace-store';
+import { currentWindowRef } from '../../lib/workspace-store';
import type { DorControlRequestPayload, DorControlResult } from 'dor/protocol';
import { SURFACE_CONTROL_METHODS } from 'dor/protocol';
import type {
@@ -422,7 +422,8 @@ export function useDorControl({
lastAgentBrowserBinaryPathRef: MutableRefObject
;
/** This Wall's own positional Workspace ref, reported by `dor list` so a caller
* learns which Workspace answered (docs/specs/dor-cli.md → "Handle Model").
- * The Window is `WINDOW_REF` until there is more than one. */
+ * The Window's own ref rides beside it, so `dor list` says which Window
+ * answered too (`currentWindowRef`). */
workspaceRef: () => string;
}): {
/** The live surface (visible pane or minimized door) whose params match, or
@@ -641,7 +642,7 @@ export function useDorControl({
result: {
surfaces,
workspaceRef: workspaceRef(),
- windowRef: WINDOW_REF,
+ windowRef: currentWindowRef(),
},
});
return;
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..bebdd24a9 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 { PreparedWorkspaceTransfer } 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;
+ /** Build what another Window needs to take this Workspace, without touching
+ * it. The caller commits only once the host has accepted, which is what keeps
+ * a refused transfer from gutting the Workspace. An explicit verb, never an
+ * unmount effect (`releaseSession` in `lib/src/lib/terminal-lifecycle.ts`). */
+ prepareWorkspaceTransfer(): 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,16 @@ export function stubWallHandle(workspaceId: WorkspaceId, overrides: Partial false,
runningCount: () => 0,
flushPersistence: async () => {},
+ prepareWorkspaceTransfer: async () => ({
+ payload: {
+ workspaceId,
+ workspace: { id: workspaceId, name: '', session: { version: 3, panes: [] } },
+ notepad: { surfaces: [], stagedDeletions: {} },
+ terminalIds: [],
+ allIds: [],
+ },
+ commit: () => {},
+ }),
closeAll: async () => null,
cancelClose: () => {},
handleDorControl: () => {},
diff --git a/lib/src/components/wall/workspace-boot-plans.ts b/lib/src/components/wall/workspace-boot-plans.ts
new file mode 100644
index 000000000..a063b738e
--- /dev/null
+++ b/lib/src/components/wall/workspace-boot-plans.ts
@@ -0,0 +1,69 @@
+import { getWorkspacesSnapshot } from '../../lib/workspace-store';
+import type { WorkspaceId } from '../../lib/session-types';
+import type { WallBootPlans, WallBootProps } from './wall-types';
+
+/**
+ * The one source of the boot record each Workspace's Wall mounts from: the
+ * restored Window's own plans, seeded at first render, plus the plan a Workspace
+ * arriving from another Window brings with it (`docs/specs/standalone.md` →
+ * "Transfer").
+ *
+ * One store rather than a latched copy per `WorkspaceWindow` render, because a
+ * Workspace can **leave and come back** — dragged out and dragged in again — and
+ * must then mount from the record it brought rather than the one it first booted
+ * with, which would put a fresh default pane over the Sessions that just
+ * arrived.
+ */
+
+const plans = new Map();
+let seeded = false;
+/** No plan at all: Lath's fresh branch, and what the strip's `+` gets. */
+const EMPTY_PLAN: WallBootProps = {};
+
+/**
+ * Install boot's own per-Workspace plans. **Only the first call is taken**:
+ * `WorkspaceWindow` renders twice under StrictMode, and both renders — and every
+ * later one — must see the same records.
+ */
+export function seedWorkspaceBootPlans(initial: WallBootPlans): void {
+ if (seeded) return;
+ seeded = true;
+ for (const [workspaceId, plan] of Object.entries(initial)) plans.set(workspaceId, plan);
+}
+
+/**
+ * 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, keyed on the Workspace store's own lifecycle: 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 an empty one — which is the fresh branch, and what a
+ * Workspace created from the strip's `+` gets. Non-destructive: every render of
+ * a Wall must see the same record. */
+export function getWorkspaceBootPlan(workspaceId: WorkspaceId): WallBootProps {
+ return plans.get(workspaceId) ?? EMPTY_PLAN;
+}
+
+/** Drop one Workspace's parked plan: its mount was unwound before the store's
+ * lifecycle would have pruned it (`unwindAdoption` in
+ * `standalone/src/workspace-move.ts`). */
+export function forgetWorkspaceBootPlan(workspaceId: WorkspaceId): void {
+ plans.delete(workspaceId);
+}
+
+/** Forget every parked plan, seed included (tests). */
+export function resetWorkspaceBootPlans(): void {
+ plans.clear();
+ seeded = false;
+}
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..424c45edb
--- /dev/null
+++ b/lib/src/components/wall/workspace-transfer.test.ts
@@ -0,0 +1,128 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { addPlainNote, clearAllNotepads, getNotes, notepadSurfaceIds } from '../../lib/notepad/notepad-store';
+import { prepareWorkspaceTransfer } 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 — that the prepare touches nothing until the
+ * host has accepted, 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 helpers = new Map();
+const forgotten: string[] = [];
+vi.mock('../../lib/helper-terminal', () => ({
+ getHelper: (parentId: string) => helpers.get(parentId),
+ forgetHelper: (parentId: string) => void forgotten.push(parentId),
+}));
+
+const SESSION: PersistedSession = { version: 3, panes: [{ id: 'pane-a', title: 'a', cwd: '/tmp', untouched: false, alert: null }] };
+
+beforeEach(() => {
+ released.length = 0;
+ forgotten.length = 0;
+ helpers.clear();
+ 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('prepareWorkspaceTransfer', () => {
+ it('serializes with a live cwd probe before anything is detached', async () => {
+ const order: string[] = [];
+ const d = deps(order, {});
+ const prepared = await prepareWorkspaceTransfer({
+ ...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(prepared.payload.workspace).toEqual({ id: 'ws-id', name: 'Deploys', session: SESSION });
+ });
+
+ it('touches nothing until the commit, so a refused transfer costs nothing', async () => {
+ addPlainNote('pane-a', 'keep me');
+
+ const prepared = await prepareWorkspaceTransfer(deps());
+
+ // The host may still refuse — the target window can close between the
+ // drag's last probe and the drop — so the Workspace is exactly as it was.
+ expect(released).toEqual([]);
+ expect(getNotes('pane-a')).toHaveLength(1);
+
+ prepared.commit();
+ expect(released).toEqual(['pane-a']);
+ expect(getNotes('pane-a')).toHaveLength(0);
+ });
+
+ it('detaches only the terminal Surfaces, and never kills one', async () => {
+ const prepared = await prepareWorkspaceTransfer(deps());
+ prepared.commit();
+
+ expect(prepared.payload.terminalIds).toEqual(['pane-a']);
+ expect(prepared.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('takes an open helper with its source instead of leaking it', async () => {
+ // A helper is not a member Surface, so nothing else in the payload names it
+ // — and one left behind is a shell owned by a Window that no longer shows
+ // it, plus a stray pane on the next reload.
+ helpers.set('pane-a', { id: 'helper-1' });
+
+ const prepared = await prepareWorkspaceTransfer(deps());
+ prepared.commit();
+
+ // Directly after its source: the target's resume re-parents it, and that
+ // needs the parent in the same slice.
+ expect(prepared.payload.terminalIds).toEqual(['pane-a', 'helper-1']);
+ // Not a member Surface: no notes, no pane, nothing to hydrate.
+ expect(prepared.payload.allIds).toEqual(['pane-a', 'browser-b']);
+ expect(released).toEqual(['pane-a', 'helper-1']);
+ // Forgotten here, so the status poller stops and the source pane does not
+ // re-open a helper it no longer holds.
+ expect(forgotten).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 prepared = await prepareWorkspaceTransfer(deps());
+ prepared.commit();
+
+ // The notes ride the payload…
+ expect(prepared.payload.notepad.surfaces.map((surface) => surface.surfaceId)).toEqual(['pane-a', 'browser-b']);
+ expect(prepared.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..499ec9de9
--- /dev/null
+++ b/lib/src/components/wall/workspace-transfer.ts
@@ -0,0 +1,118 @@
+import { snapshotNotepadForTransfer, removeSurface } from '../../lib/notepad/notepad-store';
+import { forgetHelper, getHelper } from '../../lib/helper-terminal';
+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, **plus each one's helper Session**: exactly
+ * what changes ownership. A helper is not a member Surface — it has no pane
+ * and no notes — but it is a live shell owned by this Window, and one left
+ * behind is a leaked process plus a stray pane on the source's next reload.
+ * The target re-parents it: `routeUnownedPtys` and `resumeLivePtys` both
+ * place a helper by its `parentId`, which travels with it. */
+ 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;
+}
+
+/**
+ * A Workspace built for the move but still attached to this Window.
+ *
+ * Two-phase because the host may refuse: the target window can close between
+ * the drag's last probe and the drop, and a release that ran first would leave
+ * a gutted Workspace here and a live one nowhere
+ * (`standalone/src/workspace-move.ts`).
+ */
+export interface PreparedWorkspaceTransfer {
+ payload: WorkspaceTransferPayload;
+ /**
+ * The host took it. Forget the notes and detach every Session — **the point
+ * of no return**, and never reachable from a Wall unmount.
+ */
+ commit(): void;
+}
+
+/**
+ * Build everything the target needs, **touching nothing**.
+ *
+ * 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 `commit` empties
+ * it.
+ * 2. **Take the notes**, without forgetting them: a refused transfer must leave
+ * this Window exactly as it was, so there is nothing to restore on the
+ * failure path.
+ * 3. **`commit` releases every Session.** Detached, never killed: the process
+ * keeps running and the target resumes over it.
+ */
+export async function prepareWorkspaceTransfer(
+ deps: ReleaseForTransferDeps,
+): Promise {
+ // The cwds are probed here and nowhere else: after the commit 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 panes = allIds.filter(deps.hasTerminal);
+ // A helper rides with its source, in that order: the target's resume needs the
+ // parent in the same slice to re-parent it.
+ const helpers = new Map();
+ for (const id of panes) {
+ const helper = getHelper(id);
+ if (helper) helpers.set(id, helper.id);
+ }
+ const terminalIds = panes.flatMap((id) => {
+ const helper = helpers.get(id);
+ return helper ? [id, helper] : [id];
+ });
+
+ const notepad = snapshotNotepadForTransfer(allIds);
+
+ return {
+ payload: {
+ workspaceId: deps.workspaceId,
+ workspace: { id: deps.workspaceId, name: deps.name, session },
+ notepad,
+ terminalIds,
+ allIds,
+ },
+ commit() {
+ // Leaving them behind would show the departed Workspace's notes here.
+ for (const id of allIds) removeSurface(id);
+ // Forgotten before its Session goes, so the status poller stops and the
+ // source pane does not re-open the helper it no longer holds.
+ for (const parentId of helpers.keys()) forgetHelper(parentId);
+ // 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);
+ },
+ };
+}
diff --git a/lib/src/components/workspace-strip-drag.test.ts b/lib/src/components/workspace-strip-drag.test.ts
new file mode 100644
index 000000000..37d73dc07
--- /dev/null
+++ b/lib/src/components/workspace-strip-drag.test.ts
@@ -0,0 +1,74 @@
+// @vitest-environment jsdom
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { createWorkspaceStripDrag } from './workspace-strip-drag';
+
+/**
+ * The strip's pointer controller, at the one boundary the host cares about:
+ * when the pointer leaves the strip, and when it comes back
+ * (`docs/specs/standalone.md` → "Dragging a Workspace between windows").
+ */
+
+const STRIP = { left: 0, right: 400, top: 0, bottom: 24 } as DOMRect;
+
+function pointer(type: string, x: number, y: number): PointerEvent {
+ const event = new MouseEvent(type, { clientX: x, clientY: y, bubbles: true }) as unknown as PointerEvent;
+ Object.defineProperty(event, 'pointerId', { value: 1 });
+ Object.defineProperty(event, 'button', { value: 0 });
+ return event;
+}
+
+let drag: ReturnType;
+const outside = vi.fn();
+const backInside = vi.fn();
+
+beforeEach(() => {
+ outside.mockClear();
+ backInside.mockClear();
+ const tab = document.createElement('div');
+ tab.getBoundingClientRect = () => ({ left: 0, right: 100, width: 100 }) as DOMRect;
+ document.body.replaceChildren(tab);
+ drag = createWorkspaceStripDrag({
+ order: () => ['w1'],
+ tabElement: () => tab,
+ stripRect: () => STRIP,
+ move: () => {},
+ setDragging: () => {},
+ onDragOutsideWindow: outside,
+ onDragBackInsideStrip: backInside,
+ });
+ drag.press('w1', pointer('pointerdown', 10, 10));
+});
+
+afterEach(() => drag.dispose());
+
+describe('crossing the strip edge', () => {
+ it('reports the pointer leaving, and reports it coming back exactly once', () => {
+ // Past the drag threshold, still over the strip.
+ window.dispatchEvent(pointer('pointermove', 60, 10));
+ expect(outside).not.toHaveBeenCalled();
+ expect(backInside).not.toHaveBeenCalled();
+
+ window.dispatchEvent(pointer('pointermove', 900, 300));
+ expect(outside).toHaveBeenCalledWith({ clientX: 900, clientY: 300 });
+
+ // Back over its own strip: the live reorder takes the gesture back, and a
+ // caret the host lit in another window is stale from here.
+ window.dispatchEvent(pointer('pointermove', 120, 10));
+ expect(backInside).toHaveBeenCalledTimes(1);
+ // Staying inside is not a fresh crossing.
+ window.dispatchEvent(pointer('pointermove', 140, 10));
+ expect(backInside).toHaveBeenCalledTimes(1);
+
+ // Out and back again is.
+ window.dispatchEvent(pointer('pointermove', 900, 300));
+ window.dispatchEvent(pointer('pointermove', 160, 10));
+ expect(backInside).toHaveBeenCalledTimes(2);
+ });
+
+ it('reports leaving even on a move that also reorders', () => {
+ // The reorder scan returns as soon as it moves a tab, and the host still
+ // has to hear that the pointer is outside.
+ window.dispatchEvent(pointer('pointermove', 900, 300));
+ expect(outside).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/lib/src/components/workspace-strip-drag.ts b/lib/src/components/workspace-strip-drag.ts
index f6458fff2..5ee492953 100644
--- a/lib/src/components/workspace-strip-drag.ts
+++ b/lib/src/components/workspace-strip-drag.ts
@@ -9,6 +9,12 @@ import type { WorkspaceId } from '../lib/session-types';
* It reorders live — the model moves as tab centers are crossed, and the strip
* re-renders from the store — rather than drawing a floating copy.
*/
+/** A pointer position in viewport coordinates. */
+export interface StripDragPoint {
+ clientX: number;
+ clientY: number;
+}
+
export interface StripDragHost {
/** Workspace ids in strip order, read fresh each frame. */
order(): WorkspaceId[];
@@ -20,10 +26,20 @@ export interface StripDragHost {
move(id: WorkspaceId, toIndex: number): void;
/** Which Workspace is being dragged, for the dimmed tab. Null ends the drag. */
setDragging(id: WorkspaceId | null): void;
- /** PR C: the pointer left the window's strip entirely. */
- onDragOutsideWindow?(id: WorkspaceId, point: { clientX: number; clientY: number }): void;
- /** PR C: released over another Window; true means that Window took it. */
- onDropOnOtherWindow?(id: WorkspaceId, point: { clientX: number; clientY: number }): boolean;
+ /** The pointer left the window's strip entirely. */
+ onDragOutsideWindow?(point: StripDragPoint): void;
+ /** …and came back over it. The live reorder takes the gesture back, so a drop
+ * caret the host lit in another window is stale from here. */
+ onDragBackInsideStrip?(): void;
+ /**
+ * Released. `insideStrip` is this controller's own answer — it owns the strip
+ * box — so the host never re-derives it from the DOM; true means the live
+ * reorder already committed the move. Called on every release, including that
+ * one, because only the host can drop a caret it lit in another window.
+ */
+ onDropOnOtherWindow?(id: WorkspaceId, point: StripDragPoint, insideStrip: boolean): void;
+ /** Abandoned — `pointercancel`, or Escape. Nothing moved. */
+ onDragCancelled?(): void;
}
export interface WorkspaceStripDrag {
@@ -44,6 +60,9 @@ export function createWorkspaceStripDrag(host: StripDragHost): WorkspaceStripDra
let startX = 0;
let startY = 0;
let active = false;
+ /** Whether the last move was outside the strip, so the return crossing is
+ * reported exactly once. */
+ let outsideStrip = false;
/** Set by the release of a completed drag and consumed by the one click that
* follows it. */
let clickIsDragTail = false;
@@ -85,6 +104,16 @@ export function createWorkspaceStripDrag(host: StripDragHost): WorkspaceStripDra
// reports that container as the target instead.
try { pressedOn?.setPointerCapture?.(pointerId); capturedBy = pressedOn; } catch { capturedBy = null; }
}
+ // Ahead of the reorder scan, which returns as soon as it moves a tab: the
+ // host has to hear about the crossing whether or not one happened.
+ const inside = insideStrip(event);
+ if (inside === false) {
+ outsideStrip = true;
+ host.onDragOutsideWindow?.({ clientX: event.clientX, clientY: event.clientY });
+ } else if (inside === true && outsideStrip) {
+ outsideStrip = false;
+ host.onDragBackInsideStrip?.();
+ }
const order = host.order();
const from = order.indexOf(dragId);
if (from === -1) return;
@@ -100,31 +129,47 @@ export function createWorkspaceStripDrag(host: StripDragHost): WorkspaceStripDra
return;
}
}
+ }
+
+ /** Whether the pointer is over the strip. Null when there is no strip box to
+ * compare against, which is neither in nor out. */
+ function insideStrip(event: PointerEvent): boolean | null {
const strip = host.stripRect();
- if (host.onDragOutsideWindow && strip
- && (event.clientX < strip.left || event.clientX > strip.right
- || event.clientY < strip.top || event.clientY > strip.bottom)) {
- host.onDragOutsideWindow(dragId, { clientX: event.clientX, clientY: event.clientY });
- }
+ if (!strip) return null;
+ return event.clientX >= strip.left && event.clientX <= strip.right
+ && event.clientY >= strip.top && event.clientY <= strip.bottom;
}
function onPointerUp(event: PointerEvent): void {
if (dragId === null || event.pointerId !== pointerId) return;
// The order is already committed live, so a release inside this strip has
- // nothing left to do; PR C's hook is what a release over another Window uses.
- if (active) host.onDropOnOtherWindow?.(dragId, { clientX: event.clientX, clientY: event.clientY });
+ // nothing left to move — but the host is told either way, because a caret it
+ // lit in another window is its to drop.
+ if (active) {
+ host.onDropOnOtherWindow?.(
+ dragId,
+ { clientX: event.clientX, clientY: event.clientY },
+ insideStrip(event) !== false,
+ );
+ }
end(false);
}
function onPointerCancel(event: PointerEvent): void {
if (event.pointerId !== pointerId) return;
- end(active);
+ abandon();
}
function onKeyDown(event: KeyboardEvent): void {
if (event.key !== 'Escape' || dragId === null) return;
event.preventDefault();
event.stopPropagation();
+ abandon();
+ }
+
+ /** Put the order back and tell the host, so no drop caret is stranded. */
+ function abandon(): void {
+ if (active) host.onDragCancelled?.();
end(active);
}
@@ -137,6 +182,7 @@ export function createWorkspaceStripDrag(host: StripDragHost): WorkspaceStripDra
startX = event.clientX;
startY = event.clientY;
active = false;
+ outsideStrip = false;
clickIsDragTail = false;
pressedOn = host.tabElement(id);
window.addEventListener('pointermove', onPointerMove);
diff --git a/lib/src/host/alert-store-host.test.ts b/lib/src/host/alert-store-host.test.ts
new file mode 100644
index 000000000..b3cad9842
--- /dev/null
+++ b/lib/src/host/alert-store-host.test.ts
@@ -0,0 +1,95 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { createAlertStoreHost, type AlertStoreHost } from './alert-store-host';
+import { DEFAULT_ALERT_SETTINGS } from '../lib/alert-settings-model';
+
+/**
+ * One WATCHING rule set and one alarm-settings blob for every window
+ * (`docs/specs/alert.md` → "Alarm settings"). What matters is that a second
+ * window is *corrected* rather than allowed to replace shared state, and that
+ * nothing a renderer sends is installed without revalidation.
+ */
+
+let sent: Array<{ event: string; data: unknown }>;
+let host: AlertStoreHost;
+
+const watched = (): string[][] =>
+ sent.filter((message) => message.event === 'alert:watchedCommands')
+ .map((message) => (message.data as { names: string[] }).names);
+const settings = () =>
+ sent.filter((message) => message.event === 'alert:settings')
+ .map((message) => (message.data as { settings: Record }).settings);
+
+beforeEach(() => {
+ sent = [];
+ host = createAlertStoreHost({ send: (event, data) => sent.push({ event, data }) });
+});
+
+describe('the WATCHING rule set', () => {
+ it('takes the first window\'s seed and corrects every later one', () => {
+ host.handle({ op: 'initializeWatchedCommands', names: ['npm test'] });
+ expect(watched().at(-1)).toEqual(['npm test']);
+
+ // A second window offering its own persisted copy is answered with the
+ // canonical set, not allowed to replace it.
+ host.handle({ op: 'initializeWatchedCommands', names: ['cargo build'] });
+ expect(watched().at(-1)).toEqual(['npm test']);
+ });
+
+ it('applies an edit as a delta, so a stale window cannot drop other rules', () => {
+ host.handle({ op: 'initializeWatchedCommands', names: ['npm test'] });
+ host.handle({ op: 'setCommandWatched', name: 'cargo build', watched: true });
+ expect(watched().at(-1)).toEqual(['npm test', 'cargo build']);
+
+ host.handle({ op: 'setCommandWatched', name: 'npm test', watched: false });
+ expect(watched().at(-1)).toEqual(['cargo build']);
+ });
+
+ it('ignores a malformed edit rather than installing it', () => {
+ host.handle({ op: 'initializeWatchedCommands', names: ['npm test', 42, null] });
+ expect(watched().at(-1)).toEqual(['npm test']);
+ const before = sent.length;
+ host.handle({ op: 'setCommandWatched', name: 7, watched: 'yes' });
+ host.handle({ op: 'nonsense' });
+ host.handle(null);
+ expect(sent).toHaveLength(before);
+ });
+});
+
+describe('the alarm settings', () => {
+ it('takes the first seed and corrects every later one', () => {
+ host.handle({ op: 'initializeSettings', settings: { ...DEFAULT_ALERT_SETTINGS, speakEnabled: true } });
+ expect(settings().at(-1)).toMatchObject({ speakEnabled: true });
+
+ host.handle({ op: 'initializeSettings', settings: { ...DEFAULT_ALERT_SETTINGS, speakEnabled: false } });
+ expect(settings().at(-1)).toMatchObject({ speakEnabled: true });
+ });
+
+ it('takes an explicit edit from any window', () => {
+ host.handle({ op: 'initializeSettings', settings: DEFAULT_ALERT_SETTINGS });
+ host.handle({ op: 'updateSettings', settings: { ...DEFAULT_ALERT_SETTINGS, pushEnabled: true } });
+ expect(settings().at(-1)).toMatchObject({ pushEnabled: true });
+ });
+
+ it('revalidates whatever a renderer sends', () => {
+ // A NaN or an absurd timer must never reach a host because a webview asked.
+ host.handle({ op: 'initializeSettings', settings: { speakDelayMs: Number.NaN, pushDelayMs: 1e12 } });
+ const installed = settings().at(-1)!;
+ expect(installed.speakDelayMs).toBe(DEFAULT_ALERT_SETTINGS.speakDelayMs);
+ expect(installed.pushDelayMs).toBeLessThanOrEqual(600_000);
+ });
+});
+
+it('stops broadcasting once disposed', () => {
+ host.dispose();
+ host.handle({ op: 'initializeWatchedCommands', names: ['npm test'] });
+ expect(sent).toEqual([]);
+});
+
+it('never rings anything: the sidecar has no AlertManager', () => {
+ // The stores are memory plus a broadcast; every window applies the snapshot
+ // to its own manager.
+ const send = vi.fn();
+ const bare = createAlertStoreHost({ send });
+ bare.handle({ op: 'initializeSettings', settings: DEFAULT_ALERT_SETTINGS });
+ expect(send).toHaveBeenCalledWith('alert:settings', { settings: DEFAULT_ALERT_SETTINGS });
+});
diff --git a/lib/src/host/alert-store-host.ts b/lib/src/host/alert-store-host.ts
new file mode 100644
index 000000000..6b1c1c9a1
--- /dev/null
+++ b/lib/src/host/alert-store-host.ts
@@ -0,0 +1,106 @@
+import { AlertSettingsHost, type AlertSettingsTarget } from '../lib/alert-settings-host';
+import { WatchedCommandHost, type WatchedCommandTarget } from '../lib/watched-command-host';
+import type { AlertSettings } from '../lib/alert-settings';
+
+/**
+ * The two app-global alert stores, hosted where every window can see them
+ * (`docs/specs/alert.md` → "Alarm settings"; `docs/specs/transport.md` → the
+ * two-store rule). Standalone became a multi-webview host, so each window can
+ * no longer keep its own `localStorage` mirror and call it canonical: the
+ * WATCHING rule set and the alarm settings are one per machine.
+ *
+ * Nothing here rings anything. The sidecar has no `AlertManager` — that lives
+ * in each webview — so the targets below are plain memory, and every window
+ * applies the broadcast to its own manager.
+ *
+ * Same classes the VS Code extension host runs
+ * (`vscode-ext/src/message-router.ts`), so the two hosts cannot drift.
+ */
+
+/** What a renderer asks of the two stores. Mirrors the VS Code message names. */
+export type AlertStoreCommand =
+ | { op: 'initializeWatchedCommands'; names?: unknown }
+ | { op: 'setCommandWatched'; name?: unknown; watched?: unknown }
+ | { op: 'initializeSettings'; settings?: unknown }
+ | { op: 'updateSettings'; settings?: unknown };
+
+export interface AlertStoreHost {
+ /** One `alert:command` line from a webview. */
+ handle(command: unknown): void;
+ dispose(): void;
+}
+
+/** The WATCHING rule set, with no manager behind it. */
+class WatchedCommandMemory implements WatchedCommandTarget {
+ private names: string[] = [];
+
+ getWatchedCommands(): string[] {
+ return [...this.names];
+ }
+
+ setWatchedCommands(names: string[]): void {
+ this.names = [...new Set(names.filter((name) => typeof name === 'string' && name.length > 0))];
+ }
+
+ /** A delta, never a replacement: a stale window must not drop the rules it
+ * has not heard about yet. */
+ setCommandWatched(name: string, watched: boolean): void {
+ const next = new Set(this.names);
+ if (watched) next.add(name);
+ else next.delete(name);
+ this.names = [...next];
+ }
+}
+
+/** The alarm settings blob, with no manager behind it. `AlertSettingsHost`
+ * revalidates through `normalizeAlertSettings` before this ever sees it. */
+class AlertSettingsMemory implements AlertSettingsTarget {
+ settings: AlertSettings | null = null;
+
+ applySettings(settings: AlertSettings): void {
+ this.settings = settings;
+ }
+}
+
+export function createAlertStoreHost(options: {
+ /** Writes one event to every window. */
+ send: (event: string, data: unknown) => void;
+}): AlertStoreHost {
+ const watched = new WatchedCommandHost(new WatchedCommandMemory());
+ const settings = new AlertSettingsHost(new AlertSettingsMemory());
+
+ const stopWatched = watched.subscribe((names) => options.send('alert:watchedCommands', { names }));
+ const stopSettings = settings.subscribe((value) => options.send('alert:settings', { settings: value }));
+
+ return {
+ handle(command) {
+ const message = command as AlertStoreCommand | null;
+ if (!message || typeof message.op !== 'string') return;
+ switch (message.op) {
+ case 'initializeWatchedCommands':
+ // Only the first window's offer is taken; every later one is answered
+ // with what the host already holds.
+ watched.initialize(Array.isArray(message.names) ? message.names.filter(
+ (name): name is string => typeof name === 'string',
+ ) : []);
+ return;
+ case 'setCommandWatched':
+ if (typeof message.name !== 'string' || typeof message.watched !== 'boolean') return;
+ watched.setCommandWatched(message.name, message.watched);
+ return;
+ case 'initializeSettings':
+ settings.initialize(message.settings);
+ return;
+ case 'updateSettings':
+ settings.update(message.settings);
+ return;
+ default:
+ return;
+ }
+ },
+ dispose() {
+ stopWatched();
+ stopSettings();
+ },
+ };
+}
diff --git a/lib/src/host/remote/service-protocol.ts b/lib/src/host/remote/service-protocol.ts
index 9df4e6057..1a301ff2b 100644
--- a/lib/src/host/remote/service-protocol.ts
+++ b/lib/src/host/remote/service-protocol.ts
@@ -45,6 +45,14 @@ export interface BurrowCommand {
burrowRequestId: string;
cmd: string;
params?: unknown;
+ /**
+ * Which webview sent this, stamped by a host that has more than one
+ * (`burrow_command` in `standalone/src-tauri/src/lib.rs`). The webview never
+ * sets it — it does not know its own label to the Burrow — and a host with one
+ * unnamed webview omits it. Read only by the N-answer collector, which settles
+ * an ask on having heard from every window rather than on a count.
+ */
+ window?: string;
}
/** Validate the untrusted edge of either Burrow bridge before routing a command. */
diff --git a/lib/src/host/remote/sidecar-entry.test.ts b/lib/src/host/remote/sidecar-entry.test.ts
index 4f0cb4237..34c533395 100644
--- a/lib/src/host/remote/sidecar-entry.test.ts
+++ b/lib/src/host/remote/sidecar-entry.test.ts
@@ -28,8 +28,9 @@ function emitted(event: string): T[] {
return sent.filter((message) => message.event === event).map((message) => message.data as T);
}
-function answer(ask: BurrowAsk, results: unknown[]): void {
- bridge.onAnswer({ burrowRequestId: ask.burrowRequestId, results });
+/** One window's reply. `from` is the label the host stamps on it. */
+function answer(ask: BurrowAsk, results: unknown[], from?: string): void {
+ bridge.onAnswer({ burrowRequestId: ask.burrowRequestId, results }, from);
}
function sink(): PtySink & { chunks: ProcessedPtyChunk[]; data: string[]; exits: number[] } {
@@ -83,15 +84,131 @@ 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.setWindows(['main', 'ws-2']);
+ const pending = bridge.provider.collectDirectory();
+ const ask = asks()[0]!;
+ answer(ask, [{ surfaceId: 'in-main' }], 'main');
+ answer(ask, [{ surfaceId: 'in-ws-2' }], 'ws-2');
+ expect(await pending).toEqual([{ surfaceId: 'in-main' }, { surfaceId: 'in-ws-2' }]);
+ });
+
+ it('a second answer from one window cannot settle the ask', async () => {
+ vi.useFakeTimers();
+ bridge.setWindows(['main', 'ws-2']);
+ const pending = bridge.provider.collectDirectory();
+ const ask = asks()[0]!;
+ answer(ask, [{ surfaceId: 'in-main' }], 'main');
+ // A reload racing its own reply. Counting answers would settle here, on a
+ // directory that has never heard from ws-2 — and duplicate main's panes.
+ answer(ask, [{ surfaceId: 'in-main-again' }], 'main');
+ let settled = false;
+ void pending.then(() => { settled = true; });
+ await vi.advanceTimersByTimeAsync(0);
+ expect(settled).toBe(false);
+
+ answer(ask, [{ surfaceId: 'in-ws-2' }], '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.setWindows(['main', 'ws-2', 'ws-3']);
+ const pending = bridge.provider.collectDirectory();
+ const ask = asks()[0]!;
+ answer(ask, [{ surfaceId: 'in-main' }], '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.setWindows(['main', 'ws-2']);
+ const pending = bridge.provider.collectDirectory();
+ const ask = asks()[0]!;
+ answer(ask, [{ surfaceId: 'in-main' }], 'main');
+ // The second window went away without answering.
+ bridge.setWindows(['main']);
+ 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 () => {
+ bridge.setWindows(['main']);
+ const pending = bridge.provider.collectDirectory();
+ const ask = asks()[0]!;
+ bridge.setWindows(['main', 'ws-2']);
+ // ws-2's own answer is not part of a snapshot it was never asked for.
+ answer(ask, [{ surfaceId: 'in-ws-2' }], 'ws-2');
+ answer(ask, [{ surfaceId: 'in-main' }], 'main');
+ expect(await pending).toEqual([{ surfaceId: 'in-main' }]);
+ });
+
+ it('settles a Surface op on its owner alone, without waiting out the others', async () => {
+ // The host routes an ask naming a Surface to the window that owns its PTY —
+ // `attach` and `resize` MUTATE that pane — and tells the collector where it
+ // went. Waiting on the rest would spend the whole budget on every attach.
+ vi.useFakeTimers();
+ bridge.setWindows(['main', 'ws-2', 'ws-3']);
+ const pending = bridge.provider.resolveSurface('s1', { cols: 80, rows: 24 });
+ const ask = asks()[0]!;
+ bridge.setAskDelivery({ burrowRequestId: ask.burrowRequestId, windows: ['ws-2'] });
+ answer(ask, [{ ptyId: 'p1', cols: 80, rows: 24 }], 'ws-2');
+ await vi.advanceTimersByTimeAsync(0);
+ expect(await pending).toMatchObject({ ptyId: 'p1' });
+ });
+
+ it('takes a delivery line that arrives after the answer', async () => {
+ bridge.setWindows(['main', 'ws-2']);
+ const pending = bridge.provider.collectDirectory();
+ const ask = asks()[0]!;
+ answer(ask, [{ surfaceId: 'in-ws-2' }], 'ws-2');
+ bridge.setAskDelivery({ burrowRequestId: ask.burrowRequestId, windows: ['ws-2'] });
+ expect(await pending).toEqual([{ surfaceId: 'in-ws-2' }]);
+ });
+
+ it('never widens an ask, whatever the delivery names', async () => {
+ vi.useFakeTimers();
+ bridge.setWindows(['main']);
+ const pending = bridge.provider.collectDirectory();
+ const ask = asks()[0]!;
+ // A window that never received this ask cannot be put back into it.
+ bridge.setAskDelivery({ burrowRequestId: ask.burrowRequestId, windows: ['main', 'ws-9'] });
+ answer(ask, [{ surfaceId: 'in-main' }], 'main');
+ await vi.advanceTimersByTimeAsync(0);
+ expect(await pending).toEqual([{ surfaceId: 'in-main' }]);
+ });
+
+ it('ignores a delivery line that is not a usable one', async () => {
+ bridge.setWindows(['main', 'ws-2']);
+ const pending = bridge.provider.collectDirectory();
+ const ask = asks()[0]!;
+ for (const bad of [undefined, null, 2, { burrowRequestId: 7, windows: ['main'] },
+ { burrowRequestId: ask.burrowRequestId }, { burrowRequestId: 'ask-nope', windows: ['main'] }]) {
+ bridge.setAskDelivery(bad);
+ }
+ answer(ask, [{ surfaceId: 'in-main' }], 'main');
+ answer(ask, [{ surfaceId: 'in-ws-2' }], 'ws-2');
+ expect(await pending).toEqual([{ surfaceId: 'in-main' }, { surfaceId: 'in-ws-2' }]);
+ });
+
+ it('ignores a window list that is not a usable one', async () => {
+ for (const bad of [[], 2, 'main', undefined, null]) bridge.setWindows(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..bfb409e57 100644
--- a/lib/src/host/remote/sidecar-entry.ts
+++ b/lib/src/host/remote/sidecar-entry.ts
@@ -54,8 +54,17 @@ export interface SidecarSurfaceBridgeOptions {
export interface SidecarSurfaceBridge {
provider: BurrowSurfaceProvider;
- /** An `answer` command: settles the ask it names. */
- onAnswer(params: AnswerParams | undefined): void;
+ /** An `answer` command: contributes to the ask it names, on behalf of the
+ * window `from`. The host stamps that label onto every command it forwards;
+ * a host with one unnamed webview omits it. */
+ onAnswer(params: AnswerParams | undefined, from?: string): void;
+ /** Which webviews will answer an ask, by host label. Pushed by the host on
+ * every window create and destroy (`docs/specs/standalone.md` -> "Burrow
+ * service"). */
+ setWindows(labels: unknown): void;
+ /** Which windows one ask actually reached. The host routes an ask naming a
+ * Surface to its owner alone, and only the host knows the owner. */
+ setAskDelivery(detail: unknown): void;
/** A `notify` command: something the directory depends on changed. */
onNotify(): void;
/**
@@ -83,29 +92,47 @@ export function createSidecarSurfaceBridge(
options: SidecarSurfaceBridgeOptions,
): SidecarSurfaceBridge {
interface PendingAsk {
- settle(results: unknown[]): void;
+ /** Every answering window's results, concatenated. */
+ results: unknown[];
+ /** The windows this ask went to that have not answered yet. A window
+ * answering nothing still empties its entry: what settles the ask is having
+ * heard from everyone, not having found anything. Only ever SHRINKS — a
+ * window that closed mid-fan-out will never answer, and one that opened
+ * never received the ask. */
+ awaiting: Set;
+ settle(): void;
}
const asks = new Map();
let askSeq = 0;
+ /**
+ * Which webviews will answer an ask, by host label. The empty label is the
+ * sole unnamed window — a host that never pushes labels (the browser-dev
+ * harness, the tests) has exactly one webview, and its answers carry none.
+ */
+ const SOLE_WINDOW = '';
+ let windows = new Set([SOLE_WINDOW]);
function ask(op: string, params: unknown): Promise {
const burrowRequestId = `ask-${++askSeq}`;
return new Promise((resolve) => {
+ const pending: PendingAsk = {
+ results: [],
+ awaiting: new Set(windows),
+ 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,12 +255,15 @@ 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.
+ *
+ * Keyed by *who* answered, not by how many have: two answers from one window
+ * — a reload racing its own reply — must never settle an ask the other
+ * windows have not spoken to.
*/
- onAnswer(params) {
+ onAnswer(params, from = SOLE_WINDOW) {
if (!params || typeof params.burrowRequestId !== 'string') return;
const pending = asks.get(params.burrowRequestId);
if (!pending) {
@@ -246,7 +276,51 @@ export function createSidecarSurfaceBridge(
notifyDirectoryChanged();
return;
}
- pending.settle(Array.isArray(params.results) ? params.results : []);
+ // Not awaited: either this window already answered, or it opened after the
+ // ask went out and never received it. Its results are not this snapshot's.
+ if (!pending.awaiting.delete(from)) return;
+ if (Array.isArray(params.results)) pending.results.push(...params.results);
+ if (pending.awaiting.size === 0) pending.settle();
+ },
+
+ setWindows(labels) {
+ if (!Array.isArray(labels)) return;
+ const live = labels.filter((label): label is string => typeof label === 'string');
+ if (live.length === 0) return;
+ windows = new Set(live);
+ // 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()]) {
+ for (const label of pending.awaiting) {
+ if (!windows.has(label)) pending.awaiting.delete(label);
+ }
+ if (pending.awaiting.size === 0) pending.settle();
+ }
+ },
+
+ /**
+ * Narrow one outstanding ask to the windows it was actually delivered to.
+ *
+ * An ask goes out to every window, because the directory is the union of
+ * what they all hold; but an ask naming a Surface is a question exactly one
+ * window can answer, and the host routes it there. Waiting on the rest would
+ * spend the whole budget on every attach and resize. **Narrows only** —
+ * intersected with what is still awaited, so a window that already answered
+ * cannot be put back and a late line cannot re-open a settled ask.
+ */
+ setAskDelivery(detail) {
+ const params = detail as { burrowRequestId?: unknown; windows?: unknown } | null;
+ if (!params || typeof params.burrowRequestId !== 'string') return;
+ if (!Array.isArray(params.windows)) return;
+ const pending = asks.get(params.burrowRequestId);
+ if (!pending) return;
+ const delivered = new Set(
+ params.windows.filter((label): label is string => typeof label === 'string'),
+ );
+ for (const label of pending.awaiting) {
+ if (!delivered.has(label)) pending.awaiting.delete(label);
+ }
+ if (pending.awaiting.size === 0) pending.settle();
},
onNotify() {
@@ -306,7 +380,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 +402,8 @@ export interface SidecarBurrow {
handleCommand(data: unknown): void;
onPtyEvent(event: string, data: unknown): void;
onPtySpawn(id: unknown): void;
+ setWindows(labels: unknown): void;
+ setAskDelivery(detail: unknown): void;
setThemeColors(colors: unknown): void;
dispose(): void;
}
@@ -359,12 +435,16 @@ export function createSidecarBurrow(options: SidecarBurrowOptions): SidecarBurro
const command = data;
// Both of these feed something already waiting on this side, so they
// answer nothing and never reach the service's dispatch.
- if (command.cmd === 'answer') return bridge.onAnswer(command.params as AnswerParams);
+ if (command.cmd === 'answer') {
+ return bridge.onAnswer(command.params as AnswerParams, command.window);
+ }
if (command.cmd === 'notify') return bridge.onNotify();
void service.handleCommand(command);
},
onPtyEvent: bridge.onPtyEvent,
onPtySpawn: bridge.onPtySpawn,
+ setWindows: bridge.setWindows,
+ setAskDelivery: bridge.setAskDelivery,
setThemeColors: bridge.setThemeColors,
dispose() {
service.dispose();
diff --git a/lib/src/lib/alert-settings-host.ts b/lib/src/lib/alert-settings-host.ts
index 3b7ea1d15..5c3a35443 100644
--- a/lib/src/lib/alert-settings-host.ts
+++ b/lib/src/lib/alert-settings-host.ts
@@ -3,9 +3,9 @@ import {
DEFAULT_ALERT_SETTINGS,
normalizeAlertSettings,
type AlertSettings,
-} from './alert-settings';
+} from './alert-settings-model';
-type AlertSettingsTarget = Pick;
+export type AlertSettingsTarget = Pick;
/**
* Coordinates one host-authoritative alarm-settings blob across multiple
diff --git a/lib/src/lib/alert-settings-model.ts b/lib/src/lib/alert-settings-model.ts
new file mode 100644
index 000000000..310349efb
--- /dev/null
+++ b/lib/src/lib/alert-settings-model.ts
@@ -0,0 +1,81 @@
+import { cfg } from '../cfg';
+
+/**
+ * The app-global alarm settings, their defaults, and their validation
+ * (`docs/specs/alert.md` -> Alarm settings). Like the WATCHING rule set they are
+ * a property of the app, not of a Session.
+ *
+ * Platform-free on purpose: this is what a *host* runs — the VS Code extension
+ * host and the standalone sidecar both revalidate a renderer's blob through
+ * `normalizeAlertSettings` before installing it, and neither has a renderer to
+ * drag in. `alert-settings.ts` is the renderer's own mirror over the top.
+ */
+export interface AlertSettings {
+ /** ms — how long "looking at this pane" lasts before the user counts as away. */
+ inactivityTimeoutMs: number;
+ /** Delay terminal-notification rings behind confirmed animation. */
+ deferAlertsUntilQuiet: boolean;
+ /** Speak an unattended alarm out loud after `speakDelayMs`. */
+ speakEnabled: boolean;
+ /** ms after a ring before speaking, if the ring is still unattended. */
+ speakDelayMs: number;
+ /** Push an unattended alarm to paired phones after `pushDelayMs`. */
+ pushEnabled: boolean;
+ /** ms after a ring before pushing, if the ring is still unattended. */
+ pushDelayMs: number;
+}
+
+/** Shared bounds for every delay field. Seconds in the UI, ms on the wire. */
+export const MIN_DELAY_MS = 1_000;
+export const MAX_DELAY_MS = 600_000;
+
+export const DEFAULT_ALERT_SETTINGS: AlertSettings = {
+ // cfg.ts stays the single source of the shipped default.
+ inactivityTimeoutMs: cfg.alert.userAttention,
+ deferAlertsUntilQuiet: false,
+ speakEnabled: false,
+ speakDelayMs: 10_000,
+ pushEnabled: false,
+ pushDelayMs: 20_000,
+};
+
+/** Force a millisecond delay into the shared bounds. The one clamp rule. */
+export function clampAlertDelayMs(ms: number): number {
+ return Math.min(MAX_DELAY_MS, Math.max(MIN_DELAY_MS, Math.round(ms)));
+}
+
+function clampDelay(value: unknown, fallback: number): number {
+ if (typeof value !== 'number' || !Number.isFinite(value)) return fallback;
+ return clampAlertDelayMs(value);
+}
+
+function bool(value: unknown, fallback: boolean): boolean {
+ return typeof value === 'boolean' ? value : fallback;
+}
+
+/**
+ * Coerce an arbitrary value into a complete `AlertSettings`. Unknown keys are
+ * dropped and missing keys defaulted, so the blob evolves additively without a
+ * version field — and a hand-edited `localStorage` value can never produce a
+ * `NaN` timer.
+ */
+export function normalizeAlertSettings(value: unknown): AlertSettings {
+ const raw = (typeof value === 'object' && value !== null ? value : {}) as Partial>;
+ return {
+ inactivityTimeoutMs: clampDelay(raw.inactivityTimeoutMs, DEFAULT_ALERT_SETTINGS.inactivityTimeoutMs),
+ deferAlertsUntilQuiet: bool(raw.deferAlertsUntilQuiet, DEFAULT_ALERT_SETTINGS.deferAlertsUntilQuiet),
+ speakEnabled: bool(raw.speakEnabled, DEFAULT_ALERT_SETTINGS.speakEnabled),
+ speakDelayMs: clampDelay(raw.speakDelayMs, DEFAULT_ALERT_SETTINGS.speakDelayMs),
+ pushEnabled: bool(raw.pushEnabled, DEFAULT_ALERT_SETTINGS.pushEnabled),
+ pushDelayMs: clampDelay(raw.pushDelayMs, DEFAULT_ALERT_SETTINGS.pushDelayMs),
+ };
+}
+
+export function alertSettingsEqual(a: AlertSettings, b: AlertSettings): boolean {
+ return a.inactivityTimeoutMs === b.inactivityTimeoutMs
+ && a.deferAlertsUntilQuiet === b.deferAlertsUntilQuiet
+ && a.speakEnabled === b.speakEnabled
+ && a.speakDelayMs === b.speakDelayMs
+ && a.pushEnabled === b.pushEnabled
+ && a.pushDelayMs === b.pushDelayMs;
+}
diff --git a/lib/src/lib/alert-settings.ts b/lib/src/lib/alert-settings.ts
index 46d4cd374..e67fc89aa 100644
--- a/lib/src/lib/alert-settings.ts
+++ b/lib/src/lib/alert-settings.ts
@@ -1,91 +1,23 @@
-import { cfg } from '../cfg';
import { loadJson, saveJson } from './local-json-store';
import { getPlatform } from './platform';
+import {
+ alertSettingsEqual,
+ normalizeAlertSettings,
+ type AlertSettings,
+} from './alert-settings-model';
/**
- * The app-global alarm settings edited by the Alarm settings dialog
- * (`docs/specs/alert.md` -> Alarm settings). Like the WATCHING rule set, these
- * are a property of the app, not of a Session.
+ * The renderer's copy of the app-global alarm settings: what the dialog edits
+ * and what `localStorage` holds. The shape, its defaults and its validation are
+ * the platform-free `alert-settings-model.ts`, so a host can run them beside
+ * the PTYs (`lib/src/host/alert-store-host.ts`) without dragging a renderer in.
*
- * This renderer-side copy drives the UI and persists to `localStorage`. In
- * VS Code it is a mirror of the extension host's authoritative copy: the first
- * renderer seeds the host, an edit relays the whole normalized blob, and the
- * host broadcasts its canonical snapshot to every webview. The host needs
- * `inactivityTimeoutMs` for its `AlertManager`; it relays the rest untouched so
- * two webviews cannot disagree about whether alarms speak.
+ * Re-exported here so every existing importer keeps one name to reach for.
*/
-export interface AlertSettings {
- /** ms — how long "looking at this pane" lasts before the user counts as away. */
- inactivityTimeoutMs: number;
- /** Delay terminal-notification rings behind confirmed animation. */
- deferAlertsUntilQuiet: boolean;
- /** Speak an unattended alarm out loud after `speakDelayMs`. */
- speakEnabled: boolean;
- /** ms after a ring before speaking, if the ring is still unattended. */
- speakDelayMs: number;
- /** Push an unattended alarm to paired phones after `pushDelayMs`. */
- pushEnabled: boolean;
- /** ms after a ring before pushing, if the ring is still unattended. */
- pushDelayMs: number;
-}
+export * from './alert-settings-model';
const STORAGE_KEY = 'dormouse:alert-settings';
-/** Shared bounds for every delay field. Seconds in the UI, ms on the wire. */
-export const MIN_DELAY_MS = 1_000;
-export const MAX_DELAY_MS = 600_000;
-
-export const DEFAULT_ALERT_SETTINGS: AlertSettings = {
- // cfg.ts stays the single source of the shipped default.
- inactivityTimeoutMs: cfg.alert.userAttention,
- deferAlertsUntilQuiet: false,
- speakEnabled: false,
- speakDelayMs: 10_000,
- pushEnabled: false,
- pushDelayMs: 20_000,
-};
-
-/** Force a millisecond delay into the shared bounds. The one clamp rule. */
-export function clampAlertDelayMs(ms: number): number {
- return Math.min(MAX_DELAY_MS, Math.max(MIN_DELAY_MS, Math.round(ms)));
-}
-
-function clampDelay(value: unknown, fallback: number): number {
- if (typeof value !== 'number' || !Number.isFinite(value)) return fallback;
- return clampAlertDelayMs(value);
-}
-
-function bool(value: unknown, fallback: boolean): boolean {
- return typeof value === 'boolean' ? value : fallback;
-}
-
-/**
- * Coerce an arbitrary value into a complete `AlertSettings`. Unknown keys are
- * dropped and missing keys defaulted, so the blob evolves additively without a
- * version field — and a hand-edited `localStorage` value can never produce a
- * `NaN` timer.
- */
-export function normalizeAlertSettings(value: unknown): AlertSettings {
- const raw = (typeof value === 'object' && value !== null ? value : {}) as Partial>;
- return {
- inactivityTimeoutMs: clampDelay(raw.inactivityTimeoutMs, DEFAULT_ALERT_SETTINGS.inactivityTimeoutMs),
- deferAlertsUntilQuiet: bool(raw.deferAlertsUntilQuiet, DEFAULT_ALERT_SETTINGS.deferAlertsUntilQuiet),
- speakEnabled: bool(raw.speakEnabled, DEFAULT_ALERT_SETTINGS.speakEnabled),
- speakDelayMs: clampDelay(raw.speakDelayMs, DEFAULT_ALERT_SETTINGS.speakDelayMs),
- pushEnabled: bool(raw.pushEnabled, DEFAULT_ALERT_SETTINGS.pushEnabled),
- pushDelayMs: clampDelay(raw.pushDelayMs, DEFAULT_ALERT_SETTINGS.pushDelayMs),
- };
-}
-
-function alertSettingsEqual(a: AlertSettings, b: AlertSettings): boolean {
- return a.inactivityTimeoutMs === b.inactivityTimeoutMs
- && a.deferAlertsUntilQuiet === b.deferAlertsUntilQuiet
- && a.speakEnabled === b.speakEnabled
- && a.speakDelayMs === b.speakDelayMs
- && a.pushEnabled === b.pushEnabled
- && a.pushDelayMs === b.pushDelayMs;
-}
-
let settings: AlertSettings = normalizeAlertSettings(loadJson(STORAGE_KEY, null));
const listeners = new Set<() => void>();
diff --git a/lib/src/lib/mirrored-constants.test.ts b/lib/src/lib/mirrored-constants.test.ts
index f4f6283a3..02fcfd9b9 100644
--- a/lib/src/lib/mirrored-constants.test.ts
+++ b/lib/src/lib/mirrored-constants.test.ts
@@ -269,7 +269,7 @@ describe('quit teardown budget mirrors', () => {
expect(ceiling).toBeLessThan(rustMs('QUIT_PHASE_TIMEOUT_MS'));
});
- it.each(['capture_agent_recovery', 'pty_graceful_kill_all'])(
+ it.each(['capture_agent_recovery', 'pty_graceful_kill'])(
'counts the round-trip margin Rust adds in %s',
(command) => {
const body = extract(rsSrc, rs, new RegExp(`\\n(?:async )?fn ${command}\\(([^]*?)\\n}`));
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/platform/types.ts b/lib/src/lib/platform/types.ts
index f8ab94c55..390b6bc73 100644
--- a/lib/src/lib/platform/types.ts
+++ b/lib/src/lib/platform/types.ts
@@ -19,6 +19,20 @@ export interface PtyInfo {
shell?: string;
}
+/** The host's answer to one `requestInit`, echoing the token it was asked with
+ * where the host has one to echo (`PlatformAdapter.requestInit`). */
+export interface PtyListDetail {
+ ptys: PtyInfo[];
+ requestId?: string;
+}
+
+/** One PTY's buffered output, behind the list that named it. */
+export interface PtyReplayDetail {
+ id: string;
+ data: string;
+ requestId?: string;
+}
+
/**
* A TCP socket in the LISTEN state opened by a terminal's shell process or any
* of its descendant subprocesses. `address` is the bind interface — `0.0.0.0`
@@ -366,11 +380,16 @@ export interface PlatformAdapter {
offPtyExit(handler: (detail: { id: string; exitCode: number }) => void): void;
// Resume (live-PTY replay after webview hide/show)
- requestInit(): void;
- onPtyList(handler: (detail: { ptys: PtyInfo[] }) => void): void;
- offPtyList(handler: (detail: { ptys: PtyInfo[] }) => void): void;
- onPtyReplay(handler: (detail: { id: string; data: string }) => void): void;
- offPtyReplay(handler: (detail: { id: string; data: string }) => void): void;
+ /** Ask for the live PTY list and each one's replay. `requestId` is the asking
+ * collector's token: a host serving several windows echoes it on the answer
+ * so two collections in one webview cannot finish on each other's list
+ * (docs/specs/transport.md -> "Reconnection"). A host with one webview may
+ * ignore it, and its answers then carry none. */
+ requestInit(requestId?: string): void;
+ onPtyList(handler: (detail: PtyListDetail) => void): void;
+ offPtyList(handler: (detail: PtyListDetail) => void): void;
+ onPtyReplay(handler: (detail: PtyReplayDetail) => void): void;
+ offPtyReplay(handler: (detail: PtyReplayDetail) => void): void;
// Host-initiated session persistence
onRequestSessionFlush(handler: (detail: SessionFlushRequest) => void): void;
diff --git a/lib/src/lib/reconnect.test.ts b/lib/src/lib/reconnect.test.ts
index ef70a9fdd..5cf969c51 100644
--- a/lib/src/lib/reconnect.test.ts
+++ b/lib/src/lib/reconnect.test.ts
@@ -683,3 +683,170 @@ describe('resumeOrRestoreFrom', () => {
);
});
});
+
+/**
+ * One webview can have two collections outstanding at once — a boot and a
+ * Workspace arriving from another Window, or two arrivals — and every listener
+ * sees every answer. The token is what keeps each on its own
+ * (`docs/specs/transport.md` → "Reconnection").
+ */
+describe('collectLivePtys addressing', () => {
+ /** A host that answers each `requestInit` with only the PTYs named for that
+ * token, echoing it exactly as the sidecar's `list` does. */
+ function addressedPlatform() {
+ const listHandlers = new Set<(detail: { ptys: PtyInfo[]; requestId?: string }) => void>();
+ const replayHandlers = new Set<(detail: { id: string; data: string; requestId?: string }) => void>();
+ const asked: string[] = [];
+ const platform = {
+ requestInit: (requestId?: string) => {
+ asked.push(requestId ?? '(none)');
+ },
+ onPtyList: (handler: (detail: { ptys: PtyInfo[]; requestId?: string }) => void) => { listHandlers.add(handler); },
+ offPtyList: (handler: (detail: { ptys: PtyInfo[]; requestId?: string }) => void) => { listHandlers.delete(handler); },
+ onPtyReplay: (handler: (detail: { id: string; data: string; requestId?: string }) => void) => { replayHandlers.add(handler); },
+ offPtyReplay: (handler: (detail: { id: string; data: string; requestId?: string }) => void) => { replayHandlers.delete(handler); },
+ } as unknown as PlatformAdapter;
+ const answer = (requestId: string, ids: string[] = []) => {
+ const ptys = ids.map((id) => ({ id, alive: true }) as PtyInfo);
+ for (const handler of [...listHandlers]) handler({ ptys, requestId });
+ for (const id of ids) {
+ for (const handler of [...replayHandlers]) handler({ id, data: `${id}-replay`, requestId });
+ }
+ };
+ return { platform, asked, answer };
+ }
+
+ it('gives two concurrent arrivals their own PTYs', async () => {
+ const { platform, asked, answer } = addressedPlatform();
+ const first = collectLivePtys(platform, { accept: (id) => id === 'a', timeoutMs: 1000 });
+ const second = collectLivePtys(platform, { accept: (id) => id === 'b', timeoutMs: 1000 });
+ await Promise.resolve();
+ const [firstToken, secondToken] = asked;
+ expect(firstToken).not.toBe(secondToken);
+
+ // The second arrival's list reaches the first collector too. Filtered by
+ // `accept` it is empty, and taken as this collector's own answer it would
+ // read as "the host holds none" — a cold restore over live shells.
+ answer(secondToken!, ['b']);
+ answer(firstToken!, ['a']);
+
+ const [a, b] = await Promise.all([first, second]);
+ expect(a).toMatchObject({ timedOut: false });
+ expect(b).toMatchObject({ timedOut: false });
+ expect(a.ptys.map((pty) => pty.id)).toEqual(['a']);
+ expect(b.ptys.map((pty) => pty.id)).toEqual(['b']);
+ // Each resumes over its own replay, never the other's.
+ expect([...a.replay.keys()]).toEqual(['a']);
+ expect([...b.replay.keys()]).toEqual(['b']);
+ });
+
+ it('tells an empty answer apart from no answer at all', async () => {
+ vi.useFakeTimers();
+ try {
+ const { platform, asked, answer } = addressedPlatform();
+ const empty = collectLivePtys(platform, { timeoutMs: 100 });
+ await Promise.resolve();
+ answer(asked[0]!);
+ const settled = await empty;
+ // The host answered, and it holds nothing.
+ expect(settled).toMatchObject({ ptys: [], timedOut: false });
+
+ const silent = collectLivePtys(platform, { timeoutMs: 100 });
+ await vi.advanceTimersByTimeAsync(200);
+ // Nothing came back, so nothing is known: a caller that cold-restores
+ // here starts fresh shells over PTYs that are still running.
+ expect(await silent).toMatchObject({ ptys: [], timedOut: true });
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it('resumeOrRestore buys the retry only for a saved terminal pane', async () => {
+ vi.useFakeTimers();
+ try {
+ // Nothing saved, and a host that never answers: the retry protects live
+ // shells a cold restore would start over, and there are none to protect,
+ // so first paint is not held for its whole budget.
+ const fresh = addressedPlatform();
+ (fresh.platform as { getState: () => unknown }).getState = () => null;
+ const booted = resumeOrRestore(fresh.platform);
+ await vi.advanceTimersByTimeAsync(600);
+ expect(await booted).toEqual({ paneIds: [] });
+ expect(fresh.asked).toHaveLength(1);
+
+ // A saved terminal pane is exactly what the retry protects: ask again.
+ const saved = addressedPlatform();
+ (saved.platform as { getState: () => unknown }).getState = () => ({
+ version: 3,
+ panes: [{ id: 'a', title: 'a', cwd: '/tmp', untouched: false, alert: null }],
+ });
+ const restoring = resumeOrRestore(saved.platform);
+ await vi.advanceTimersByTimeAsync(600);
+ expect(saved.asked).toHaveLength(2);
+ saved.answer(saved.asked[1]!, ['a']);
+ expect((await restoring).paneIds).toEqual(['a']);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it('asks a second time before believing silence, and resumes on the late answer', async () => {
+ vi.useFakeTimers();
+ try {
+ const { platform, asked, answer } = addressedPlatform();
+ const collecting = collectLivePtys(platform, { timeoutMs: 100, retryTimeoutMs: 3000 });
+ // The first wait runs out with nothing back: a boot that believed it here
+ // would cold-restore, starting a second set of shells over live ones.
+ await vi.advanceTimersByTimeAsync(200);
+ expect(asked).toHaveLength(2);
+
+ // The host is just slow. Its answer to the second ask is what resumes.
+ answer(asked[1]!, ['a']);
+ const collected = await collecting;
+ expect(collected.timedOut).toBe(false);
+ expect(collected.ptys.map((pty) => pty.id)).toEqual(['a']);
+ expect([...collected.replay.keys()]).toEqual(['a']);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it('asks once when told to, and once more only on silence', async () => {
+ vi.useFakeTimers();
+ try {
+ const { platform, asked, answer } = addressedPlatform();
+ const answered = collectLivePtys(platform, { timeoutMs: 100, retryTimeoutMs: 3000 });
+ await Promise.resolve();
+ answer(asked[0]!);
+ expect(await answered).toMatchObject({ ptys: [], timedOut: false });
+ // An answered ask is never repeated.
+ expect(asked).toHaveLength(1);
+
+ // No `retryTimeoutMs`: one ask, and the silence stands.
+ const once = collectLivePtys(platform, { timeoutMs: 100 });
+ await vi.advanceTimersByTimeAsync(200);
+ expect(await once).toMatchObject({ timedOut: true });
+ expect(asked).toHaveLength(2);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it('accepts an answer from a host that echoes no token', async () => {
+ // VS Code, Pocket and the website each serve one webview, so their answers
+ // carry none and there is nothing to tell apart.
+ const listHandlers = new Set<(detail: { ptys: PtyInfo[] }) => void>();
+ const platform = {
+ requestInit: () => {
+ for (const handler of [...listHandlers]) handler({ ptys: [{ id: 'a', alive: true } as PtyInfo] });
+ },
+ onPtyList: (handler: (detail: { ptys: PtyInfo[] }) => void) => { listHandlers.add(handler); },
+ offPtyList: (handler: (detail: { ptys: PtyInfo[] }) => void) => { listHandlers.delete(handler); },
+ onPtyReplay: () => {},
+ offPtyReplay: () => {},
+ } as unknown as PlatformAdapter;
+ const collected = await collectLivePtys(platform, { timeoutMs: 1000 });
+ expect(collected.ptys.map((pty) => pty.id)).toEqual(['a']);
+ expect(collected.timedOut).toBe(false);
+ });
+});
diff --git a/lib/src/lib/reconnect.ts b/lib/src/lib/reconnect.ts
index c13058ff4..2ebe25ff0 100644
--- a/lib/src/lib/reconnect.ts
+++ b/lib/src/lib/reconnect.ts
@@ -25,6 +25,11 @@ export interface ReconnectResult {
export interface LivePtys {
ptys: PtyInfo[];
replay: Map;
+ /** The host never answered this collection's own `requestInit`. **An empty
+ * `ptys` means "the host holds none" only when this is false**: a caller that
+ * cold-restores on a timeout starts fresh shells over PTYs that are still
+ * running (`planArrival` in `standalone/src/workspace-move.ts`). */
+ timedOut: boolean;
}
/**
@@ -55,31 +60,106 @@ export interface ResumePlanOptions {
* 3. Neither → return empty (Wall creates a fresh terminal)
*/
export async function resumeOrRestore(platform: PlatformAdapter): Promise {
- return resumeOrRestoreFrom(platform, await collectLivePtys(platform));
+ const savedSession = readPersistedSession(platform.getState());
+ // The retry protects live shells from being restored over, and a record with
+ // no terminal pane has none to lose: without the gate a host whose
+ // `requestInit` answers nothing holds first paint for the whole budget
+ // (`restoreWindow` in `standalone/src/window-restore.ts` gates the same way).
+ const hasTerminalPanes =
+ savedSession?.panes.some((pane) => pane.surfaceType !== 'browser') ?? false;
+ const live = await collectLivePtys(
+ platform,
+ hasTerminalPanes ? { retryTimeoutMs: LIST_RETRY_MS } : {},
+ );
+ return resumeOrRestoreFrom(platform, live, { savedSession });
}
+/** How one collection differs from the ordinary boot one. */
+export interface CollectPtysOptions {
+ /** What makes the host answer, given this collection's own token to carry.
+ * Defaults to `platform.requestInit(requestId)` — the whole Window. A
+ * Workspace arriving from another Window instead asks the host for exactly
+ * the PTYs whose ownership just moved to it. */
+ trigger?: (requestId: string) => 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 once more on this budget when the host never answered at all. Omitted:
+ * one attempt (`collectLivePtysOnce`). */
+ retryTimeoutMs?: number;
+}
+
+/**
+ * The second ask's budget when the first got no `pty:list` at all.
+ *
+ * A `timedOut` list is the input a cold restore reads as "the host holds
+ * nothing", and acting on it starts a second set of shells over the ones still
+ * running. A launch slow enough to outrun 500 ms — a cold sidecar behind an
+ * antivirus scan — is exactly when that happens, so the host is asked once more
+ * before its silence is believed. Costs nothing when there is nothing to say:
+ * an empty list still resolves as soon as it arrives.
+ */
+export const LIST_RETRY_MS = 3000;
+
+/** Distinct per collection and per webview reload; only ever compared for
+ * equality against the host's echo. */
+let collectSeq = 0;
+
/**
* Ask the host for its PTYs and gather the replay each one sends back.
*
* Bounded rather than counted-to-completion: a host that lists PTYs but never
* replays one of them must not hold up boot, so 500 ms is the ceiling and a
* short list resolves as soon as every replay has arrived.
+ *
+ * **Finishes only on its own answer.** Every listener sees every `pty:list`, so
+ * one window running two collections at once — a boot and a Workspace arriving,
+ * or two arrivals — would otherwise let each finish on the other's list and
+ * conclude the host holds nothing. The token rides the `requestInit` and comes
+ * back on the list and each replay; an answer carrying none is a host that does
+ * not echo it (VS Code, Pocket, the website), which has one collector anyway.
+ *
+ * **Asks twice before believing silence**, on `retryTimeoutMs`: the whole
+ * difference between "the host holds nothing" and "the host never answered" is
+ * `timedOut`, and a caller that cold-restores on the second starts a second set
+ * of shells over the ones still running.
*/
-export function collectLivePtys(platform: PlatformAdapter): Promise {
+export async function collectLivePtys(
+ platform: PlatformAdapter,
+ options: CollectPtysOptions = {},
+): Promise {
+ const first = await collectLivePtysOnce(platform, options);
+ if (!first.timedOut || options.retryTimeoutMs === undefined) return first;
+ return collectLivePtysOnce(platform, { ...options, timeoutMs: options.retryTimeoutMs });
+}
+
+/** One ask and one wait. `collectLivePtys` is this, plus the retry. */
+function collectLivePtysOnce(
+ platform: PlatformAdapter,
+ options: CollectPtysOptions = {},
+): Promise {
+ const accept = options.accept ?? (() => true);
+ const requestId = `init-${++collectSeq}`;
+ const mine = (detail: { requestId?: string }) =>
+ detail.requestId === undefined || detail.requestId === requestId;
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;
+ const handleList = (detail: { ptys: PtyInfo[]; requestId?: string }) => {
+ if (!mine(detail)) return;
+ ptyList = detail.ptys.filter((pty) => accept(pty.id));
if (ptyList.length === 0) {
finish();
}
};
- const handleReplay = (detail: { id: string; data: string }) => {
+ const handleReplay = (detail: { id: string; data: string; requestId?: string }) => {
+ if (!mine(detail) || !accept(detail.id)) return;
replay.set(detail.id, detail.data);
if (ptyList && replay.size >= ptyList.length) {
finish();
@@ -93,12 +173,13 @@ export function collectLivePtys(platform: PlatformAdapter): Promise {
clearTimeout(timeout);
platform.offPtyList(handleList);
platform.offPtyReplay(handleReplay);
- resolve({ ptys: ptyList ?? [], replay });
+ resolve({ ptys: ptyList ?? [], replay, timedOut: ptyList === null });
}
platform.onPtyList(handleList);
platform.onPtyReplay(handleReplay);
- platform.requestInit();
+ // Last: the handlers must be armed before anything can answer.
+ (options.trigger ?? ((token: string) => platform.requestInit(token)))(requestId);
});
}
diff --git a/lib/src/lib/session-save.ts b/lib/src/lib/session-save.ts
index ec59aa505..4cb50efb4 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
+ * (`prepareWorkspaceTransfer` 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 e3364a12b..72c54bbd7 100644
--- a/lib/src/lib/terminal-lifecycle.ts
+++ b/lib/src/lib/terminal-lifecycle.ts
@@ -568,7 +568,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);
@@ -576,9 +584,10 @@ 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);
// Detach before releasing: unlike a minimize, nothing here has to survive, so the
// fallback renderer the addon's disposal constructs never touches the document.
+ // A released Session's context goes too: the target Window mounts its own.
entry.element.remove();
entry.webglRenderer?.unmount();
entry.terminal.dispose();
@@ -588,6 +597,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 b9f0069b6..8ad05904f 100644
--- a/lib/src/lib/terminal-registry.ts
+++ b/lib/src/lib/terminal-registry.ts
@@ -49,6 +49,7 @@ export {
mountElement,
refitSession,
registerSurfaceFocusHandle,
+ releaseSession,
restoreTerminal,
resumeTerminal,
setPendingShellOpts,
diff --git a/lib/src/lib/throttle.test.ts b/lib/src/lib/throttle.test.ts
new file mode 100644
index 000000000..ef74b8d42
--- /dev/null
+++ b/lib/src/lib/throttle.test.ts
@@ -0,0 +1,99 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { throttleTrailing } from './throttle';
+
+beforeEach(() => {
+ vi.useFakeTimers();
+});
+
+afterEach(() => {
+ vi.useRealTimers();
+});
+
+describe('throttleTrailing', () => {
+ it('fires immediately for a single call (leading edge) and never again', () => {
+ const fn = vi.fn();
+ const throttled = throttleTrailing(fn, 150);
+
+ throttled();
+ expect(fn).toHaveBeenCalledTimes(1);
+
+ // No further calls arrive — the window closes with nothing pending.
+ vi.advanceTimersByTime(1000);
+ expect(fn).toHaveBeenCalledTimes(1);
+ });
+
+ it('coalesces a burst into leading + capped intermediates + trailing', () => {
+ const fn = vi.fn();
+ const throttled = throttleTrailing(fn, 150);
+
+ // Simulate ~26 animation frames (16ms apart) across a 440ms motion.
+ for (let t = 0; t < 440; t += 16) {
+ throttled();
+ vi.advanceTimersByTime(16);
+ }
+ // Leading (t=0) + intermediates roughly every 150ms while the burst runs.
+ // Far fewer than 27 raw frames; a small handful.
+ const duringBurst = fn.mock.calls.length;
+ expect(duringBurst).toBeGreaterThanOrEqual(2);
+ expect(duringBurst).toBeLessThanOrEqual(5);
+
+ // Let everything settle — a final trailing call fits the resting geometry.
+ // Strictly greater than the burst count: the last frame left a trailing
+ // call pending, so exactly one more fire must land after settling.
+ // (`> duringBurst - 1` would be `>= duringBurst`, which holds even if no
+ // trailing call fired, defeating the check.)
+ vi.advanceTimersByTime(300);
+ const total = fn.mock.calls.length;
+ expect(total).toBeGreaterThan(duringBurst);
+ expect(total).toBeLessThanOrEqual(5);
+ });
+
+ it('fires a trailing call after a leading + one interior call', () => {
+ const fn = vi.fn();
+ const throttled = throttleTrailing(fn, 150);
+
+ throttled(); // leading, fires now
+ expect(fn).toHaveBeenCalledTimes(1);
+
+ vi.advanceTimersByTime(50);
+ throttled(); // inside window — trailing pending, not yet fired
+ expect(fn).toHaveBeenCalledTimes(1);
+
+ vi.advanceTimersByTime(100); // window (150ms) closes → trailing fires
+ expect(fn).toHaveBeenCalledTimes(2);
+
+ // Nothing else pending — no extra fire.
+ vi.advanceTimersByTime(1000);
+ expect(fn).toHaveBeenCalledTimes(2);
+ });
+
+ it('cancel() drops a pending trailing call', () => {
+ const fn = vi.fn();
+ const throttled = throttleTrailing(fn, 150);
+
+ throttled(); // leading fires
+ throttled(); // trailing pending
+ expect(fn).toHaveBeenCalledTimes(1);
+
+ throttled.cancel();
+ vi.advanceTimersByTime(1000);
+ // Trailing was cancelled — still just the one leading call.
+ expect(fn).toHaveBeenCalledTimes(1);
+ });
+
+ it('leads again after the window fully closes', () => {
+ const fn = vi.fn();
+ const throttled = throttleTrailing(fn, 150);
+
+ throttled();
+ expect(fn).toHaveBeenCalledTimes(1);
+
+ // Let the window close with nothing pending.
+ vi.advanceTimersByTime(200);
+ expect(fn).toHaveBeenCalledTimes(1);
+
+ // A later call is a fresh leading edge, immediate again.
+ throttled();
+ expect(fn).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/lib/src/lib/throttle.ts b/lib/src/lib/throttle.ts
new file mode 100644
index 000000000..1e76b57d0
--- /dev/null
+++ b/lib/src/lib/throttle.ts
@@ -0,0 +1,58 @@
+/** A throttled function with a `cancel()` to drop any pending trailing call. */
+export interface ThrottledFn {
+ (): void;
+ /** Drop any pending trailing call and close the window. Call on teardown so a
+ * trailing invocation never fires after the caller has unmounted. */
+ cancel(): void;
+}
+
+/**
+ * Throttle `fn` on the leading edge with a guaranteed trailing call:
+ *
+ * - The first call fires `fn` immediately (leading), so a one-off event stays
+ * instant.
+ * - While calls keep arriving, `fn` runs at most once per `ms`.
+ * - Once calls stop, one final trailing call fires so the last state is always
+ * applied exactly.
+ *
+ * Used by the cross-window Workspace drag: every pointer move asks Rust which
+ * window is under the cursor, and the answer must be current when the pointer
+ * stops (`standalone/src/workspace-drag.ts`). The leading edge answers the first
+ * move at once, the throttle caps the probe rate, and the trailing call reports
+ * the last point exactly.
+ */
+export function throttleTrailing(fn: () => void, ms: number): ThrottledFn {
+ let timer: ReturnType | null = null;
+ let trailingPending = false;
+
+ const onTimeout = () => {
+ if (trailingPending) {
+ // Calls arrived during the window — fire once for them, then reopen the
+ // window so a continued stream keeps coalescing.
+ trailingPending = false;
+ fn();
+ timer = setTimeout(onTimeout, ms);
+ } else {
+ timer = null;
+ }
+ };
+
+ const throttled = (() => {
+ if (timer === null) {
+ // Leading edge: fire now and open the throttle window.
+ fn();
+ timer = setTimeout(onTimeout, ms);
+ } else {
+ // Inside a window: remember to fire once when it closes.
+ trailingPending = true;
+ }
+ }) as ThrottledFn;
+
+ throttled.cancel = () => {
+ if (timer !== null) clearTimeout(timer);
+ timer = null;
+ trailingPending = false;
+ };
+
+ return throttled;
+}
diff --git a/lib/src/lib/watched-command-host.ts b/lib/src/lib/watched-command-host.ts
index 8882d6bf8..44e21876c 100644
--- a/lib/src/lib/watched-command-host.ts
+++ b/lib/src/lib/watched-command-host.ts
@@ -1,6 +1,6 @@
import type { AlertManager } from './alert-manager';
-type WatchedCommandTarget = Pick<
+export type WatchedCommandTarget = Pick<
AlertManager,
'getWatchedCommands' | 'setCommandWatched' | 'setWatchedCommands'
>;
diff --git a/lib/src/lib/window-session-aggregator.test.ts b/lib/src/lib/window-session-aggregator.test.ts
index 305c066da..69a0dbc87 100644
--- a/lib/src/lib/window-session-aggregator.test.ts
+++ b/lib/src/lib/window-session-aggregator.test.ts
@@ -4,10 +4,12 @@
// and there is no unload without a `window`.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
+ clearWorkspaceTransferring,
flushWindowSession,
forgetWorkspaceSession,
getWindowSnapshot,
installWindowSessionWriter,
+ markWorkspaceTransferring,
previousWorkspaceSession,
publishWorkspaceSession,
resetWindowSessionAggregator,
@@ -68,6 +70,31 @@ describe('window session aggregator', () => {
expect(getWindowSnapshot().workspaces.map((ws) => ws.id)).toEqual([first]);
});
+ it('writes no Workspace that is in flight to another Window', () => {
+ // Its shells already belong to the target, so a quit or a crash in the gap
+ // must not persist the same Workspace in two Windows and restore it twice
+ // (`docs/specs/standalone.md` → "Arrival queue").
+ const first = getWorkspacesSnapshot().workspaces[0].id;
+ const second = createWorkspace({ name: 'Second' }).id;
+ publishWorkspaceSession(first, session('a'));
+ publishWorkspaceSession(second, session('b'));
+
+ markWorkspaceTransferring(second);
+ expect(getWindowSnapshot().workspaces.map((ws) => ws.id)).toEqual([first]);
+ // The active id falls back to a Workspace the blob actually contains.
+ expect(getWindowSnapshot().activeWorkspaceId).toBe(first);
+
+ // Refused: this Window persists it again, record and all.
+ clearWorkspaceTransferring(second);
+ expect(getWindowSnapshot().workspaces.map((ws) => ws.id)).toEqual([first, second]);
+
+ // Adopted instead: forgetting it clears the mark with the record.
+ markWorkspaceTransferring(second);
+ forgetWorkspaceSession(second);
+ clearWorkspaceTransferring(second);
+ expect(getWindowSnapshot().workspaces.map((ws) => ws.id)).toEqual([first]);
+ });
+
it('forgets a Workspace session, seeded or published', () => {
const first = getWorkspacesSnapshot().workspaces[0].id;
seedWindowSession({ version: 1, workspaces: [{ id: first, name: 'One', session: session('seed') }], activeWorkspaceId: first });
diff --git a/lib/src/lib/window-session-aggregator.ts b/lib/src/lib/window-session-aggregator.ts
index 04bd3f835..b19514bf5 100644
--- a/lib/src/lib/window-session-aggregator.ts
+++ b/lib/src/lib/window-session-aggregator.ts
@@ -15,6 +15,8 @@ import type { PersistedSession, PersistedWindow, PersistedWorkspace, WorkspaceId
*/
const records = new Map();
+/** Workspaces this Window has handed to another one and not yet released. */
+const transferring = new Set();
let writer: ((snapshot: PersistedWindow) => void | Promise) | null = null;
let unsubscribeWorkspaces: (() => void) | null = null;
let timer: ReturnType | null = null;
@@ -56,10 +58,33 @@ export function publishWorkspaceSession(workspaceId: WorkspaceId, session: Persi
/** Drop a Workspace's session (its Workspace was closed or moved away). */
export function forgetWorkspaceSession(workspaceId: WorkspaceId): void {
+ transferring.delete(workspaceId);
if (!records.delete(workspaceId)) return;
scheduleWrite();
}
+/**
+ * This Workspace has been handed to another Window and not yet released
+ * (`docs/specs/standalone.md` → "Arrival queue").
+ *
+ * **A transferring Workspace is in no snapshot this Window writes.** It is still
+ * mounted here, and its Sessions are still attached, because the target may
+ * refuse it — but its shells already belong to the target, so a quit or a crash
+ * in the gap must not leave the same Workspace persisted by two Windows and
+ * restored twice. Cleared by `clearWorkspaceTransferring` (the target refused
+ * it) or by `forgetWorkspaceSession` (it landed).
+ */
+export function markWorkspaceTransferring(workspaceId: WorkspaceId): void {
+ transferring.add(workspaceId);
+ scheduleWrite();
+}
+
+/** The transfer was refused: this Window persists the Workspace again. */
+export function clearWorkspaceTransferring(workspaceId: WorkspaceId): void {
+ if (!transferring.delete(workspaceId)) return;
+ scheduleWrite();
+}
+
/**
* This Workspace's last persisted record — what its Wall published, or what boot
* seeded until then. A save's previous-pane map reads a dead PTY's retained
@@ -72,16 +97,17 @@ export function previousWorkspaceSession(workspaceId: WorkspaceId): PersistedSes
/**
* The Window as it stands: Workspaces in strip order carrying the id, name, and
- * latest session of each. A Workspace with no record at all is omitted rather
- * than written empty, and the active id then falls back to the first Workspace
- * that is in the blob — a blob naming an absent Workspace restores nothing as
- * active (`readPersistedWindow` repairs it, but only after the user has already
- * landed somewhere unexpected).
+ * latest session of each. A Workspace with no record at all — or one in flight
+ * to another Window — is omitted rather than written empty, and the active id
+ * then falls back to the first Workspace that is in the blob; a blob naming an
+ * absent Workspace restores nothing as active (`readPersistedWindow` repairs it,
+ * but only after the user has already landed somewhere unexpected).
*/
export function getWindowSnapshot(): PersistedWindow {
const { workspaces, activeId } = getWorkspacesSnapshot();
const collected: PersistedWorkspace[] = [];
for (const workspace of workspaces) {
+ if (transferring.has(workspace.id)) continue;
const session = previousWorkspaceSession(workspace.id);
if (!session) continue;
collected.push({ id: workspace.id, name: workspace.name, session });
@@ -187,6 +213,7 @@ function cancelPending(): void {
/** Forget every session, seed, and installed writer (tests). */
export function resetWindowSessionAggregator(): void {
records.clear();
+ transferring.clear();
writer = null;
unsubscribeWorkspaces?.();
unsubscribeWorkspaces = null;
diff --git a/lib/src/lib/workspace-store.ts b/lib/src/lib/workspace-store.ts
index 426921a4d..52d0055dc 100644
--- a/lib/src/lib/workspace-store.ts
+++ b/lib/src/lib/workspace-store.ts
@@ -162,12 +162,30 @@ export function moveWorkspace(id: WorkspaceId, toIndex: number): boolean {
return true;
}
-/** The only Window this build addresses; `window:` beyond it is an error. */
-export const WINDOW_REF = 'window:1';
+/** A Window that never names itself: one webview is the whole application
+ * (VS Code, Pocket, the website playground). */
+const DEFAULT_WINDOW_REF = 'window:1';
+let windowRef = DEFAULT_WINDOW_REF;
-/** Whether `ref` names this Window — `window:1`, or the bare `1`. */
+/**
+ * Name this Window to `dor`, as `window:`. Injected by a host that has
+ * more than one Window and so knows its own labels — the lib cannot
+ * (`docs/specs/dor-cli.md` -> "Handle Model").
+ */
+export function setWindowLabel(label: string): void {
+ windowRef = `window:${label}`;
+}
+
+/** How this Window names itself to `dor`, which is what `dor list` reports. */
+export function currentWindowRef(): string {
+ return windowRef;
+}
+
+/** Whether `ref` names **this** Window — its full ref, or the bare label. A ref
+ * naming another Window is not one this Window can act on. */
export function isWindowRef(ref: string): boolean {
- return ref.trim() === WINDOW_REF || ref.trim() === '1';
+ const trimmed = ref.trim();
+ return trimmed === windowRef || `window:${trimmed}` === windowRef;
}
/** A Workspace's positional `dor` ref. One no longer in this Window — its Wall is
diff --git a/lib/src/main.tsx b/lib/src/main.tsx
index 111df30d3..addbb9520 100644
--- a/lib/src/main.tsx
+++ b/lib/src/main.tsx
@@ -27,7 +27,8 @@ if (isVscode) {
initAlertStateReceiver();
// Request PTY list before rendering so Wall can restore existing sessions.
-// On non-VSCode platforms (or first launch), this resolves immediately with no IDs.
+// With nothing saved (a first launch, or the fake adapter) this self-caps at
+// 500 ms; only a saved terminal pane buys the 3 s retry.
resumeOrRestore(platform).then((result) => {
createRoot(document.getElementById("root")!).render(
diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json
index c4be84ddf..c749efa58 100644
--- a/scripts/spec-word-budgets.json
+++ b/scripts/spec-word-budgets.json
@@ -2,35 +2,35 @@
"AGENTS.md": 3350,
"SECURITY.md": 200,
"SELF_HOST.md": 6000,
- "docs/specs/alert.md": 6700,
- "docs/specs/auto-update.md": 1000,
+ "docs/specs/alert.md": 6800,
+ "docs/specs/auto-update.md": 1100,
"docs/specs/deploy.md": 1900,
"docs/specs/dor-browser.md": 4500,
- "docs/specs/dor-cli.md": 4950,
+ "docs/specs/dor-cli.md": 5100,
"docs/specs/dor-tool.md": 2100,
- "docs/specs/glossary.md": 2850,
+ "docs/specs/glossary.md": 3000,
"docs/specs/layout.md": 8500,
"docs/specs/mobile-terminal-ui.md": 1950,
"docs/specs/mouse-and-clipboard.md": 3750,
- "docs/specs/notepad.md": 3800,
+ "docs/specs/notepad.md": 3900,
"docs/specs/pocket-app.md": 4050,
"docs/specs/relay.md": 10150,
"docs/specs/remote-api.md": 3600,
"docs/specs/remote-security-model.md": 4200,
"docs/specs/security-audit.md": 1750,
"docs/specs/security-ci.md": 2500,
- "docs/specs/security-local.md": 2600,
+ "docs/specs/security-local.md": 2650,
"docs/specs/security-remote.md": 4900,
"docs/specs/security-supply-chain.md": 1150,
"docs/specs/security.md": 1900,
"docs/specs/shortcuts.md": 1050,
- "docs/specs/standalone.md": 5300,
+ "docs/specs/standalone.md": 8750,
"docs/specs/terminal-context.md": 900,
"docs/specs/terminal-escapes.md": 3750,
"docs/specs/terminal-state.md": 2350,
"docs/specs/theme.md": 2150,
"docs/specs/tiling-engine.md": 4500,
- "docs/specs/transport.md": 4800,
+ "docs/specs/transport.md": 5350,
"docs/specs/tutorial.md": 1900,
"docs/specs/vscode.md": 7400,
"docs/specs/webgl-text.md": 1200,
diff --git a/standalone/scripts/build-sidecar-proxy.mjs b/standalone/scripts/build-sidecar-proxy.mjs
index bfe44813c..946038125 100644
--- a/standalone/scripts/build-sidecar-proxy.mjs
+++ b/standalone/scripts/build-sidecar-proxy.mjs
@@ -5,8 +5,9 @@
// - lib/src/host/agent-browser-host.ts → sidecar/agent-browser-host.cjs
// - lib/src/host/remote/sidecar-entry.ts → sidecar/burrow.cjs
// - lib/src/host/recovery.ts → sidecar/recovery.cjs
-// See docs/specs/dor-browser.md, docs/specs/remote-api.md, and
-// docs/specs/standalone.md -> "Agent recovery".
+// - lib/src/host/alert-store-host.ts → sidecar/alert-store.cjs
+// See docs/specs/dor-browser.md, docs/specs/remote-api.md,
+// docs/specs/standalone.md -> "Agent recovery", and docs/specs/alert.md.
import { build } from 'esbuild';
import { rm } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
@@ -29,6 +30,7 @@ const bundles = [
{ entry: 'iframe-proxy.ts', out: 'iframe-proxy.cjs' },
{ entry: 'agent-browser-host.ts', out: 'agent-browser-host.cjs' },
{ entry: 'recovery.ts', out: 'recovery.cjs' },
+ { entry: 'alert-store-host.ts', out: 'alert-store.cjs' },
{
entry: 'remote/sidecar-entry.ts',
out: 'burrow.cjs',
diff --git a/standalone/scripts/dev-agent-browser.mjs b/standalone/scripts/dev-agent-browser.mjs
index a7b6797ce..1783c12df 100644
--- a/standalone/scripts/dev-agent-browser.mjs
+++ b/standalone/scripts/dev-agent-browser.mjs
@@ -106,7 +106,7 @@ const fireAndForget = {
pty_resize: ({ id, cols, rows }) => writeSidecar('pty:resize', { id, cols, rows }),
pty_theme_colors: ({ colors }) => writeSidecar('pty:themeColors', colors),
pty_kill: ({ id }) => writeSidecar('pty:kill', { id }),
- pty_request_init: () => writeSidecar('pty:requestInit'),
+ pty_request_init: ({ requestId } = {}) => writeSidecar('pty:requestInit', { requestId }),
dor_control_response: ({ response }) => writeSidecar('dor:controlResponse', response),
// The Burrow's whole bridge rides one passthrough, exactly as it does
// through Rust (`burrow_command` in src-tauri/src/lib.rs).
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/scripts/window-listeners.test.mjs b/standalone/scripts/window-listeners.test.mjs
new file mode 100644
index 000000000..a17155f3e
--- /dev/null
+++ b/standalone/scripts/window-listeners.test.mjs
@@ -0,0 +1,51 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { readdirSync, readFileSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+/**
+ * A listener registered with Tauri's default `Any` target receives every event
+ * in the process, including the ones Rust addressed to one window
+ * (`match_any_or_filter` in Tauri's event listener). The whole per-window
+ * routing would then be decoration: every window would take every other
+ * window's terminal output, its `pty:list`, its Workspace arrivals and its
+ * teardown order (docs/specs/standalone.md -> "Routing").
+ *
+ * The failure is silent — nothing errors, the events simply go everywhere — so
+ * this scans the source rather than trusting review.
+ */
+
+const src = join(dirname(fileURLToPath(import.meta.url)), '..', 'src');
+/** The one module allowed to reach the bare API: it is the wrapper. */
+const WRAPPER = 'window-label.ts';
+
+// Recursive: a listener in a subdirectory is exactly as unscoped as one beside
+// the wrapper, and a scan that skipped it would report a clean bill of health.
+const sources = readdirSync(src, { recursive: true, withFileTypes: true })
+ .filter((entry) => entry.isFile()
+ && /\.tsx?$/.test(entry.name)
+ && !entry.name.includes('.test.')
+ && entry.name !== WRAPPER)
+ .map((entry) => ({
+ name: entry.name,
+ text: readFileSync(join(entry.parentPath, entry.name), 'utf8'),
+ }));
+
+test('the scan found the sources it is meant to be reading', () => {
+ // A walk that found nothing passes both checks below without reading a line.
+ assert.ok(sources.length > 10, `only ${sources.length} sources under ${src}`);
+ assert.ok(sources.some((file) => file.name === 'tauri-adapter.ts'));
+});
+
+test('every window listener is scoped through listenToWindow', () => {
+ // `listen(` preceded by a word character or a dot is something else
+ // (`listenToWindow(`, `appWindow.listen(`).
+ const offenders = sources.filter((file) => /(? file.name), []);
+});
+
+test('the wrapper is the only importer of the bare event API', () => {
+ const offenders = sources.filter((file) => /from ['"]@tauri-apps\/api\/event['"]/.test(file.text));
+ assert.deepEqual(offenders.map((file) => file.name), []);
+});
diff --git a/standalone/sidecar/main.js b/standalone/sidecar/main.js
index 0cbf13994..2e448a619 100644
--- a/standalone/sidecar/main.js
+++ b/standalone/sidecar/main.js
@@ -26,6 +26,11 @@ const { createSidecarBurrow } = require('./burrow.cjs');
// machine (shared with the VS Code extension host) plus the single-use record
// store. See docs/specs/standalone.md -> "Agent recovery".
const { captureAgentRecovery, createRecoveryStore, sliceSince } = require('./recovery.cjs');
+// Same pattern again: lib/src/host/alert-store-host.ts holds the two
+// app-global alert stores — one WATCHING rule set and one alarm-settings blob
+// for every window — running the same classes the VS Code extension host runs.
+// See docs/specs/alert.md.
+const { createAlertStoreHost } = require('./alert-store.cjs');
const agentBrowser = createAgentBrowserHost({
writeClipboardText: (text) => clipboard.writeClipboardText(text),
@@ -77,6 +82,10 @@ const dorControlToken = process.env.DORMOUSE_CONTROL_TOKEN;
delete process.env.DORMOUSE_CONTROL_TOKEN;
delete process.env.DORMOUSE_CONTROL_SOCKET;
+// Broadcast, never addressed: both stores are one per machine, so every window
+// gets the same canonical snapshot (docs/specs/standalone.md -> "Windows").
+const alertStore = createAlertStoreHost({ send });
+
const dorControl = createDorControlServer({
token: dorControlToken,
send,
@@ -138,7 +147,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, data?.requestId); 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 +184,17 @@ 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).
+ // Which webviews will answer a Burrow ask (docs/specs/standalone.md
+ // -> "Burrow service").
+ case 'burrow:windows': burrow.setWindows(data?.labels); break;
+ // Which windows an ask actually reached. Only the host knows: one naming
+ // a Surface goes to its owner alone (docs/specs/standalone.md ->
+ // "Burrow service").
+ case 'burrow:askDelivered': burrow.setAskDelivery(data); break;
+ case 'alert:command': alertStore.handle(data); break;
case 'pty:themeColors': burrow.setThemeColors(data); break;
case 'sidecar:shutdown': shutdown(); break;
case 'dor:controlResponse': dorControl?.respond(data); break;
@@ -270,6 +289,7 @@ async function shutdown() {
]);
} catch {}
dorControl?.close();
+ alertStore.dispose();
burrow.dispose();
mgr.killAll();
process.exit(0);
diff --git a/standalone/sidecar/pty-core.js b/standalone/sidecar/pty-core.js
index 8d1f52c5a..0efb1fd70 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,29 @@ 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"), and `requestId` so the asking
+ * collector can tell its own answer from a concurrent one's
+ * (docs/specs/transport.md -> "Reconnection").
+ */
+ function list(ids, forWindow, requestId) {
+ 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 } : {}),
+ ...(requestId === undefined || requestId === null ? {} : { requestId }),
+ };
+ 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,22 +1445,31 @@ module.exports.create = function create(send, ptyModule, { replay = false, slice
if (requestId !== undefined) send('interruptDone', { requestId });
}
- function gracefulKillAll(timeout = 2000, requestId) {
+ /**
+ * SIGTERM `ids` and resolve once they have exited.
+ *
+ * Always an explicit set, never a blanket kill: one window of several tears
+ * down alone, and killing a sibling's terminals is unrecoverable. The host
+ * names the window's own PTYs (`pty_graceful_kill` in
+ * standalone/src-tauri/src/lib.rs).
+ */
+ function gracefulKill(ids, timeout = 2000, requestId) {
const done = () => send('gracefulKillDone', { requestId });
+ const targets = (Array.isArray(ids) ? ids : []).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);
};
@@ -1450,6 +1481,6 @@ module.exports.create = function create(send, ptyModule, { replay = false, slice
}
return { spawn, write, resize, hasPty, kill, killAll, list, context,
- getCwd, getCwds, getOpenPorts, interrupt, gracefulKillAll, getShells,
+ getCwd, getCwds, getOpenPorts, interrupt, gracefulKill, getShells,
liveIds, receivedChars, outputSince };
};
diff --git a/standalone/sidecar/pty-core.test.js b/standalone/sidecar/pty-core.test.js
index feeb4ca4a..43c912ab7 100644
--- a/standalone/sidecar/pty-core.test.js
+++ b/standalone/sidecar/pty-core.test.js
@@ -511,7 +511,7 @@ test('interrupt with no live PTYs still reports done', async () => {
assert.deepEqual(events.at(-1), { event: 'interruptDone', data: { requestId: 'req-empty' } });
});
-test('gracefulKillAll SIGTERMs live PTYs, echoes requestId, forwards final output', async () => {
+test('gracefulKill SIGTERMs the named PTYs, echoes requestId, forwards final output', async () => {
const events = [];
const killSignals = [];
const listeners = {};
@@ -532,7 +532,7 @@ test('gracefulKillAll SIGTERMs live PTYs, echoes requestId, forwards final outpu
}, { spawn() { return fakePty; } });
mgr.spawn('pane-1');
- mgr.gracefulKillAll(1, 'req-42');
+ mgr.gracefulKill(['pane-1'], 1, 'req-42');
listeners.data?.('final output');
await done;
@@ -549,7 +549,7 @@ test('gracefulKillAll SIGTERMs live PTYs, echoes requestId, forwards final outpu
});
});
-test('gracefulKillAll resolves early after exits and a final output grace tick', async () => {
+test('gracefulKill resolves early after exits and a final output grace tick', async () => {
const listeners = {};
const events = [];
const fakePty = {
@@ -570,7 +570,7 @@ test('gracefulKillAll resolves early after exits and a final output grace tick',
mgr.spawn('pane-1');
const started = Date.now();
- mgr.gracefulKillAll(60_000, 'req-1');
+ mgr.gracefulKill(['pane-1'], 60_000, 'req-1');
listeners.exit({ exitCode: 0, signal: 15 }); // empties the live-PTY map
// ConPTY can flush after exit. Deliver on a later tick to exercise the grace.
setTimeout(() => listeners.data('final output'), 10);
@@ -583,7 +583,7 @@ test('gracefulKillAll resolves early after exits and a final output grace tick',
assert.ok(Date.now() - started < 5_000);
});
-test('gracefulKillAll with no live PTYs waits one grace tick', async () => {
+test('gracefulKill with nothing live waits one grace tick', async () => {
const events = [];
let resolveDone;
const done = new Promise((resolve) => { resolveDone = resolve; });
@@ -594,7 +594,7 @@ test('gracefulKillAll with no live PTYs waits one grace tick', async () => {
spawn() { throw new Error('nothing should spawn'); },
});
- mgr.gracefulKillAll(60_000, 'req-1');
+ mgr.gracefulKill(['pane-1'], 60_000, 'req-1');
assert.deepEqual(events, []);
await done;
@@ -1635,3 +1635,155 @@ 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' } });
+});
+
+// One window can have two collections outstanding — a boot and a Workspace
+// arriving from another window — and every listener there sees every answer.
+// The token is what tells them apart (docs/specs/transport.md -> "Reconnection").
+test('list echoes the asking collector\'s token on the list and every replay', () => {
+ const events = [];
+ const pty = fakePtyModule();
+ const mgr = create((event, data) => events.push({ event, data }), pty.module, { replay: true });
+ mgr.spawn('a');
+ pty.listeners.get('a').data('from a');
+
+ events.length = 0;
+ mgr.list(['a'], 'ws-2', 'init-7');
+ assert.equal(events[0].data.requestId, 'init-7');
+ assert.deepEqual(events[1], {
+ event: 'replay',
+ data: { id: 'a', data: 'from a', forWindow: 'ws-2', requestId: 'init-7' },
+ });
+
+ // A host with nothing to echo carries no field at all, which every adapter
+ // that serves one webview relies on.
+ events.length = 0;
+ mgr.list(['a'], 'ws-2');
+ assert.equal('requestId' in events[0].data, false);
+ assert.equal('requestId' in events[1].data, false);
+ events.length = 0;
+ mgr.list(['a'], 'ws-2', null);
+ assert.equal('requestId' in events[0].data, false);
+});
+
+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, []);
+});
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 b7573383b..e13e179a3 100644
--- a/standalone/src-tauri/src/lib.rs
+++ b/standalone/src-tauri/src/lib.rs
@@ -2,23 +2,34 @@ 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;
+// The Dock's Quit, an `osascript` quit and a logout reach AppKit without ever
+// raising `RunEvent::ExitRequested` (docs/specs/standalone.md §Trigger
+// interception).
+#[cfg(target_os = "macos")]
+mod macos_terminate;
+use quit_state::{CloseMachine, QuitAction, QuitMachine};
+use routing::{Route, RouteView};
use std::{
- collections::HashMap,
+ collections::{HashMap, HashSet},
env,
fs::{create_dir_all, File, OpenOptions},
io::{BufRead, BufReader, Write},
path::{Path, PathBuf},
process::Stdio,
- sync::atomic::{AtomicBool, AtomicU64, Ordering},
+ sync::atomic::{AtomicU64, AtomicUsize, 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::MenuItem;
+#[cfg(target_os = "macos")]
use tauri::menu::AboutMetadata;
use process_wrap::std::{ChildWrapper, CommandWrap};
#[cfg(windows)]
@@ -48,36 +59,387 @@ 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`.
+
+/// The three maps a sidecar line is routed against, behind **one** lock: they
+/// are always read together, so a PTY chunk costs one acquisition rather than
+/// three, and every label the routing table hands back stays borrowed out of
+/// this guard instead of being cloned per line.
+#[derive(Default)]
+struct RoutingState {
+ /// ptyId -> window label. Minted only in `pty_spawn`, dropped by
+ /// `pty_kill`, an exit, or a window going away; reassigned by a transfer.
+ owners: HashMap,
+ /// 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: HashMap,
+ /// dor requestId -> the window handling it, so a cancel reaches the window
+ /// holding the subscription, watch or completion claim it releases.
+ dor_targets: HashMap,
+}
+
+#[derive(Default)]
+struct WindowState {
+ routing: Mutex,
+ /// `awaiting_replay.len()`, readable without the lock. Nothing is
+ /// transferring in the steady state, and this is what lets a chunk skip the
+ /// sweep and the `Instant::now()` it needs.
+ suppressed: AtomicUsize,
+ /// Window labels, most recently focused first.
+ focus_order: Mutex>,
+ /// Every Workspace in flight, from the source's invoke until its target
+ /// adopts it or dies (`routing::Arrival`). Pulled, never pushed.
+ ///
+ /// **Never take this lock while holding `routing`.** `dispatch_sidecar_event`
+ /// reads it before it takes `routing`, so the two are only ever acquired in
+ /// that order.
+ arrivals: 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; the entry
+ /// is dropped once that webview is destroyed and can no longer save.
+ closing: Mutex>,
+ /// The next `ws-`, seeded above every live and saved label at setup.
+ next_ws: AtomicU64,
+}
+
+impl RoutingState {
+ /// Every id `label` owns.
+ fn owned_by(&self, label: &str) -> Vec {
+ self.owners
+ .iter()
+ .filter(|(_, owner)| owner.as_str() == label)
+ .map(|(id, _)| id.clone())
+ .collect()
+ }
+}
+
+impl WindowState {
+ fn owned_by(&self, label: &str) -> Vec {
+ guard(&self.routing).owned_by(label)
+ }
+
+ /// A window spawned a PTY: it owns it until a transfer moves it.
+ ///
+ /// Clears any suppression left under this id. A spawn reusing an id whose
+ /// transfer never completed would otherwise start life silenced, with no
+ /// replay coming to lift it — the sweep's 5 s of a dead pane.
+ fn mint(&self, id: &str, label: &str) {
+ let mut routing = guard(&self.routing);
+ routing.owners.insert(id.to_string(), label.to_string());
+ if routing.awaiting_replay.remove(id).is_some() {
+ self.suppressed
+ .store(routing.awaiting_replay.len(), Ordering::Relaxed);
+ }
+ }
+
+ /// Refuse every later `save_session` for `label` (a deliberate close removed
+ /// its snapshot). Cleared by `Destroyed`, after which no save can arrive.
+ fn begin_closing(&self, label: &str) {
+ guard(&self.closing).insert(label.to_string());
+ }
+
+ fn refuses_save(&self, label: &str) -> bool {
+ guard(&self.closing).contains(label)
+ }
+
+ /// Hand `ids` to `label`. `suppress` holds their output until each one's
+ /// replay has been emitted to it (docs/specs/standalone.md §Transfer);
+ /// without it the ids go straight back into service, which is how a refused
+ /// arrival returns them to the window that still has them.
+ fn reassign(&self, ids: &[String], label: &str, suppress: bool) {
+ let mut routing = guard(&self.routing);
+ let now = Instant::now();
+ for id in ids {
+ routing.owners.insert(id.clone(), label.to_string());
+ if suppress {
+ routing.awaiting_replay.insert(id.clone(), now);
+ } else {
+ routing.awaiting_replay.remove(id);
+ }
+ }
+ self.suppressed
+ .store(routing.awaiting_replay.len(), Ordering::Relaxed);
+ }
+
+ /// Forget one PTY entirely (a kill, or its exit).
+ fn forget_pty(&self, id: &str) {
+ let mut routing = guard(&self.routing);
+ routing.owners.remove(id);
+ routing.awaiting_replay.remove(id);
+ self.suppressed
+ .store(routing.awaiting_replay.len(), Ordering::Relaxed);
+ }
+
+ /// Drop any suppression on `ids`, leaving ownership alone. What settles an
+ /// adopted arrival: each replay lifted its own on the way out, and this is
+ /// the defensive clear for an id whose replay never came because the shell
+ /// exited mid-transfer.
+ fn clear_suppression(&self, ids: &[String]) {
+ let mut routing = guard(&self.routing);
+ for id in ids {
+ routing.awaiting_replay.remove(id);
+ }
+ self.suppressed
+ .store(routing.awaiting_replay.len(), Ordering::Relaxed);
+ }
+
+ /// Forget a window: its ownership, its outstanding `dor` requests and its
+ /// focus entry. Returns the arrivals it can no longer take — **whose shells
+ /// are deliberately not in the second half** — and the ids it owned outright,
+ /// which the caller reaps.
+ fn drop_window(&self, label: &str) -> (Vec, Vec) {
+ // Taken first, and their ids dropped from `owners` before `owned_by`
+ // reads it: an arriving shell belongs to its source again, and reaping
+ // it here would kill a terminal the source is still showing.
+ let lost = routing::take_arrivals_to(&mut guard(&self.arrivals), label);
+ let owned = {
+ let mut routing = guard(&self.routing);
+ for id in lost.iter().flat_map(|arrival| &arrival.terminal_ids) {
+ routing.owners.remove(id);
+ routing.awaiting_replay.remove(id);
+ }
+ let owned = routing.owned_by(label);
+ for id in &owned {
+ routing.owners.remove(id);
+ }
+ // Its answers can never arrive, so neither can the cancels that
+ // would have retired them.
+ routing.dor_targets.retain(|_, target| target != label);
+ self.suppressed
+ .store(routing.awaiting_replay.len(), Ordering::Relaxed);
+ owned
+ };
+ guard(&self.focus_order).retain(|entry| entry != label);
+ (lost, 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());
+ }
+
+ /// The most recently focused window: where a sidecar event naming no window
+ /// is delivered (`Route::Focused`). The quit walk never reads focus — its
+ /// order is `quit_order`, `main` last and the rest unordered.
+ fn focused(&self) -> Option {
+ guard(&self.focus_order).first().cloned()
+ }
+}
+
+/// Where one sidecar line goes, owning its label so the routing lock can be
+/// released before anything is serialized or emitted.
+enum Delivery {
+ Nowhere,
+ Broadcast,
+ To(String),
+ UnownedSurface { request_id: String, surface_id: String },
+}
+
+/// Route one sidecar stdout line to the window it belongs to.
+///
+/// The hot path — once per PTY chunk — so it takes the routing lock once, reads
+/// no clock unless something is actually mid-transfer, and copies only the one
+/// label it needs.
+///
+/// **Never hold the routing lock across an emit.** Serializing the payload and
+/// queueing it are unbounded work with the main thread possibly parked in
+/// `pty_spawn` waiting for this very lock, and Tauri's `tracing` feature swaps
+/// the emit for one that blocks on a main-thread reply — which would deadlock.
+fn dispatch_sidecar_event(app: &AppHandle, event: &str, data: JsonValue) {
+ let Some(state) = app.try_state::() else {
+ let _ = app.emit(event, data);
+ return;
+ };
+
+ let mut released: Vec = Vec::new();
+ let delivery = {
+ // Before the routing lock, never inside it (§`arrivals`). Nothing is
+ // transferring in the steady state, so this second acquisition is paid
+ // only while something is.
+ let arriving = if state.suppressed.load(Ordering::Relaxed) > 0 {
+ routing::arrival_ids(&guard(&state.arrivals))
+ } else {
+ HashSet::new()
+ };
+ let mut routing = guard(&state.routing);
+ if state.suppressed.load(Ordering::Relaxed) > 0 {
+ released = routing::sweep_awaiting(
+ &mut routing.awaiting_replay,
+ Instant::now(),
+ routing::AWAITING_REPLAY_MAX,
+ &arriving,
+ );
+ if !released.is_empty() {
+ state
+ .suppressed
+ .store(routing.awaiting_replay.len(), Ordering::Relaxed);
+ }
+ }
+
+ match routing::route(
+ event,
+ &data,
+ &RouteView {
+ owners: &routing.owners,
+ awaiting_replay: &routing.awaiting_replay,
+ dor_targets: &routing.dor_targets,
+ },
+ ) {
+ Route::Drop => Delivery::Nowhere,
+ Route::Broadcast => Delivery::Broadcast,
+ Route::EmitTo(label) => Delivery::To(label.to_string()),
+ // Resolved here, where the focus order is a sibling of the map the
+ // table read; the lock over it is separate and taken for one clone.
+ Route::Focused => match state.focused() {
+ Some(label) => Delivery::To(label),
+ None => Delivery::Broadcast,
+ },
+ Route::UnownedSurface {
+ request_id,
+ surface_id,
+ } => Delivery::UnownedSurface {
+ request_id: request_id.to_string(),
+ surface_id: surface_id.to_string(),
+ },
+ }
+ };
+
+ let mut delivered: Option<&str> = None;
+ match &delivery {
+ Delivery::Nowhere => {}
+ Delivery::Broadcast => {
+ let _ = app.emit(event, &data);
+ }
+ Delivery::To(label) => {
+ delivered = Some(label.as_str());
+ let _ = app.emit_to(label.as_str(), event, &data);
+ }
+ Delivery::UnownedSurface {
+ request_id,
+ surface_id,
+ } => {
+ // Never a sibling window: acting on the wrong terminal is worse
+ // than failing (docs/specs/dor-cli.md → "Standalone").
+ 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. Only these four events
+ // pay a second acquisition; a PTY chunk takes the lock once and is done.
+ let id = || data.get("id").and_then(JsonValue::as_str);
+ let request_id = || data.get("requestId").and_then(JsonValue::as_str);
+ match event {
+ "pty:exit" => {
+ if let Some(id) = id() {
+ state.forget_pty(id);
+ }
+ }
+ "pty:replay" => {
+ if let Some(id) = id() {
+ let mut routing = guard(&state.routing);
+ routing.awaiting_replay.remove(id);
+ state
+ .suppressed
+ .store(routing.awaiting_replay.len(), Ordering::Relaxed);
+ }
+ }
+ "dor:controlRequest" => {
+ if let (Some(label), Some(request_id)) = (delivered, request_id()) {
+ guard(&state.routing)
+ .dor_targets
+ .insert(request_id.to_string(), label.to_string());
+ }
+ }
+ "dor:controlCancel" => {
+ if let Some(request_id) = request_id() {
+ guard(&state.routing).dor_targets.remove(request_id);
+ }
+ }
+ // The collector settles on having heard from every window the ask
+ // reached, and only Rust knows this one reached exactly its Surface's
+ // owner (docs/specs/standalone.md -> "Burrow service").
+ "burrow:ask" => {
+ if let (Some(label), Some(sidecar)) =
+ (delivered, app.try_state::())
+ {
+ if let Some(burrow_request_id) =
+ data.get("burrowRequestId").and_then(JsonValue::as_str)
+ {
+ send_to_sidecar(
+ &sidecar,
+ serde_json::json!({
+ "event": "burrow:askDelivered",
+ "data": { "burrowRequestId": burrow_request_id, "windows": [label] },
+ })
+ .to_string(),
+ );
+ }
+ }
+ }
+ _ => {}
+ }
+
+ // Logged outside the lock: a chatty log write must never sit in front of the
+ // next PTY chunk's routing decision.
+ for id in released {
+ append_log(format!(
+ "[window] suppression for {id} expired with no arrival claiming it; releasing"
+ ));
+ }
+}
+
+/// Tell the sidecar's Burrow which webviews will answer an ask
+/// (docs/specs/standalone.md §Burrow service). Labels, not a count: the
+/// collector settles on having heard from each named window, so it can tell a
+/// window that closed mid-fan-out from one that answered twice.
+fn send_window_labels(app: &AppHandle) {
+ let Some(state) = app.try_state::() else {
+ return;
+ };
+ let labels = window_labels(app);
+ send_to_sidecar(
+ &state,
+ serde_json::json!({ "event": "burrow:windows", "data": { "labels": labels } }).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
@@ -87,86 +449,232 @@ const QUIT_ACK_TIMEOUT_MS: u64 = 2_000;
// `lib/src/lib/mirrored-constants.test.ts`.
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 => {
+ // 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", ());
+ }
+ QuitAction::Teardown { label, last } => {
+ let _ = app.emit_to(
+ label.as_str(),
+ "dormouse://quit-teardown",
+ serde_json::json!({ "last": last }),
+ );
+ }
+ QuitAction::Destroy { label } => {
+ // The snapshot stays on disk — that is what separates a quit
+ // from a per-window close. Ownership and the sidecar's window
+ // list are settled by the `Destroyed` arm.
+ if let Some(window) = app.get_webview_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);
+ append_log(format!("[quit] requested across {labels:?}"));
+ 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;
+ };
+ append_log(format!("[window] close requested for {label}"));
+ 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: take the window's snapshot off disk and
+/// destroy it. Called from `close_window`, and from the ack watchdog when the
+/// webview never answered.
+///
+/// The rest — forgetting its PTYs, telling the quit machine, telling the
+/// sidecar's Burrow — happens in the `Destroyed` arm, which is the first moment
+/// Tauri has actually taken the label out of `webview_windows()`.
+fn finish_window_close(app: &AppHandle, label: &str) {
+ append_log(format!("[window] closing {label} and removing its snapshot"));
+ if let Some(state) = app.try_state::() {
+ guard(&state.close).clear(label);
+ }
+ if let Some(state) = app.try_state::() {
+ // Before the removal, not after: a save already in flight from this
+ // webview would otherwise put the snapshot back. Reached from the
+ // watchdog too, where the webview never called `remove_window_session`.
+ state.begin_closing(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();
+ }
+}
+
+/// SIGTERM the PTYs a window left behind.
+///
+/// Reached whenever a window goes away still owning shells — the close
+/// ack-timeout path ran no teardown at all, and a teardown that overran its
+/// budget can leave stragglers. Unowned output routes nowhere
+/// (`routing::owner`), so without this they would run on invisibly.
+fn reap_orphaned_ptys(app: &AppHandle, label: &str, ids: Vec) {
+ if ids.is_empty() {
+ return;
+ }
+ let Some(sidecar) = app.try_state::() else {
+ return;
+ };
+ append_log(format!(
+ "[window] {label} left {} PTY(s) with no owner; killing them",
+ ids.len()
+ ));
+ send_to_sidecar(
+ &sidecar,
+ serde_json::json!({
+ "event": "pty:gracefulKill",
+ "data": { "ids": ids, "timeout": 2000 },
+ })
+ .to_string(),
+ );
+}
+
const LOG_FILE_ENV: &str = "DORMOUSE_LOG_FILE";
fn log_timestamp() -> u64 {
@@ -371,8 +879,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 }
@@ -411,7 +928,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) {
+ windows.forget_pty(&id);
let msg = serde_json::json!({
"event": "pty:kill",
"data": { "id": id }
@@ -419,9 +937,38 @@ 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).
+///
+/// **Excludes every id an in-flight arrival claims.** Ownership moves the
+/// instant the source invokes, so a window booting with a Workspace already
+/// queued for it would otherwise list those shells here and place them as
+/// top-level panes — beside the Workspace the arrival is about to mount them
+/// into. They come through `adopt_ready`, and only there.
+///
+/// `request_id` is the asking collector's own token, echoed on the answer:
+/// one window can have two collections outstanding (a boot and an arrival), and
+/// neither may finish on the other's list (docs/specs/transport.md §Reconnection).
#[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>,
+ request_id: Option,
+) {
+ let ids = routing::boot_list_ids(
+ windows.owned_by(window.label()),
+ &guard(&windows.arrivals),
+ );
+ let msg = serde_json::json!({
+ "event": "pty:requestInit",
+ "data": {
+ "forWindow": window.label(),
+ "ids": ids,
+ "requestId": request_id,
+ },
+ });
send_to_sidecar(&state, msg.to_string());
}
@@ -430,7 +977,20 @@ fn pty_request_init(state: tauri::State<'_, SidecarState>) {
// has no reason to know, so the payload rides through opaquely. Replies come
// back on the sidecar's own stdout events, not from this invoke.
#[tauri::command]
-fn burrow_command(state: tauri::State<'_, SidecarState>, payload: JsonValue) {
+fn burrow_command(
+ window: tauri::Window,
+ state: tauri::State<'_, SidecarState>,
+ mut payload: JsonValue,
+) {
+ // The one field Rust adds: which webview this came from. An ask fans out to
+ // every window and settles on having heard from each, and a webview cannot
+ // name itself to the Burrow (§Burrow service).
+ if let Some(command) = payload.as_object_mut() {
+ command.insert(
+ "window".to_string(),
+ JsonValue::String(window.label().to_string()),
+ );
+ }
let msg = serde_json::json!({
"event": "burrow:command",
"data": payload,
@@ -438,8 +998,28 @@ fn burrow_command(state: tauri::State<'_, SidecarState>, payload: JsonValue) {
send_to_sidecar(&state, msg.to_string());
}
+// The two app-global alert stores live in the sidecar so N windows share one
+// answer (docs/specs/alert.md -> "Alarm settings"). One opaque passthrough, like
+// `burrow_command`: the payload names its own op, and the shape belongs to
+// `lib/src/host/alert-store-host.ts` at the other end. The canonical snapshot
+// comes back as a broadcast `alert:settings` / `alert:watchedCommands`.
#[tauri::command]
-fn dor_control_response(state: tauri::State<'_, SidecarState>, response: DorControlResponse) {
+fn alert_command(state: tauri::State<'_, SidecarState>, payload: JsonValue) {
+ let msg = serde_json::json!({ "event": "alert:command", "data": payload });
+ send_to_sidecar(&state, msg.to_string());
+}
+
+#[tauri::command]
+fn dor_control_response(
+ state: tauri::State<'_, SidecarState>,
+ windows: tauri::State<'_, WindowState>,
+ response: DorControlResponse,
+) {
+ // The request is answered, so nothing is left for a cancel to reach
+ // (§Routing).
+ guard(&windows.routing)
+ .dor_targets
+ .remove(&response.request_id);
let msg = serde_json::json!({
"event": "dor:controlResponse",
"data": response,
@@ -511,14 +1091,24 @@ fn pty_get_open_ports(
// in `capture_agent_recovery` is `SIDECAR_ROUND_TRIP_MARGIN_MS` in
// `standalone/src/quit.ts` — pinned by `lib/src/lib/mirrored-constants.test.ts`.
#[tauri::command]
-async fn pty_graceful_kill_all(
+async fn pty_graceful_kill(
+ window: tauri::Window,
state: tauri::State<'_, SidecarState>,
+ windows: tauri::State<'_, WindowState>,
timeout: u64,
) -> Result<(), String> {
+ // Minus every id an arrival claims: ownership moves at the source's invoke,
+ // so those shells are still shown by the window that sent them
+ // (`pty_request_init` filters the same set). Bound here so the arrivals
+ // guard is released before the blocking round trip below.
+ let ids = routing::boot_list_ids(
+ windows.owned_by(window.label()),
+ &guard(&windows.arrivals),
+ );
request_from_sidecar_timeout(
&state,
- "pty:gracefulKillAll",
- serde_json::json!({ "timeout": timeout }),
+ "pty:gracefulKill",
+ serde_json::json!({ "ids": ids, "timeout": timeout }),
Duration::from_millis(timeout + 1500),
)?;
Ok(())
@@ -537,16 +1127,26 @@ 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>,
- ids: Option>,
+ windows: tauri::State<'_, WindowState>,
timeout: u64,
) -> Result<(), String> {
+ // This window's own PTYs, and only those: 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. An arriving Workspace's shells are the
+ // source's until it adopts them, and Ctrl-C there would hit agents the
+ // source is still showing (`pty_graceful_kill` filters the same set).
+ let ids = routing::boot_list_ids(
+ windows.owned_by(window.label()),
+ &guard(&windows.arrivals),
+ );
request_from_sidecar_timeout(
&state,
"pty:captureRecovery",
serde_json::json!({ "ids": ids, "timeout": timeout }),
// Margin for the round trip beyond the sidecar's own ceiling; the same
- // one `pty_graceful_kill_all` adds (see its comment for the pin).
+ // one `pty_graceful_kill` adds (see its comment for the pin).
Duration::from_millis(timeout + 1500),
)?;
Ok(())
@@ -1107,6 +1707,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 windows.refuses_save(window.label()) {
+ return Ok(());
+ }
+ }
write_session_to(&sessions_dir(window.app_handle())?, window.label(), &state)
}
@@ -1155,6 +1763,244 @@ 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;
+
+/// One window's outer box in **physical** pixels, plus the scale that turns it
+/// logical. Kept live from the `Moved` / `Resized` payloads, so neither the
+/// debounced write nor the drag hit test has to ask the platform per event.
+#[derive(Clone, Copy, Debug, PartialEq)]
+struct CachedRect {
+ origin: (i32, i32),
+ size: (u32, u32),
+ scale: f64,
+}
+
+impl CachedRect {
+ /// Fold in a window event, which carries only the half that changed.
+ fn apply(&mut self, origin: Option<(i32, i32)>, size: Option<(u32, u32)>) {
+ if let Some(origin) = origin {
+ self.origin = origin;
+ }
+ if let Some(size) = size {
+ self.size = size;
+ }
+ }
+
+ /// The box a snapshot stores. Logical, not physical: one taken on one
+ /// display must reopen sensibly on another with a different scale factor.
+ fn to_logical(self) -> WindowGeometry {
+ WindowGeometry {
+ x: f64::from(self.origin.0) / self.scale,
+ y: f64::from(self.origin.1) / self.scale,
+ width: f64::from(self.size.0) / self.scale,
+ height: f64::from(self.size.1) / self.scale,
+ }
+ }
+
+ /// How the cross-window drag hit test sees this window.
+ fn hit_rect(self, label: &str, hittable: bool) -> routing::WindowRect {
+ routing::WindowRect {
+ label: label.to_string(),
+ origin: self.origin,
+ size: self.size,
+ scale: self.scale,
+ hittable,
+ }
+ }
+}
+
+/// What the debounce thread owes, behind one lock.
+///
+/// The two are always read together, and the atomicity is the point: the flush
+/// slot released *after* the dirty set was taken leaves a window whose `Moved`
+/// landed in between marked dirty with no thread left to write it — and that
+/// window is exactly one whose last move was its final position.
+#[derive(Default)]
+struct GeometryFlush {
+ /// Labels whose cached rect has not reached disk yet.
+ dirty: HashSet,
+ /// Whether a debounce thread is already going to drain `dirty`.
+ flushing: bool,
+}
+
+/// Each window's outer box, plus what the debounce thread owes.
+///
+/// **Never call a platform query while holding `rects`.** Off the main thread
+/// `scale_factor()` and `is_minimized()` block on the event loop, and the main
+/// thread may be inside `window_at_cursor` waiting for this very lock. The flush
+/// reads those values first and hands them to `refresh_rect`, which takes no
+/// window at all so the rule cannot be broken by accident.
+#[derive(Default)]
+struct GeometryState {
+ rects: Mutex>,
+ flush: Mutex,
+}
+
+impl GeometryState {
+ /// Mark `label` dirty. `true` when the caller owes a debounce thread.
+ fn mark_dirty(&self, label: &str) -> bool {
+ let mut flush = guard(&self.flush);
+ flush.dirty.insert(label.to_string());
+ !std::mem::replace(&mut flush.flushing, true)
+ }
+
+ /// Everything pending, releasing the flush slot in the same step.
+ fn take_dirty(&self) -> HashSet {
+ let mut flush = guard(&self.flush);
+ flush.flushing = false;
+ std::mem::take(&mut flush.dirty)
+ }
+
+ /// Fold a platform-read scale into the cached box and hand back a copy.
+ /// Takes the value rather than the window: nothing may ask the platform
+ /// anything while this lock is held.
+ fn refresh_rect(&self, label: &str, scale: Option) -> Option {
+ let mut rects = guard(&self.rects);
+ let rect = rects.get_mut(label)?;
+ if let Some(scale) = scale {
+ rect.scale = scale;
+ }
+ Some(*rect)
+ }
+
+ /// A window went away: its box and any pending write go with it.
+ fn forget(&self, label: &str) {
+ guard(&self.rects).remove(label);
+ guard(&self.flush).dirty.remove(label);
+ }
+}
+
+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()
+}
+
+/// Seed the cache from the platform. Once per window, at creation: from there
+/// the `Moved` / `Resized` payloads carry the new box themselves.
+fn seed_geometry(app: &AppHandle, label: &str) {
+ let (Some(state), Some(window)) = (
+ app.try_state::(),
+ app.get_webview_window(label),
+ ) else {
+ return;
+ };
+ let (Ok(position), Ok(size)) = (window.outer_position(), window.outer_size()) else {
+ return;
+ };
+ // Every platform read before the lock (`GeometryState`).
+ let scale = window.scale_factor().unwrap_or(1.0);
+ guard(&state.rects).insert(
+ label.to_string(),
+ CachedRect {
+ origin: (position.x, position.y),
+ size: (size.width, size.height),
+ scale,
+ },
+ );
+}
+
+/// Update the cached box from a window event and schedule the debounced write.
+/// The event carries the new value, so this costs no platform round trip; the
+/// minimized and scale checks belong to the flush, which runs once per window
+/// per debounce window rather than once per frame of a drag.
+fn note_geometry(app: &AppHandle, label: &str, origin: Option<(i32, i32)>, size: Option<(u32, u32)>) {
+ let Some(state) = app.try_state::() else {
+ return;
+ };
+ {
+ let mut rects = guard(&state.rects);
+ let Some(rect) = rects.get_mut(label) else {
+ // No seed means no window we know of; a `Destroyed` racing the last
+ // `Moved` is the ordinary way here.
+ return;
+ };
+ rect.apply(origin, size);
+ }
+ if !state.mark_dirty(label) {
+ 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 dirty = state.take_dirty();
+ let Ok(dir) = sessions_dir(&app) else { return };
+ for label in dirty {
+ // A window that closed inside the debounce took its geometry file
+ // with it; do not resurrect one for it.
+ let Some(window) = app.get_webview_window(&label) else {
+ continue;
+ };
+ // Both platform reads happen here, before the lock: this thread is
+ // not the main one, so each of them parks on the event loop
+ // (`GeometryState`). A minimized window reports a nonsense box on
+ // some platforms; keep the last real one instead.
+ if window.is_minimized().unwrap_or(false) {
+ continue;
+ }
+ // Refreshed here, and only here: the drag hit test reads the cache
+ // between flushes, and a `Moved` is what follows a window crossing
+ // onto a display with a different scale factor.
+ let scale = window.scale_factor().ok();
+ let Some(rect) = state.refresh_rect(&label, scale) else {
+ continue;
+ };
+ let Ok(json) = serde_json::to_string(&rect.to_logical()) 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`
@@ -1391,44 +2237,572 @@ 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;
+ // The only platform read of this window's box: from here the `Moved` /
+ // `Resized` payloads keep the cache current (§Boot and geometry).
+ seed_geometry(app, label);
+ 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)
+ )
+}
+
+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()
+}
+
+/// The record one drop becomes.
+fn arrival_from(from: &str, to: &str, payload: JsonValue) -> Result {
+ let workspace_id = payload
+ .get("workspaceId")
+ .and_then(JsonValue::as_str)
+ .ok_or_else(|| "a transfer payload must name its workspaceId".to_string())?
+ .to_string();
+ let terminal_ids = payload_terminal_ids(&payload);
+ Ok(routing::Arrival {
+ workspace_id,
+ from: from.to_string(),
+ to: to.to_string(),
+ terminal_ids,
+ payload,
+ queued_at: Instant::now(),
+ })
+}
+
+/// Open one arrival: reassign its shells to the target and suppress them, then
+/// queue the record. **Ownership moves 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).
+fn begin_arrival(
+ app: &AppHandle,
+ windows: &WindowState,
+ arrival: routing::Arrival,
+) -> Result<(), String> {
+ {
+ let mut arrivals = guard(&windows.arrivals);
+ if routing::has_arrival(&arrivals, &arrival.workspace_id) {
+ return Err(format!(
+ "Workspace '{}' is already in flight",
+ arrival.workspace_id
+ ));
+ }
+ windows.reassign(&arrival.terminal_ids, &arrival.to, true);
+ routing::queue_arrival(&mut arrivals, arrival.clone());
+ }
+ spawn_arrival_watchdog(app.clone(), &arrival);
+ Ok(())
+}
+
+/// Bound an arrival: a target that never settles it — alive but wedged, so
+/// `Destroyed` never hands it back either — would leave the Workspace marked
+/// transferring in the source and its shells silent for good. Past
+/// `ARRIVAL_MAX` the record is retired and handed back like any refusal.
+fn spawn_arrival_watchdog(app: AppHandle, arrival: &routing::Arrival) {
+ let workspace_id = arrival.workspace_id.clone();
+ let to = arrival.to.clone();
+ let queued_at = arrival.queued_at;
+ std::thread::spawn(move || {
+ std::thread::sleep(routing::ARRIVAL_MAX);
+ let Some(windows) = app.try_state::() else {
+ return;
+ };
+ let expired = routing::expire_arrival(
+ &mut guard(&windows.arrivals),
+ &workspace_id,
+ &to,
+ queued_at,
+ );
+ if let Some(arrival) = expired {
+ hand_back_arrival(&app, &windows, &arrival, "the target never adopted it");
+ }
+ });
+}
+
+/// One arrival will never be adopted: give its shells back to the source,
+/// unsuppressed, and tell the source so it clears the Workspace's transferring
+/// mark. **The Workspace simply stays where it is** — nothing was released, so
+/// there is nothing to put back.
+///
+/// The record must already be out of the queue; the caller took it.
+fn hand_back_arrival(
+ app: &AppHandle,
+ windows: &WindowState,
+ arrival: &routing::Arrival,
+ reason: &str,
+) {
+ append_log(format!(
+ "[window] {} never arrived in {} ({reason}); handing it back to {}",
+ arrival.workspace_id, arrival.to, arrival.from
+ ));
+ if app.get_webview_window(&arrival.from).is_some() {
+ windows.reassign(&arrival.terminal_ids, &arrival.from, false);
+ let _ = app.emit_to(
+ arrival.from.as_str(),
+ "dormouse://workspace-arrival-failed",
+ serde_json::json!({ "workspaceId": arrival.workspace_id, "reason": reason }),
+ );
+ return;
+ }
+ // Both ends are gone, so these shells belong to no window and nothing would
+ // ever paint them (`routing::owner`).
+ for id in &arrival.terminal_ids {
+ windows.forget_pty(id);
+ }
+ reap_orphaned_ptys(app, &arrival.from, arrival.terminal_ids.clone());
+}
+
+/// Tear a Workspace out into a brand-new window under the cursor.
+///
+/// The payload is *queued*, never emitted: an `emit_to` a window that does not
+/// exist yet is lost, so the new webview drains it with `take_arrivals` during
+/// its own boot (docs/specs/standalone.md §Arrival queue).
+#[tauri::command]
+fn open_workspace_window(
+ app: AppHandle,
+ window: tauri::Window,
+ windows: tauri::State<'_, WindowState>,
+ payload: JsonValue,
+) -> Result {
+ let label = next_window_label(&windows);
+ // 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 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,
+ }),
+ _ => None,
+ }
+ };
+ let arrival = arrival_from(window.label(), &label, payload)?;
+ // The one thing needed after the record is queued, so the payload itself is
+ // moved rather than cloned.
+ let workspace_id = arrival.workspace_id.clone();
+ append_log(format!("[window] tearing {workspace_id} out into {label}"));
+ begin_arrival(&app, &windows, arrival)?;
+ if let Err(err) = build_window(&app, &label, geometry) {
+ // Nothing will ever drain the queue, and the PTYs would stay suppressed
+ // and ownerless. The source is waiting on this `Err` and has released
+ // nothing, so the ids go back in silence — no `arrival-failed`, which
+ // would clear a transferring mark that was never set.
+ if let Some(arrival) =
+ routing::take_arrival(&mut guard(&windows.arrivals), &workspace_id, &label)
+ {
+ windows.reassign(&arrival.terminal_ids, &arrival.from, false);
+ }
+ return Err(err);
+ }
+ send_window_labels(&app);
+ Ok(label)
+}
+
+/// Move a Workspace into a window that already exists.
+#[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());
+ }
+ let arrival = arrival_from(window.label(), &to, payload)?;
+ append_log(format!(
+ "[window] transferring {} from {} to {to}",
+ arrival.workspace_id,
+ window.label()
+ ));
+ // Queued, not emitted: the target may be booting, or torn out moments ago,
+ // and have no listener yet — and it is a legal drop target either way
+ // (docs/specs/standalone.md §Arrival queue).
+ begin_arrival(&app, &windows, arrival)?;
+ // 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();
+ }
+ // A nudge, carrying nothing: the payload is in the queue, and a window with
+ // no listener yet finds it there.
+ let _ = app.emit_to(to.as_str(), "dormouse://workspace-arriving", ());
+ Ok(())
+}
+
+/// The target has armed its collector for one arrival; ask the sidecar to list
+/// and replay **exactly that arrival's** PTYs. This hop is what removes the
+/// whole "arrived before armed" bug class.
+///
+/// **Never "everything suppressed for this window".** Two Workspaces can be in
+/// flight into one window at once — a tear-out with a second tab dropped on it
+/// moments later — and a window-wide answer would let each collector finish on
+/// the other's shells, resuming a Workspace over panes that belong to its
+/// neighbour.
+///
+/// **Always answers**, even with no ids at all: the collector waits on its own
+/// `pty:list`, and a Workspace of browser panes alone would otherwise sit out
+/// its whole timeout. An empty `ids` is an empty list, never everything
+/// (`list` in `standalone/sidecar/pty-core.js`).
+#[tauri::command]
+fn adopt_ready(
+ window: tauri::Window,
+ state: tauri::State<'_, SidecarState>,
+ windows: tauri::State<'_, WindowState>,
+ workspace_id: String,
+ request_id: Option,
+) -> Result<(), String> {
+ let label = window.label();
+ let ids = {
+ let arrivals = guard(&windows.arrivals);
+ let arrival = routing::find_arrival(&arrivals, &workspace_id)
+ .filter(|arrival| arrival.to == label)
+ .ok_or_else(|| format!("no arrival of '{workspace_id}' into {label}"))?;
+ arrival.terminal_ids.clone()
+ };
+ let msg = serde_json::json!({
+ "event": "pty:requestInit",
+ "data": { "forWindow": label, "ids": ids, "requestId": request_id },
+ });
+ send_to_sidecar(&state, msg.to_string());
+ Ok(())
+}
+
+/// The target has mounted the Workspace. Retire the record, drop what is left of
+/// its suppression, and **only now** tell the source it may commit.
+///
+/// One Workspace, one message: a source with two Workspaces in flight into the
+/// same window must not lose both because one of them landed.
+#[tauri::command]
+fn adopt_done(
+ app: AppHandle,
+ window: tauri::Window,
+ windows: tauri::State<'_, WindowState>,
+ workspace_id: String,
+) -> Result<(), String> {
+ let arrival = routing::take_arrival(
+ &mut guard(&windows.arrivals),
+ &workspace_id,
+ window.label(),
+ )
+ .ok_or_else(|| format!("no arrival of '{workspace_id}' into {}", window.label()))?;
+ windows.clear_suppression(&arrival.terminal_ids);
+ append_log(format!(
+ "[window] {workspace_id} adopted by {}; telling {}",
+ arrival.to, arrival.from
+ ));
+ let _ = app.emit_to(
+ arrival.from.as_str(),
+ "dormouse://workspace-departed",
+ serde_json::json!({ "workspaceId": arrival.workspace_id }),
+ );
+ Ok(())
+}
+
+/// The target refused the arrival — its PTYs never answered, or the mount threw.
+#[tauri::command]
+fn adopt_failed(
+ app: AppHandle,
+ window: tauri::Window,
+ windows: tauri::State<'_, WindowState>,
+ workspace_id: String,
+ reason: Option,
+) -> Result<(), String> {
+ let arrival = routing::take_arrival(
+ &mut guard(&windows.arrivals),
+ &workspace_id,
+ window.label(),
+ )
+ .ok_or_else(|| format!("no arrival of '{workspace_id}' into {}", window.label()))?;
+ hand_back_arrival(
+ &app,
+ &windows,
+ &arrival,
+ reason.as_deref().unwrap_or("the target refused it"),
+ );
+ Ok(())
+}
+
+/// Every Workspace in flight into this window, oldest first.
+///
+/// **Not consumed**: the record settles at `adopt_done`, so a webview that
+/// drains at boot and again when its listener is installed sees only what it has
+/// yet to adopt. The webview dedupes what it is already mounting.
+#[tauri::command]
+fn take_arrivals(window: tauri::Window, windows: tauri::State<'_, WindowState>) -> Vec {
+ routing::arrival_payloads(&guard(&windows.arrivals), window.label())
+}
+
+/// 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::() {
+ windows.begin_closing(window.label());
+ }
+ remove_session_from(&sessions_dir(app)?, window.label())
+}
+
+/// Which window is under the cursor, in that window's own logical client space.
+///
+/// Runs off the geometry cache (§Boot and geometry) rather than re-asking the
+/// platform for four numbers per window: a drag probes this ~16 times a second.
+/// Visibility is not cached, being the one part no window event carries
+/// reliably.
+///
+/// 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>,
+ geometry: tauri::State<'_, GeometryState>,
+) -> Option {
+ let point = app.cursor_position().ok()?;
+ // Copied out first: the visibility queries below reach the platform, and
+ // nothing may ask it anything while `rects` is held (`GeometryState`).
+ let cached: Vec<(String, CachedRect)> = guard(&geometry.rects)
+ .iter()
+ .map(|(label, rect)| (label.clone(), *rect))
+ .collect();
+ let rects: Vec = cached
+ .into_iter()
+ .filter_map(|(label, rect)| {
+ let window = app.get_webview_window(&label)?;
+ let hittable =
+ window.is_visible().unwrap_or(true) && !window.is_minimized().unwrap_or(false);
+ Some(rect.hit_rect(&label, hittable))
+ })
+ .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());
}
-// 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.
+// 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_progress(state: tauri::State<'_, QuitState>) {
- state.tearing_down.store(true, Ordering::SeqCst);
- state.progress.fetch_add(1, Ordering::SeqCst);
+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);
}
-// 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).
+// 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_cancel(state: tauri::State<'_, QuitState>) {
- state.seq.fetch_add(1, Ordering::SeqCst);
+fn quit_progress(window: tauri::Window, state: tauri::State<'_, QuitState>) {
+ guard(&state.machine).progress(window.label());
}
-// 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.
+// 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_cancel(app: AppHandle, state: tauri::State<'_, QuitState>) {
+ let actions = guard(&state.machine).cancel();
+ apply_quit_actions(&app, actions);
+}
+
+// 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_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);
+}
+
+// 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());
+}
+
+// This window is done with itself: its close orchestrator archived, removed the
+// snapshot and killed its PTYs, or its last Workspace moved away and there was
+// nothing to end at all. Rust's half is the same either way; what separates the
+// two is what the webview did first, so the intent lives at the call sites
+// (standalone/src/window-close.ts, standalone/src/workspace-move.ts).
+#[tauri::command]
+fn close_window(app: AppHandle, window: tauri::Window) {
+ finish_window_close(&app, window.label());
}
// Normal app quit should let the Node sidecar run its shutdown handler first:
@@ -1862,7 +3236,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);
}
});
@@ -1930,6 +3306,10 @@ fn start_sidecar(app: &AppHandle) -> Result {
// ── App entry point ─────────────────────────────────────────────────────────
+/// The app menu's Quit item, matched in `on_menu_event`. Only the macOS menu
+/// carries one; nothing else can ever raise this id.
+const QUIT_MENU_ITEM_ID: &str = "dormouse-quit";
+
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
@@ -1961,7 +3341,18 @@ pub fn run() {
&PredefinedMenuItem::hide(handle, None)?,
&PredefinedMenuItem::hide_others(handle, None)?,
&PredefinedMenuItem::separator(handle)?,
- &PredefinedMenuItem::quit(handle, None)?,
+ // Never `PredefinedMenuItem::quit`: muda wires that straight
+ // to AppKit's `terminate:`, which ends the process without
+ // ever raising `ExitRequested`. A custom item routes the
+ // menu and its Cmd+Q through `request_quit` like every other
+ // trigger (docs/specs/standalone.md §Trigger interception).
+ &MenuItem::with_id(
+ handle,
+ QUIT_MENU_ITEM_ID,
+ "Quit Dormouse Terminal",
+ true,
+ Some("CmdOrCtrl+Q"),
+ )?,
],
)?));
items.push(Box::new(Submenu::with_items(
@@ -1979,31 +3370,123 @@ 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 }));
+ .on_menu_event(|app, event| {
+ if event.id() == QUIT_MENU_ITEM_ID {
+ request_quit(app);
}
- // 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) {
+ })
+ .on_window_event(|window, event| {
+ 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());
+ }
+ }
+ // The payload is the new box, so nothing is asked of the
+ // platform here (§Boot and geometry).
+ WindowEvent::Moved(position) => {
+ note_geometry(app, window.label(), Some((position.x, position.y)), None);
+ }
+ WindowEvent::Resized(size) => {
+ note_geometry(app, window.label(), None, Some((size.width, size.height)));
+ }
+ // 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, .. } => {
+ // The flow's own destroys do not come through here, so an
+ // approved or walking quit meeting a close request means the
+ // user pressed the button mid-teardown: refuse it, or the
+ // window goes away from under its own teardown and the walk
+ // waits out its budget on a dead label.
+ if quit_approved(app) {
+ return;
+ }
api.prevent_close();
- request_quit(app);
+ if quit_walking(app) {
+ return;
+ }
+ if app.webview_windows().len() > 1 {
+ request_window_close(app, window.label());
+ } else {
+ request_quit(app);
+ }
}
+ // The window is gone. Everything keyed by its label is settled
+ // here, and only here: this is the first moment Tauri has taken
+ // it out of `webview_windows()`.
+ WindowEvent::Destroyed => {
+ let label = window.label().to_string();
+ if let Some(state) = app.try_state::() {
+ // Shells it still owned belong to nobody now, and
+ // unowned output routes nowhere.
+ let (lost, orphaned) = state.drop_window(&label);
+ // The webview is gone, so no save can arrive under this
+ // label again and the refusal can go with it.
+ guard(&state.closing).remove(&label);
+ reap_orphaned_ptys(app, &label, orphaned);
+ // A Workspace on its way here can never arrive: its
+ // source still shows it, still holds its Sessions, and
+ // has released nothing (§Arrival queue).
+ for arrival in lost {
+ hand_back_arrival(
+ app,
+ &state,
+ &arrival,
+ "the target window closed mid-arrival",
+ );
+ }
+ }
+ if let Some(state) = app.try_state::() {
+ state.forget(&label);
+ }
+ if let Some(state) = app.try_state::() {
+ guard(&state.close).clear(&label);
+ // A window that left outside the flow can never vote or
+ // finish, so the quit advances past it rather than
+ // waiting out its budget. Bound before the call: the
+ // guard would otherwise still be held inside
+ // `apply_quit_actions`, which takes the same lock.
+ let actions = {
+ let mut machine = guard(&state.machine);
+ if machine.approved {
+ // Already exiting: these destroys are the exit's
+ // own, and nothing is left to advance.
+ Vec::new()
+ } else {
+ machine.forget_window(&label)
+ }
+ };
+ apply_quit_actions(app, actions);
+ }
+ // The Burrow's ask collector settles on having heard from
+ // every live window, so it must learn about this one only
+ // now that asking it would be impossible.
+ send_window_labels(app);
+ }
+ _ => {}
}
})
.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)
@@ -2038,11 +3521,34 @@ 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);
+ }
+ // `main` came up from the config, so nothing has seeded its cached
+ // box; the drag hit test reads that cache (§Boot and geometry).
+ seed_geometry(app.handle(), routing::MAIN_LABEL);
+ // The Burrow fans an ask out to every window and collects N answers.
+ send_window_labels(app.handle());
+
Ok(())
})
.invoke_handler(tauri::generate_handler![
@@ -2055,18 +3561,33 @@ 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,
pty_request_init,
dor_control_response,
burrow_command,
+ alert_command,
kill_sidecar_now,
quit_ack,
+ quit_vote,
quit_progress,
quit_cancel,
+ quit_window_done,
quit_proceed,
+ window_close_ack,
+ window_close_cancel,
+ close_window,
+ open_workspace_window,
+ transfer_workspace,
+ adopt_ready,
+ adopt_done,
+ adopt_failed,
+ take_arrivals,
+ remove_window_session,
+ window_at_cursor,
+ hover_workspace_target,
get_available_shells,
read_clipboard_file_paths,
read_clipboard_image_as_file_path,
@@ -2089,7 +3610,11 @@ pub fn run() {
.expect("error while building Dormouse")
.run(|app, event| match event {
#[cfg(target_os = "macos")]
- RunEvent::Ready => set_macos_dock_icon(),
+ RunEvent::Ready => {
+ set_macos_dock_icon();
+ // The delegate exists by now, which is what this splices onto.
+ macos_terminate::install(app);
+ }
// Cmd+Q / app-menu / dock quit / interceptable OS logout (§Quit flow).
// The flow's own app.exit(0) re-enters here with approved=true and
// passes; `code` (None = user-initiated) is deliberately ignored.
@@ -2120,8 +3645,11 @@ mod tests {
temp_write_path, write_notepad_archive_to, write_session_to, SESSION_TEMP_SUFFIX,
NOTEPAD_ARCHIVE_FILE,
};
+ use super::guard;
+ use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
+ use std::sync::atomic::Ordering;
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
@@ -2577,6 +4105,186 @@ 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()]
+ );
+ }
+
+ /// The cached box is fed by the window events alone, and it is what both the
+ /// debounced write and the cross-window drag read (§Boot and geometry).
+ #[test]
+ fn the_geometry_cache_folds_window_events_and_drives_the_hit_test() {
+ let mut rect = super::CachedRect {
+ origin: (0, 0),
+ size: (800, 600),
+ scale: 2.0,
+ };
+ // A `Moved` carries only the origin, a `Resized` only the size.
+ rect.apply(Some((100, 40)), None);
+ rect.apply(None, Some((1000, 700)));
+ assert_eq!(rect.origin, (100, 40));
+ assert_eq!(rect.size, (1000, 700));
+
+ // Stored logical, so the box reopens sensibly on another display.
+ let geometry = rect.to_logical();
+ assert_eq!(
+ (geometry.x, geometry.y, geometry.width, geometry.height),
+ (50.0, 20.0, 500.0, 350.0)
+ );
+
+ // The same cached rect is the hit test's input: a point inside the box
+ // it moved to hits, and the client-space answer is relative to it.
+ let hit = super::routing::window_at(
+ &[rect.hit_rect("ws-2", true)],
+ &["ws-2".to_string()],
+ (300.0, 240.0),
+ )
+ .expect("the moved window is under the cursor");
+ assert_eq!((hit.label.as_str(), hit.x, hit.y), ("ws-2", 100.0, 100.0));
+ // Outside the box it moved to, and inside the one it left.
+ assert_eq!(
+ super::routing::window_at(&[rect.hit_rect("ws-2", true)], &[], (10.0, 10.0)),
+ None
+ );
+ }
+
+ /// The debounce thread's own bookkeeping. The dirty set and the flush slot
+ /// move together, so a `Moved` arriving as the thread drains either rides
+ /// the drain it is racing or schedules the next one — never neither, which
+ /// is how a window's final position used to go unwritten.
+ #[test]
+ fn the_geometry_flush_slot_is_released_with_the_drain() {
+ let state = super::GeometryState::default();
+ // The first move owes a debounce thread; the ones behind it ride that
+ // same thread rather than spawning one apiece.
+ assert!(state.mark_dirty("main"));
+ assert!(!state.mark_dirty("ws-2"));
+ assert!(!state.mark_dirty("main"));
+
+ let drained = state.take_dirty();
+ assert_eq!(drained.len(), 2, "both windows are written: {drained:?}");
+ assert!(drained.contains("main") && drained.contains("ws-2"));
+
+ // Slot released: a later move owes a fresh thread. Taken apart from the
+ // drain, this is the write that carries a window's last position.
+ assert!(state.mark_dirty("ws-2"));
+ assert_eq!(
+ state.take_dirty(),
+ HashSet::from(["ws-2".to_string()]),
+ "only what was marked since the last drain"
+ );
+ // Draining nothing is not an error, and still leaves the slot free.
+ assert!(state.take_dirty().is_empty());
+ assert!(state.mark_dirty("main"));
+ }
+
+ /// The cached box is refreshed from a scale the caller has already read.
+ /// The signature is the rule: nothing can ask the platform anything while
+ /// the `rects` lock is held (`GeometryState`).
+ #[test]
+ fn refreshing_a_cached_rect_takes_the_scale_rather_than_the_window() {
+ let state = super::GeometryState::default();
+ guard(&state.rects).insert(
+ "ws-2".to_string(),
+ super::CachedRect {
+ origin: (100, 40),
+ size: (800, 600),
+ scale: 1.0,
+ },
+ );
+ // A window dragged onto a display with a different scale factor.
+ let rect = state.refresh_rect("ws-2", Some(2.0)).expect("cached");
+ assert_eq!(rect.scale, 2.0);
+ assert_eq!(rect.to_logical().width, 400.0);
+ // A platform that would not answer leaves the last known scale.
+ assert_eq!(state.refresh_rect("ws-2", None).unwrap().scale, 2.0);
+ // A window whose `Destroyed` beat the flush has no box to write.
+ assert!(state.refresh_rect("gone", Some(2.0)).is_none());
+ state.forget("ws-2");
+ assert!(state.refresh_rect("ws-2", Some(2.0)).is_none());
+ }
+
+ /// A deliberate close removes the snapshot, so every later save under that
+ /// label is refused — including one already in flight from the webview that
+ /// is going away (docs/specs/standalone.md -> "Per-window close"). Both close
+ /// paths set it: the webview's own `remove_window_session`, and
+ /// `finish_window_close` for the ack-timeout path where it never ran.
+ #[test]
+ fn a_closing_window_refuses_every_later_save_until_it_is_destroyed() {
+ let state = super::WindowState::default();
+ assert!(!state.refuses_save("ws-2"));
+ state.begin_closing("ws-2");
+ assert!(state.refuses_save("ws-2"));
+ // Never a sibling's.
+ assert!(!state.refuses_save("main"));
+ // `Destroyed` drops the refusal: no save can arrive under a dead label.
+ guard(&state.closing).remove("ws-2");
+ assert!(!state.refuses_save("ws-2"));
+ }
+
+ /// A spawn reusing a transferring id must not inherit its suppression: no
+ /// replay is coming for the new PTY, so it would paint nothing until the
+ /// fail-open sweep (`routing::AWAITING_REPLAY_MAX`).
+ #[test]
+ fn minting_a_pty_clears_a_stale_transfer_suppression() {
+ let state = super::WindowState::default();
+ state.reassign(&["pane-a".to_string()], "ws-2", true);
+ assert_eq!(state.suppressed.load(Ordering::Relaxed), 1);
+
+ state.mint("pane-a", "main");
+ assert!(guard(&state.routing).awaiting_replay.is_empty());
+ assert_eq!(state.suppressed.load(Ordering::Relaxed), 0);
+ assert_eq!(state.owned_by("main"), vec!["pane-a".to_string()]);
}
#[test]
diff --git a/standalone/src-tauri/src/macos_terminate.rs b/standalone/src-tauri/src/macos_terminate.rs
new file mode 100644
index 000000000..edff7c665
--- /dev/null
+++ b/standalone/src-tauri/src/macos_terminate.rs
@@ -0,0 +1,97 @@
+//! The macOS quit triggers nothing else catches.
+//!
+//! Tauri's `RunEvent::ExitRequested` is raised from tao's window handling, and
+//! tao's app delegate implements only `applicationWillTerminate:` — by which
+//! point AppKit has decided and nothing may refuse. So the Dock's Quit item, an
+//! `osascript` quit, and a logout or restart all end the process without the
+//! quit flow ever running: no confirmation, no agent-recovery capture, no final
+//! save (docs/specs/standalone.md -> "Trigger interception"; rationale).
+//!
+//! This splices `applicationShouldTerminate:` onto the live delegate's class, so
+//! those triggers land in `request_quit` like every other one. The app-menu item
+//! and its `Cmd+Q` accelerator are handled separately, by the custom menu item
+//! `lib.rs` builds in place of `PredefinedMenuItem::quit`.
+
+use objc2::runtime::{AnyClass, AnyObject, Imp, Sel};
+use objc2::{ffi, msg_send, sel, MainThreadMarker};
+use objc2_app_kit::{NSApplication, NSApplicationTerminateReply};
+use std::sync::OnceLock;
+use tauri::AppHandle;
+
+use crate::{append_log, quit_approved, request_quit};
+
+/// The handle the spliced method answers on behalf of. Set once, at `Ready`.
+static APP: OnceLock = OnceLock::new();
+
+/// `NSUInteger (*)(id, SEL, id)` — the encoding AppKit expects for
+/// `applicationShouldTerminate:`. Informational for a direct `objc_msgSend`,
+/// which is how AppKit calls this, but the runtime stores it and forwarding
+/// machinery reads it.
+const SHOULD_TERMINATE_TYPES: &[u8] = b"L@:@\0";
+
+/// Our `applicationShouldTerminate:`.
+///
+/// Answers `Cancel` and starts the flow the first time; the flow's own
+/// `app.exit(0)` comes back through here with the quit already approved, and
+/// that pass answers `Now`. Panic-free by construction: the release profile
+/// aborts on unwind, and this runs inside AppKit's stack.
+unsafe extern "C-unwind" fn should_terminate(
+ _this: *mut AnyObject,
+ _cmd: Sel,
+ _sender: *mut AnyObject,
+) -> NSApplicationTerminateReply {
+ let Some(app) = APP.get() else {
+ // Nothing is managed yet, so there is no session to lose.
+ return NSApplicationTerminateReply::TerminateNow;
+ };
+ if quit_approved(app) {
+ return NSApplicationTerminateReply::TerminateNow;
+ }
+ append_log("[quit] intercepted an AppKit terminate (Dock, logout, or script)");
+ request_quit(app);
+ NSApplicationTerminateReply::TerminateCancel
+}
+
+/// Install the interception. Call once, from `RunEvent::Ready`, where tao's
+/// delegate is already the application's.
+pub fn install(app: &AppHandle) {
+ if APP.set(app.clone()).is_err() {
+ return;
+ }
+ let Some(mtm) = MainThreadMarker::new() else {
+ append_log("[quit] terminate interception skipped: not on the main thread");
+ return;
+ };
+ let ns_app = NSApplication::sharedApplication(mtm);
+ let delegate: *mut AnyObject = unsafe { msg_send![&*ns_app, delegate] };
+ if delegate.is_null() {
+ append_log("[quit] terminate interception skipped: the app has no delegate");
+ return;
+ }
+ let selector = sel!(applicationShouldTerminate:);
+ let class = unsafe { ffi::object_getClass(delegate) } as *mut AnyClass;
+ if class.is_null() {
+ append_log("[quit] terminate interception skipped: the delegate has no class");
+ return;
+ }
+ // `class_addMethod` refuses when the class already implements the selector,
+ // which is the signal that a tao upgrade started handling this itself — at
+ // which point ours would be dead code rather than a second answer.
+ let imp: Imp = unsafe { std::mem::transmute(should_terminate as unsafe extern "C-unwind" fn(*mut AnyObject, Sel, *mut AnyObject) -> NSApplicationTerminateReply) };
+ let added = unsafe {
+ ffi::class_addMethod(
+ class,
+ selector,
+ imp,
+ SHOULD_TERMINATE_TYPES.as_ptr().cast(),
+ )
+ };
+ if added.as_bool() {
+ append_log("[quit] AppKit terminate interception installed");
+ } else {
+ append_log(
+ "[quit] WARNING could not install AppKit terminate interception; \
+ Dock Quit and logout will bypass the quit flow",
+ );
+ }
+}
diff --git a/standalone/src-tauri/src/quit_state.rs b/standalone/src-tauri/src/quit_state.rs
new file mode 100644
index 000000000..75cbd4a7a
--- /dev/null
+++ b/standalone/src-tauri/src/quit_state.rs
@@ -0,0 +1,601 @@
+//! 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,
+ /// Bumped at each teardown phase boundary; the watchdog treats a bump as
+ /// progress and refreshes its budget.
+ pub progress: u64,
+}
+
+/// What forgetting a window leaves the machine owing, decided while `phase` is
+/// borrowed and applied once it is not.
+enum Forgotten {
+ Nothing,
+ Walk,
+ Exit,
+ Teardown(String, bool),
+}
+
+#[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.
+ ///
+ /// Clears `voted` only from `Idle`. A vote already cast stands through a
+ /// repeat trigger: the webview that cast it is committed and answers the
+ /// re-request with an ack alone, so clearing it would leave the machine in
+ /// `Voting` with no dialog left to answer. Once the walk has started the
+ /// repeat must also leave the walk in flight, or the fresh watchdog drops
+ /// into the unbounded voting wait and stops bounding it.
+ pub fn request(&mut self, labels: &[String]) -> (u64, Vec) {
+ self.seq += 1;
+ let idle = self.phase == QuitPhase::Idle;
+ 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 idle {
+ entry.voted = false;
+ }
+ next.insert(label.clone(), entry);
+ }
+ self.windows = next;
+ // Nothing left to ask. An `ExitRequested` raised after the last window
+ // was closed would otherwise park the machine in `Voting` with no
+ // window to vote and refuse every later exit.
+ if self.windows.is_empty() {
+ return (self.seq, self.exit());
+ }
+ 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) {
+ self.windows.entry(label.to_string()).or_default().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.
+ ///
+ /// **A live quit that runs out of windows exits**: every window is gone and
+ /// nothing is left to tear down, so holding the phase open would leave a
+ /// headless process nobody can reach.
+ pub fn forget_window(&mut self, label: &str) -> Vec {
+ self.windows.remove(label);
+ // Decided against a borrow of `phase` alone, then applied: `exit` and
+ // `start_walk` both need the whole machine.
+ let next = match &mut self.phase {
+ QuitPhase::Idle => Forgotten::Nothing,
+ QuitPhase::Voting => {
+ if self.windows.is_empty() {
+ Forgotten::Exit
+ } else if self.windows.values().all(|entry| entry.voted) {
+ Forgotten::Walk
+ } else {
+ Forgotten::Nothing
+ }
+ }
+ QuitPhase::Walking { order, index } => {
+ match order.iter().position(|entry| entry == label) {
+ None => Forgotten::Nothing,
+ Some(position) => {
+ order.remove(position);
+ if order.is_empty() {
+ Forgotten::Exit
+ } else if position > *index {
+ Forgotten::Nothing
+ } else if position < *index {
+ *index -= 1;
+ Forgotten::Nothing
+ } else {
+ // It was the window being torn down: advance.
+ match order.get(*index).cloned() {
+ Some(label) => {
+ Forgotten::Teardown(label, *index + 1 == order.len())
+ }
+ None => Forgotten::Exit,
+ }
+ }
+ }
+ }
+ }
+ };
+ match next {
+ Forgotten::Nothing => Vec::new(),
+ Forgotten::Walk => self.start_walk(),
+ Forgotten::Exit => self.exit(),
+ Forgotten::Teardown(label, last) => vec![QuitAction::Teardown { label, last }],
+ }
+ }
+
+ /// Nothing left to ask or to tear down. Approving here is what stops the
+ /// `app.exit(0)` this returns from re-entering the flow as a fresh quit.
+ fn exit(&mut self) -> Vec {
+ self.phase = QuitPhase::Idle;
+ self.approved = true;
+ 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 {
+ return self.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: retire the pending close so a
+ /// live watchdog stops speaking for it. **The seq is bumped, never reused**
+ /// — removing the entry hands the next `request` for this label the same
+ /// token a sleeping watchdog still holds, which would then destroy the
+ /// window out from under its second dialog.
+ pub fn clear(&mut self, label: &str) {
+ if let Some(entry) = self.pending.get_mut(label) {
+ entry.seq += 1;
+ entry.acked = false;
+ }
+ }
+
+ /// 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_interrupting_the_walk() {
+ 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 progress —
+ // which is what keeps the fresh watchdog bounding it rather than
+ // dropping into the unbounded voting wait.
+ assert!(matches!(quit.phase, QuitPhase::Walking { .. }));
+ assert_eq!(quit.walking_progress(), Some(("ws-2".into(), 1)));
+ // The stale watchdog stands down; the fresh one bounds the same teardown.
+ assert!(quit.stale(1));
+ assert!(!quit.stale(2));
+ }
+
+ #[test]
+ fn a_repeat_trigger_while_voting_keeps_the_votes_already_cast() {
+ let mut quit = QuitMachine::default();
+ quit.request(&labels(&["main", "ws-2"]));
+ // `main` had nothing running and voted at once; `ws-2` is on its dialog.
+ quit.vote("main");
+ assert_eq!(quit.phase, QuitPhase::Voting);
+
+ // Cmd+Q again: `main` is committed and only re-acks, never re-votes.
+ let (seq, actions) = quit.request(&labels(&["main", "ws-2"]));
+ assert_eq!(seq, 2);
+ assert_eq!(actions, vec![QuitAction::RequestAll]);
+ assert_eq!(quit.phase, QuitPhase::Voting);
+
+ // The dialog's yes is the last vote: the walk starts instead of wedging.
+ let actions = quit.vote("ws-2");
+ assert!(matches!(quit.phase, QuitPhase::Walking { .. }));
+ assert!(!actions.is_empty());
+ }
+
+ #[test]
+ fn a_cancelled_quit_asks_every_window_again() {
+ let mut quit = QuitMachine::default();
+ quit.request(&labels(&["main", "ws-2"]));
+ quit.vote("main");
+ quit.cancel();
+ assert_eq!(quit.phase, QuitPhase::Idle);
+
+ // From Idle every vote is fresh: `main` alone no longer carries the quit.
+ quit.request(&labels(&["main", "ws-2"]));
+ quit.vote("ws-2");
+ assert_eq!(quit.phase, QuitPhase::Voting);
+ quit.vote("main");
+ assert!(matches!(quit.phase, QuitPhase::Walking { .. }));
+ }
+
+ #[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 { .. }]));
+ }
+
+ /// Every window went away while the quit was still running — each one
+ /// closed, or crashed. There is nothing left to ask and nothing left to tear
+ /// down, so the app exits rather than living on with no window.
+ #[test]
+ fn a_quit_that_runs_out_of_windows_exits_instead_of_going_headless() {
+ let mut voting = QuitMachine::default();
+ voting.request(&labels(&["main", "ws-2"]));
+ assert_eq!(voting.forget_window("main"), Vec::new());
+ assert_eq!(voting.forget_window("ws-2"), vec![QuitAction::Exit]);
+ // Approved, so the `app.exit(0)` this asks for is not re-caught as a
+ // fresh quit trigger.
+ assert!(voting.approved);
+
+ let mut walking = QuitMachine::default();
+ walking.request(&labels(&["main", "ws-2"]));
+ walking.vote("main");
+ walking.vote("ws-2");
+ assert!(matches!(walking.phase, QuitPhase::Walking { .. }));
+ // The window ahead in the order leaves, then the one being torn down.
+ assert_eq!(walking.forget_window("main"), Vec::new());
+ assert_eq!(walking.forget_window("ws-2"), vec![QuitAction::Exit]);
+ assert!(walking.approved);
+ }
+
+ /// The trigger itself found no window: `main` and `ws-2` were both closed
+ /// while the other's teardown ran, and the `ExitRequested` that followed
+ /// carries an empty label list. Parking in `Voting` here would leave nothing
+ /// able to vote, cancel or be forgotten, and every later exit refused.
+ #[test]
+ fn a_trigger_with_no_windows_exits_instead_of_parking_in_voting() {
+ let mut quit = QuitMachine::default();
+ let (seq, actions) = quit.request(&labels(&[]));
+ assert_eq!(seq, 1);
+ assert_eq!(actions, vec![QuitAction::Exit]);
+ assert_eq!(quit.phase, QuitPhase::Idle);
+ assert!(quit.approved);
+ assert!(quit.stale(seq), "nothing is left for a watchdog to bound");
+ }
+
+ /// A session whose `main` was closed still walks every window and still ends
+ /// on one of them — which one is unspecified, because only `main` ever holds
+ /// a pending update to install (docs/specs/auto-update.md).
+ #[test]
+ fn without_main_the_walk_still_ends_on_a_last_window() {
+ let mut quit = QuitMachine::default();
+ quit.request(&labels(&["ws-2", "ws-5"]));
+ quit.vote("ws-5");
+ let actions = quit.vote("ws-2");
+ let [QuitAction::Teardown { label: first, last }] = actions.as_slice() else {
+ panic!("expected one teardown, got {actions:?}");
+ };
+ assert!(!last, "two windows: the first is not the last");
+ let second = if first == "ws-2" { "ws-5" } else { "ws-2" };
+ assert_eq!(
+ quit.window_done(first),
+ vec![
+ QuitAction::Destroy {
+ label: first.clone()
+ },
+ QuitAction::Teardown {
+ label: second.to_string(),
+ last: true
+ }
+ ]
+ );
+ }
+
+ #[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));
+ }
+
+ /// Cancel, then X again while the first watchdog still sleeps: the second
+ /// close must not be handed the token the first watchdog holds, or its wake
+ /// would destroy the window 1.2 s into the user's second dialog.
+ #[test]
+ fn a_cleared_close_never_hands_its_seq_to_the_next_request() {
+ let mut close = CloseMachine::default();
+ let first = close.request("ws-2");
+ close.ack("ws-2");
+ close.clear("ws-2");
+ let second = close.request("ws-2");
+ assert_ne!(first, second);
+ assert!(close.stale("ws-2", first), "the cancelled close's watchdog stands down");
+ assert!(!close.stale("ws-2", second));
+ // The cleared ack does not carry over to the new close either.
+ assert!(!close.acked("ws-2"));
+ }
+}
diff --git a/standalone/src-tauri/src/routing.rs b/standalone/src-tauri/src/routing.rs
new file mode 100644
index 000000000..77ea20150
--- /dev/null
+++ b/standalone/src-tauri/src/routing.rs
@@ -0,0 +1,801 @@
+//! 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, HashSet};
+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);
+
+/// How long an arrival may sit unadopted before Rust hands it back. The
+/// target's own collection times out at 3 s and a torn-out window boots in
+/// well under this; past it the target webview is wedged, and its shells
+/// would otherwise stay silent in the source forever.
+pub const ARRIVAL_MAX: Duration = Duration::from_secs(20);
+
+/// Where one sidecar event goes. Every label is borrowed from the state it was
+/// read out of: this runs once per PTY chunk, so it allocates nothing.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum Route<'a> {
+ /// To exactly this window label.
+ EmitTo(&'a str),
+ /// 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,
+ /// Nothing is delivered: the id is mid-transfer and its bytes are already in
+ /// the replay the new owner is about to receive, or no window owns it at all
+ /// and every window would otherwise ring for a pane none of them shows.
+ Drop,
+ /// A `dor` request naming no Surface belongs to whichever window the user
+ /// is looking at. Resolved by the caller, which alone holds the focus order.
+ Focused,
+ /// 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 -> "Standalone").
+ UnownedSurface {
+ request_id: &'a str,
+ surface_id: &'a str,
+ },
+}
+
+/// 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,
+ /// Which window took each outstanding `dor` request, so its cancel follows
+ /// the request instead of waking every window.
+ pub dor_targets: &'a HashMap,
+}
+
+fn str_field<'a>(data: &'a JsonValue, key: &str) -> Option<&'a str> {
+ data.get(key).and_then(JsonValue::as_str)
+}
+
+fn lookup<'a>(map: &'a HashMap, key: &str) -> Route<'a> {
+ match map.get(key) {
+ Some(label) => Route::EmitTo(label.as_str()),
+ // Nobody is holding this request, so nothing follows it.
+ None => Route::Broadcast,
+ }
+}
+
+/// Route by PTY ownership. **An id no window owns is dropped, never broadcast**:
+/// ownership is minted for every PTY this app spawns, so an unowned id is one
+/// whose window went away — and a broadcast would ring every other window's
+/// AlertManager for a pane none of them shows. The caller reaps the process
+/// (`Destroyed` in `standalone/src-tauri/src/lib.rs`).
+fn owner<'a>(map: &'a HashMap, id: &str) -> Route<'a> {
+ match map.get(id) {
+ Some(label) => Route::EmitTo(label.as_str()),
+ None => Route::Drop,
+ }
+}
+
+/// The one decision every sidecar stdout line passes through.
+pub fn route<'a>(event: &str, data: &'a JsonValue, view: &RouteView<'a>) -> Route<'a> {
+ 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(view.owners, 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" => match str_field(data, "id") {
+ Some(id) => owner(view.owners, id),
+ None => Route::Broadcast,
+ },
+ // The list answers one window's `pty:requestInit`, which named itself.
+ "pty:list" => match str_field(data, "forWindow") {
+ Some(label) => Route::EmitTo(label),
+ None => Route::Broadcast,
+ },
+ "dor:controlRequest" => {
+ let Some(surface_id) = str_field(data, "surfaceId") else {
+ return Route::Focused;
+ };
+ match view.owners.get(surface_id) {
+ Some(label) => Route::EmitTo(label.as_str()),
+ None => Route::UnownedSurface {
+ request_id: str_field(data, "requestId").unwrap_or_default(),
+ surface_id,
+ },
+ }
+ }
+ // The cancel follows the request: only the window handling it holds the
+ // subscription, watch or completion claim the cancel releases.
+ "dor:controlCancel" => match str_field(data, "requestId") {
+ Some(request_id) => lookup(view.dor_targets, request_id),
+ None => Route::Broadcast,
+ },
+ // A Burrow ask naming a Surface is a question exactly one window can
+ // answer, and `attach` / `resize` MUTATE that Surface — fanned out, every
+ // other window is asked to resize a pane it does not hold. The directory
+ // ask names none and stays a broadcast, because it is the union of every
+ // window's panes (docs/specs/standalone.md -> "Burrow service").
+ "burrow:ask" => match data
+ .get("params")
+ .and_then(|params| params.get("surfaceId"))
+ .and_then(JsonValue::as_str)
+ {
+ // A Surface with no PTY (a browser pane) is owned by no id here, so
+ // it keeps the fan-out: only its own window answers non-empty.
+ Some(surface_id) => lookup(view.owners, surface_id),
+ None => Route::Broadcast,
+ },
+ // `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(view.owners, id),
+ None => Route::Broadcast,
+ },
+ _ => Route::Broadcast,
+ }
+}
+
+/// One Workspace in flight between two windows, keyed by `workspace_id`.
+///
+/// **The record is the whole transaction.** It is created when the source
+/// invokes and lives until the target adopts the Workspace or dies, and it is
+/// what scopes the target's `pty:requestInit`, what keeps the sweep off a real
+/// arrival's suppression, what a boot list excludes, and what the hand-back on
+/// failure reads (docs/specs/standalone.md -> "Arrival queue").
+///
+/// **Held rather than emitted**: a window that has not installed its arrival
+/// listener yet — one still booting, or one torn out moments ago — is a legal
+/// drop target, and an `emit_to` it would simply be lost.
+#[derive(Debug, Clone, PartialEq)]
+pub struct Arrival {
+ pub workspace_id: String,
+ /// The window that still shows the Workspace until the target adopts it.
+ pub from: String,
+ pub to: String,
+ /// Exactly the PTYs whose ownership moved, helpers included.
+ pub terminal_ids: Vec,
+ /// What the target mounts the Workspace from.
+ pub payload: JsonValue,
+ /// When it was queued: the deadline's origin, and what makes the expiry
+ /// watchdog's record *this* one rather than a later re-drop of the same
+ /// Workspace into the same window.
+ pub queued_at: Instant,
+}
+
+/// Every arrival in flight, oldest first. A Vec, not a map: there are a handful
+/// at most, and both the per-window drain and the by-Workspace lookup want the
+/// order the drops happened in.
+pub type Arrivals = Vec;
+
+/// Whether a Workspace is already in flight. **One arrival per Workspace**: a
+/// second would silence the same ids twice and leave one record to hand back.
+pub fn has_arrival(arrivals: &Arrivals, workspace_id: &str) -> bool {
+ arrivals
+ .iter()
+ .any(|arrival| arrival.workspace_id == workspace_id)
+}
+
+pub fn queue_arrival(arrivals: &mut Arrivals, arrival: Arrival) {
+ arrivals.push(arrival);
+}
+
+pub fn find_arrival<'a>(arrivals: &'a Arrivals, workspace_id: &str) -> Option<&'a Arrival> {
+ arrivals
+ .iter()
+ .find(|arrival| arrival.workspace_id == workspace_id)
+}
+
+/// Settle one arrival, but **only from the window it was queued for**: a stale
+/// `adopt_done` from the source could otherwise retire a transfer the target is
+/// still resuming.
+pub fn take_arrival(arrivals: &mut Arrivals, workspace_id: &str, to: &str) -> Option {
+ let position = arrivals
+ .iter()
+ .position(|arrival| arrival.workspace_id == workspace_id && arrival.to == to)?;
+ Some(arrivals.remove(position))
+}
+
+/// Retire an arrival that outlived `ARRIVAL_MAX`, but **only the exact record
+/// the watchdog was armed for**: one adopted and re-dropped since would carry a
+/// later `queued_at`, and belongs to its own watchdog.
+pub fn expire_arrival(
+ arrivals: &mut Arrivals,
+ workspace_id: &str,
+ to: &str,
+ queued_at: Instant,
+) -> Option {
+ let position = arrivals.iter().position(|arrival| {
+ arrival.workspace_id == workspace_id && arrival.to == to && arrival.queued_at == queued_at
+ })?;
+ Some(arrivals.remove(position))
+}
+
+/// Every arrival `label` will never take, removed: its window is gone.
+pub fn take_arrivals_to(arrivals: &mut Arrivals, label: &str) -> Vec {
+ let mut lost = Vec::new();
+ arrivals.retain(|arrival| {
+ if arrival.to == label {
+ lost.push(arrival.clone());
+ false
+ } else {
+ true
+ }
+ });
+ lost
+}
+
+/// What `label` mounts, oldest first. **Not consumed**: the record settles at
+/// `adopt_done`, so a webview that drains twice — at boot and again when its
+/// listener is installed — finds an arrival it has not settled yet rather than
+/// losing the Workspace to a drain that happened too early.
+pub fn arrival_payloads(arrivals: &Arrivals, label: &str) -> Vec {
+ arrivals
+ .iter()
+ .filter(|arrival| arrival.to == label)
+ .map(|arrival| arrival.payload.clone())
+ .collect()
+}
+
+/// Every id an in-flight arrival claims: the ids the sweep may not release and
+/// a boot list may not place as panes.
+pub fn arrival_ids(arrivals: &Arrivals) -> HashSet {
+ arrivals
+ .iter()
+ .flat_map(|arrival| arrival.terminal_ids.iter().cloned())
+ .collect()
+}
+
+/// What a window's own `pty:requestInit` may name — and what its teardown may
+/// kill or interrupt: the ids it owns, **minus every id an arrival claims**.
+/// Ownership moves at the source's invoke, so a window with a Workspace queued
+/// for it owns those shells while the source is still showing them; listed at
+/// boot they would be placed as top-level panes beside the Workspace about to
+/// mount them, and in a teardown's kill set they would die under the source.
+pub fn boot_list_ids(owned: Vec, arrivals: &Arrivals) -> Vec {
+ if arrivals.is_empty() {
+ return owned;
+ }
+ let arriving = arrival_ids(arrivals);
+ owned.into_iter().filter(|id| !arriving.contains(id)).collect()
+}
+
+/// Release every suppression older than `max` that **no arrival claims**,
+/// returning what was released.
+///
+/// Fail open, but only defensively: a suppression whose arrival record is gone
+/// is bookkeeping nothing will ever lift, while a real arrival's is lifted by
+/// its own replay — and a cold boot slow enough to outrun `max` would otherwise
+/// have its shells unsilenced into a window that has not resumed them yet.
+pub fn sweep_awaiting(
+ map: &mut HashMap,
+ now: Instant,
+ max: Duration,
+ arriving: &HashSet,
+) -> Vec {
+ // The steady state: nothing is transferring, so this costs one branch.
+ if map.is_empty() {
+ return Vec::new();
+ }
+ let stale: Vec = map
+ .iter()
+ .filter(|(id, at)| now.duration_since(**at) >= max && !arriving.contains(*id))
+ .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()
+}
+
+/// Whether `main` was among `labels`, and everything else in the order given.
+/// Both orderings below put `main` at one end and keep the rest as they came.
+fn partition_main(labels: impl IntoIterator- >) -> (bool, Vec
) {
+ let mut has_main = false;
+ let mut rest: Vec = Vec::new();
+ for label in labels {
+ let label = label.as_ref();
+ if label == MAIN_LABEL {
+ has_main = true;
+ } else {
+ rest.push(label.to_string());
+ }
+ }
+ (has_main, rest)
+}
+
+/// 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 saved = file_names.into_iter().filter_map(|name| {
+ // `.json.tmp` also ends with `.tmp`, so strip on the full suffix and a
+ // temp never survives to become a label.
+ let label = name.as_ref().strip_suffix(".json")?;
+ (label == MAIN_LABEL || ws_index(label).is_some()).then(|| label.to_string())
+ });
+ let (has_main, mut ws) = partition_main(saved.collect::>());
+ ws.sort_by_key(|label| ws_index(label).unwrap_or_default());
+ let mut labels: Vec = Vec::with_capacity(ws.len() + 1);
+ if has_main {
+ labels.push(MAIN_LABEL.to_string());
+ }
+ labels.extend(ws);
+ labels
+}
+
+/// Teardown order for a quit: **`main` last**, which is the window that holds
+/// `updater:*` and installs a pending update once every sibling has handed on
+/// (docs/specs/auto-update.md). Every other label keeps the order it came in.
+pub fn quit_order(labels: impl IntoIterator- >) -> Vec
{
+ let (has_main, mut order) = partition_main(labels);
+ 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 labels(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 = labels(&[("a", "main"), ("b", "ws-2")]);
+ let none = awaiting(&[]);
+ let dor = labels(&[("dor-7", "ws-2")]);
+ let view = RouteView {
+ owners: &owned,
+ awaiting_replay: &none,
+ dor_targets: &dor,
+ };
+ let cases: &[(&str, JsonValue, Route)] = &[
+ ("pty:data", json!({"id":"a"}), Route::EmitTo("main")),
+ ("pty:data", json!({"id":"b"}), Route::EmitTo("ws-2")),
+ // Every PTY is minted with an owner, so an unowned id is one whose
+ // window went away: dropped, never rung through every sibling.
+ ("pty:data", json!({"id":"zz"}), Route::Drop),
+ ("pty:exit", json!({"id":"zz"}), Route::Drop),
+ ("alert:state", json!({"id":"zz"}), Route::Drop),
+ (
+ "terminal:semanticEvents",
+ json!({"id":"b"}),
+ Route::EmitTo("ws-2"),
+ ),
+ (
+ "terminal:protocolEvents",
+ json!({"id":"a"}),
+ Route::EmitTo("main"),
+ ),
+ ("pty:exit", json!({"id":"b"}), Route::EmitTo("ws-2")),
+ ("pty:replay", json!({"id":"a"}), Route::EmitTo("main")),
+ (
+ "pty:list",
+ json!({"forWindow":"ws-2","ptys":[]}),
+ Route::EmitTo("ws-2"),
+ ),
+ ("pty:list", json!({"ptys":[]}), Route::Broadcast),
+ ("alert:state", json!({"id":"a"}), Route::EmitTo("main")),
+ ("alert:settings", json!({"speech":true}), Route::Broadcast),
+ (
+ "dor:controlRequest",
+ json!({"requestId":"dor-1","surfaceId":"b"}),
+ Route::EmitTo("ws-2"),
+ ),
+ // No Surface named: the caller hands it to the focused window.
+ (
+ "dor:controlRequest",
+ json!({"requestId":"dor-2"}),
+ Route::Focused,
+ ),
+ // A cancel follows the window that took its request; one for a
+ // request nobody is holding has nothing to follow.
+ (
+ "dor:controlCancel",
+ json!({"requestId":"dor-7"}),
+ Route::EmitTo("ws-2"),
+ ),
+ (
+ "dor:controlCancel",
+ json!({"requestId":"dor-2"}),
+ Route::Broadcast,
+ ),
+ // The directory is the union of every window's panes.
+ (
+ "burrow:ask",
+ json!({"burrowRequestId":"ask-1","op":"directory","params":{}}),
+ Route::Broadcast,
+ ),
+ // A surface op names its Surface, and only its owner may answer:
+ // `attach` and `resize` mutate the pane they reach.
+ (
+ "burrow:ask",
+ json!({"burrowRequestId":"ask-2","op":"surfaceOp","params":{"surfaceId":"b","op":"attach"}}),
+ Route::EmitTo("ws-2"),
+ ),
+ // A Surface with no PTY here (a browser pane) keeps the fan-out.
+ (
+ "burrow:ask",
+ json!({"burrowRequestId":"ask-3","op":"surfaceOp","params":{"surfaceId":"browser-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 = labels(&[("a", "main")]);
+ let none = awaiting(&[]);
+ let no_dor = HashMap::new();
+ let view = RouteView {
+ owners: &owned,
+ awaiting_replay: &none,
+ dor_targets: &no_dor,
+ };
+ assert_eq!(
+ route(
+ "dor:controlRequest",
+ &json!({"requestId":"dor-9","surfaceId":"gone"}),
+ &view
+ ),
+ Route::UnownedSurface {
+ request_id: "dor-9",
+ surface_id: "gone"
+ }
+ );
+ }
+
+ #[test]
+ fn a_transferring_pty_is_suppressed_until_its_replay() {
+ let owned = labels(&[("a", "ws-2")]);
+ let held = awaiting(&["a"]);
+ let none = awaiting(&[]);
+ let no_dor = HashMap::new();
+ let suppressed = RouteView {
+ owners: &owned,
+ awaiting_replay: &held,
+ dor_targets: &no_dor,
+ };
+ 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")
+ );
+ // 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,
+ dor_targets: &no_dor,
+ };
+ assert_eq!(
+ route("pty:data", &json!({"id":"a"}), &released),
+ Route::EmitTo("ws-2")
+ );
+ }
+
+ #[test]
+ fn a_stale_suppression_with_no_arrival_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, &HashSet::new());
+ assert_eq!(swept, vec!["old".to_string()]);
+ assert!(map.contains_key("fresh"));
+ }
+
+ /// A cold boot slower than `AWAITING_REPLAY_MAX` must not have its shells
+ /// unsilenced into a window that has not resumed them yet: the fail-open is
+ /// for suppressions no arrival claims.
+ #[test]
+ fn the_sweep_never_releases_a_live_arrivals_suppression() {
+ let mut map = HashMap::new();
+ let now = Instant::now();
+ map.insert("arriving".to_string(), now - Duration::from_secs(9));
+ map.insert("orphan".to_string(), now - Duration::from_secs(9));
+ let mut arrivals = Arrivals::new();
+ queue_arrival(&mut arrivals, arrival("w1", "main", "ws-2", &["arriving"]));
+
+ let swept = sweep_awaiting(&mut map, now, AWAITING_REPLAY_MAX, &arrival_ids(&arrivals));
+ assert_eq!(swept, vec!["orphan".to_string()]);
+ assert!(map.contains_key("arriving"));
+
+ // Its record settled: the suppression is ordinary bookkeeping again.
+ take_arrival(&mut arrivals, "w1", "ws-2").unwrap();
+ assert_eq!(
+ sweep_awaiting(&mut map, now, AWAITING_REPLAY_MAX, &arrival_ids(&arrivals)),
+ vec!["arriving".to_string()]
+ );
+ }
+
+ #[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", "ws-5"]), vec!["ws-2", "ws-5"]);
+ assert_eq!(quit_order(["main"]), vec!["main"]);
+ }
+
+ fn arrival(workspace_id: &str, from: &str, to: &str, ids: &[&str]) -> Arrival {
+ Arrival {
+ workspace_id: workspace_id.to_string(),
+ from: from.to_string(),
+ to: to.to_string(),
+ terminal_ids: ids.iter().map(|id| (*id).to_string()).collect(),
+ payload: json!({ "workspaceId": workspace_id }),
+ queued_at: Instant::now(),
+ }
+ }
+
+ #[test]
+ fn an_expiry_retires_only_the_record_it_was_armed_for() {
+ let mut arrivals = Arrivals::new();
+ let first = arrival("ws-a", "main", "ws-2", &["t1"]);
+ let armed_for = first.queued_at;
+ queue_arrival(&mut arrivals, first);
+
+ // Adopted and dropped on the same window again before the watchdog
+ // fired: the record now in the queue is the second drop's.
+ take_arrival(&mut arrivals, "ws-a", "ws-2");
+ let second = arrival("ws-a", "main", "ws-2", &["t1"]);
+ assert_ne!(second.queued_at, armed_for);
+ queue_arrival(&mut arrivals, second.clone());
+
+ assert_eq!(expire_arrival(&mut arrivals, "ws-a", "ws-2", armed_for), None);
+ assert_eq!(arrivals, vec![second.clone()]);
+ assert_eq!(
+ expire_arrival(&mut arrivals, "ws-a", "ws-2", second.queued_at),
+ Some(second)
+ );
+ assert!(arrivals.is_empty());
+ }
+
+ /// A window that has not installed its arrival listener yet is a legal drop
+ /// target, so the payload waits for it instead of being emitted into the void
+ /// — and it keeps waiting until that window has actually adopted it.
+ #[test]
+ fn an_arrival_waits_for_its_window_and_settles_only_on_adoption() {
+ let mut arrivals = Arrivals::new();
+ queue_arrival(&mut arrivals, arrival("w1", "main", "ws-2", &["a"]));
+ queue_arrival(&mut arrivals, arrival("w2", "main", "ws-2", &["b"]));
+ queue_arrival(&mut arrivals, arrival("w3", "main", "ws-3", &["c"]));
+
+ let ids = |payloads: Vec| {
+ payloads
+ .iter()
+ .map(|payload| payload["workspaceId"].as_str().unwrap().to_string())
+ .collect::>()
+ };
+ assert_eq!(ids(arrival_payloads(&arrivals, "ws-2")), vec!["w1", "w2"], "oldest first");
+ // Draining does not consume: a webview drains at boot and again when its
+ // listener is installed, and neither may lose a Workspace.
+ assert_eq!(ids(arrival_payloads(&arrivals, "ws-2")), vec!["w1", "w2"]);
+ assert_eq!(arrival_payloads(&arrivals, "nobody").len(), 0);
+
+ // Settling is keyed by Workspace and scoped to the window it arrived in.
+ assert_eq!(take_arrival(&mut arrivals, "w1", "main"), None);
+ assert_eq!(take_arrival(&mut arrivals, "w1", "ws-2").unwrap().from, "main");
+ assert_eq!(ids(arrival_payloads(&arrivals, "ws-2")), vec!["w2"]);
+ assert!(!has_arrival(&arrivals, "w1"));
+ assert!(has_arrival(&arrivals, "w2"));
+
+ // The target went away: every arrival it will never take comes back, and
+ // a sibling's is untouched.
+ let lost = take_arrivals_to(&mut arrivals, "ws-2");
+ assert_eq!(lost.iter().map(|a| a.workspace_id.as_str()).collect::>(), vec!["w2"]);
+ assert_eq!(lost[0].terminal_ids, vec!["b".to_string()]);
+ assert_eq!(ids(arrival_payloads(&arrivals, "ws-3")), vec!["w3"]);
+ }
+
+ /// Each arrival names its own shells: two in flight at once must not each
+ /// resume over the other's (docs/specs/standalone.md -> "Arrival queue").
+ #[test]
+ fn arrival_ids_are_per_arrival_not_per_window() {
+ let mut arrivals = Arrivals::new();
+ queue_arrival(&mut arrivals, arrival("w1", "main", "ws-2", &["a", "a-helper"]));
+ queue_arrival(&mut arrivals, arrival("w2", "ws-9", "ws-2", &["b"]));
+
+ assert_eq!(
+ find_arrival(&arrivals, "w1").unwrap().terminal_ids,
+ vec!["a".to_string(), "a-helper".to_string()]
+ );
+ assert_eq!(find_arrival(&arrivals, "w2").unwrap().terminal_ids, vec!["b".to_string()]);
+ assert_eq!(
+ arrival_ids(&arrivals),
+ ["a", "a-helper", "b"].iter().map(|id| (*id).to_string()).collect::>()
+ );
+ }
+
+ /// A window booting with a Workspace already queued for it owns those shells
+ /// from the source's invoke. Listing them here would place them as top-level
+ /// panes beside the Workspace about to mount them; the same set is a
+ /// teardown's kill and interrupt list, where they would die under the source
+ /// still showing them (`pty_graceful_kill`, `capture_agent_recovery`).
+ #[test]
+ fn a_boot_list_never_names_an_arrivals_shells() {
+ let owned = || vec!["own-1".to_string(), "a".to_string(), "own-2".to_string()];
+ let mut arrivals = Arrivals::new();
+ assert_eq!(boot_list_ids(owned(), &arrivals), owned(), "nothing in flight");
+
+ queue_arrival(&mut arrivals, arrival("w1", "main", "ws-2", &["a"]));
+ assert_eq!(
+ boot_list_ids(owned(), &arrivals),
+ vec!["own-1".to_string(), "own-2".to_string()]
+ );
+
+ // Adopted: the ids are ordinary panes of this window again.
+ take_arrival(&mut arrivals, "w1", "ws-2").unwrap();
+ assert_eq!(boot_list_ids(owned(), &arrivals), owned());
+ }
+
+ 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/AppBar.tsx b/standalone/src/AppBar.tsx
index 64e7413ac..9ddd6898f 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 { onDragBackInsideStrip, onDragCancelled, 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,9 +167,16 @@ export function AppBar() {
on the event target alone, so no tab or tab 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,
@@ -172,3 +186,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 (
+
+ );
+}
diff --git a/standalone/src/QuitConfirmModal.test.ts b/standalone/src/QuitConfirmModal.test.ts
new file mode 100644
index 000000000..d1a087738
--- /dev/null
+++ b/standalone/src/QuitConfirmModal.test.ts
@@ -0,0 +1,41 @@
+// @vitest-environment jsdom
+import { createElement, act } from "react";
+import { createRoot } from "react-dom/client";
+import { describe, expect, it } from "vitest";
+import { QuitConfirmModal } from "./QuitConfirmModal";
+
+/**
+ * The dialog's copy, at the one place it depends on something other than the
+ * running count (`docs/specs/standalone.md` → "Quit flow", Confirmation
+ * dialog). No JSX: the standalone suite has no React transform, and this needs
+ * none.
+ */
+function render(props: Parameters[0]): string {
+ const host = document.createElement("div");
+ document.body.append(host);
+ const root = createRoot(host);
+ act(() => { root.render(createElement(QuitConfirmModal, props)); });
+ const text = document.body.textContent ?? "";
+ act(() => { root.unmount(); });
+ host.remove();
+ return text;
+}
+
+describe("QuitConfirmModal copy", () => {
+ it("warns that closing discards the update this window is holding", () => {
+ const text = render({
+ confirming: false,
+ intent: { kind: "close-window", windowName: "Deploys", discardsUpdate: true },
+ });
+ expect(text).toContain("Close this window?");
+ expect(text).toContain("The downloaded update will be discarded.");
+ });
+
+ it("says nothing about an update otherwise", () => {
+ expect(render({ confirming: false, intent: { kind: "close-window" } }))
+ .not.toContain("downloaded update");
+ // A quit installs it rather than discarding it, so the line never applies.
+ expect(render({ confirming: false, intent: { kind: "quit" } }))
+ .not.toContain("downloaded update");
+ });
+});
diff --git a/standalone/src/QuitConfirmModal.tsx b/standalone/src/QuitConfirmModal.tsx
index 86fc2ef1d..cdb5a0fe2 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,32 @@ 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.`;
+ // The download lives in this webview, so closing the window throws it away and
+ // the app installs nothing on the next quit (docs/specs/auto-update.md).
+ const updateNotice = !archiveError && !confirming && intent.discardsUpdate
+ ? 'The downloaded update will be discarded.'
+ : null;
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);
@@ -90,6 +112,7 @@ export function QuitConfirmModal({
>
{title}
{body}
+ {updateNotice && {updateNotice}
}
void>();
private exitHandlers = new Set<(detail: { id: string; exitCode: number }) => void>();
- private listHandlers = new Set<(detail: { ptys: PtyInfo[] }) => void>();
- private replayHandlers = new Set<(detail: { id: string; data: string }) => void>();
+ private listHandlers = new Set<(detail: PtyListDetail) => void>();
+ private replayHandlers = new Set<(detail: PtyReplayDetail) => void>();
private alertStateHandlers = new Set<(detail: AlertStateDetail) => void>();
private alertManager = new AlertManager();
private unlistenHost: (() => void) | null = null;
@@ -279,14 +280,14 @@ export class BrowserSidecarAdapter implements PlatformAdapter {
offPtyData(handler: (detail: PtyDataDetail) => void): void { this.dataHandlers.delete(handler); }
onPtyExit(handler: (detail: { id: string; exitCode: number }) => void): void { this.exitHandlers.add(handler); }
offPtyExit(handler: (detail: { id: string; exitCode: number }) => void): void { this.exitHandlers.delete(handler); }
- requestInit(): void {
- this.host.send("pty_request_init");
+ requestInit(requestId?: string): void {
+ this.host.send("pty_request_init", { requestId: requestId ?? null });
this.pushThemeColors();
}
- onPtyList(handler: (detail: { ptys: PtyInfo[] }) => void): void { this.listHandlers.add(handler); }
- offPtyList(handler: (detail: { ptys: PtyInfo[] }) => void): void { this.listHandlers.delete(handler); }
- onPtyReplay(handler: (detail: { id: string; data: string }) => void): void { this.replayHandlers.add(handler); }
- offPtyReplay(handler: (detail: { id: string; data: string }) => void): void { this.replayHandlers.delete(handler); }
+ onPtyList(handler: (detail: PtyListDetail) => void): void { this.listHandlers.add(handler); }
+ offPtyList(handler: (detail: PtyListDetail) => void): void { this.listHandlers.delete(handler); }
+ onPtyReplay(handler: (detail: PtyReplayDetail) => void): void { this.replayHandlers.add(handler); }
+ offPtyReplay(handler: (detail: PtyReplayDetail) => void): void { this.replayHandlers.delete(handler); }
onRequestSessionFlush(_handler: (detail: { requestId: string }) => void): void {}
offRequestSessionFlush(_handler: (detail: { requestId: string }) => void): void {}
notifySessionFlushComplete(_requestId: string): void {}
@@ -356,15 +357,15 @@ export class BrowserSidecarAdapter implements PlatformAdapter {
this.alertManager.onExit(payload.id, payload.exitCode);
for (const handler of this.exitHandlers) handler(payload);
} else if (event === "pty:list") {
- for (const pty of (data as { ptys: PtyInfo[] }).ptys) if (pty.helper) this.alertManager.setHelper(pty.id, true);
- for (const handler of this.listHandlers) handler(data as { ptys: PtyInfo[] });
+ for (const pty of (data as PtyListDetail).ptys) if (pty.helper) this.alertManager.setHelper(pty.id, true);
+ for (const handler of this.listHandlers) handler(data as PtyListDetail);
} else if (event === "pty:replay") {
// The one stream the sidecar does not parse; see TauriAdapter, including
// why the one-shot parser still needs the theme.
- const { id, data: text } = data as { id: string; data: string };
+ const { id, data: text, requestId } = data as PtyReplayDetail;
const parsed = new TerminalProtocolParser(themeColorProvider).process(text);
applyTerminalSemanticEvents(id, collectTerminalSemanticEvents(parsed.events));
- for (const handler of this.replayHandlers) handler({ id, data: parsed.visibleData });
+ for (const handler of this.replayHandlers) handler({ id, data: parsed.visibleData, requestId });
} else if (event === BURROW_RESULT_EVENT) {
this.burrowClient.onResult(data as BurrowResult);
} else if (event === BURROW_ASK_EVENT) {
diff --git a/standalone/src/main.tsx b/standalone/src/main.tsx
index 0de951acd..922dd985a 100644
--- a/standalone/src/main.tsx
+++ b/standalone/src/main.tsx
@@ -4,6 +4,8 @@ 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 { setWindowLabel } 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";
@@ -82,6 +84,11 @@ 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. The lib gets the
+ // label too, so `dor list` names the Window that answered
+ // (`docs/specs/dor-cli.md` → "Handle Model").
+ setWindowLabel(await resolveWindowLabel());
const platform = await createPlatform();
setPlatform(platform);
await platform.init();
@@ -104,12 +111,16 @@ 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;
+ initQuitFlow(adapter);
+ initWindowClose(adapter);
+ // A quit or a close with ≥1 running command opens .
setQuitConfirmGate(openQuitConfirm);
}
const { initAlertStateReceiver } = await import("dormouse-lib/lib/terminal-registry");
@@ -124,9 +135,30 @@ 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;
+ let armWorkspaceMoves: (() => void) | null = null;
+ if (!BROWSER_DEV_HOST) {
+ const [{ bootFromTearOut, initWorkspaceMoves }, { initDropCaret }] = await Promise.all([
+ import("./workspace-move"),
+ import("./workspace-drop-caret"),
+ ]);
+ initialPlans = await bootFromTearOut(platform);
+ armWorkspaceMoves = () => initWorkspaceMoves(platform);
+ initDropCaret();
+ }
+ initialPlans ??= await restoreWindowOrFresh(platform);
+ // Strictly after the restore: arming drains whatever was dropped on this
+ // window while it booted, and `restoreWindowOrFresh` installs the Workspace
+ // store wholesale (`docs/specs/standalone.md` → "Arrival queue").
+ armWorkspaceMoves?.();
- startUpdateCheck();
+ // Only `main` runs the periodic check, and only `main` holds `updater:*`
+ // (`capabilities/main-only.json`), so a session whose `main` was closed has
+ // no update to install until it relaunches (docs/specs/auto-update.md).
+ if (isMainWindow()) startUpdateCheck();
createRoot(document.getElementById("root")!).render(
diff --git a/standalone/src/quit-confirm-store.test.ts b/standalone/src/quit-confirm-store.test.ts
index e86b8a2e9..6d360fa9b 100644
--- a/standalone/src/quit-confirm-store.test.ts
+++ b/standalone/src/quit-confirm-store.test.ts
@@ -10,10 +10,9 @@ import {
_resetQuitConfirmForTesting,
} from "./quit-confirm-store";
-// The store's only runtime dependency on ./quit is the QuitConfirmContext TYPE
-// (erased), so these tests need no Tauri/orchestrator mocks: drive the gate
-// with a hand-made context. The gate↔orchestrator seam itself is covered by
-// quit.test.ts.
+// The store imports one erased TYPE and nothing else, so these tests need no
+// Tauri/orchestrator mocks: drive the gate with a hand-made context. The
+// gate↔orchestrator seam itself is covered by quit.test.ts.
const makeCtx = () => ({ confirm: vi.fn(), cancel: vi.fn() });
describe("quit-confirm store", () => {
diff --git a/standalone/src/quit-confirm-store.ts b/standalone/src/quit-confirm-store.ts
index ad218f6da..5e6268faa 100644
--- a/standalone/src/quit-confirm-store.ts
+++ b/standalone/src/quit-confirm-store.ts
@@ -1,4 +1,4 @@
-import type { QuitConfirmContext } from "./quit";
+import type { TeardownConfirmContext } from "./teardown-flow";
/**
* Module store backing the quit-confirmation dialog. The quit orchestrator's
@@ -9,12 +9,30 @@ 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;
+ /** This window holds an approved, downloaded update that closing throws away:
+ * the download lives in the webview, so nothing else can install it
+ * (docs/specs/auto-update.md). Never set on a quit, which installs it. */
+ discardsUpdate?: boolean;
+}
+
+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
// is made, so a repeated confirm / a late cancel is a no-op.
-let activeCtx: QuitConfirmContext | null = null;
+let activeCtx: TeardownConfirmContext | null = null;
const listeners = new Set<() => void>();
export function subscribeQuitConfirm(listener: () => void): () => void {
@@ -33,6 +51,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 +64,17 @@ 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 {
- if (phase !== null) return;
+export function openQuitConfirm(ctx: TeardownConfirmContext, next: QuitConfirmIntent = QUIT_INTENT): void {
+ if (phase !== null) {
+ // **Never leave a refused context unsettled.** Its flow would sit in
+ // `confirming` for the life of the window, and the host would wait out its
+ // budget on a decision that can never arrive. The arbiter in
+ // `teardown-flow.ts` should have kept this from happening at all.
+ ctx.cancel();
+ return;
+ }
activeCtx = ctx;
+ intent = next;
phase = "open";
emit();
}
@@ -56,8 +87,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: TeardownConfirmContext,
+ next: QuitConfirmIntent = QUIT_INTENT,
+): void {
activeCtx = ctx;
+ intent = next;
archiveError = message;
phase = "archive-failed";
emit();
@@ -85,9 +121,27 @@ 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(kind?: QuitConfirmIntent["kind"]): void {
+ if (phase === null) return;
+ // A quit cancelled elsewhere says nothing about this window's own close.
+ if (kind !== undefined && intent.kind !== kind) 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 03bda59af..7ac2d65d6 100644
--- a/standalone/src/quit.test.ts
+++ b/standalone/src/quit.test.ts
@@ -17,6 +17,7 @@ const mocks = vi.hoisted(() => ({
notepadSurfaceIds: vi.fn(() => [] as string[]),
removeSurface: vi.fn(),
flushWindowSession: vi.fn(async () => {}),
+ getWorkspacesSnapshot: vi.fn(() => ({ workspaces: [{ id: "w1", name: "Deploys" }], activeId: "w1" })),
}));
vi.mock("@tauri-apps/api/core", () => ({ invoke: mocks.invoke }));
@@ -30,6 +31,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,
@@ -39,6 +42,10 @@ vi.mock("dormouse-lib/lib/notepad/notepad-store", () => ({
vi.mock("dormouse-lib/lib/window-session-aggregator", () => ({
flushWindowSession: mocks.flushWindowSession,
}));
+// How a window names itself in its dialog: the Workspace it is showing.
+vi.mock("dormouse-lib/lib/workspace-store", () => ({
+ getWorkspacesSnapshot: mocks.getWorkspacesSnapshot,
+}));
vi.mock("./updater", () => ({
hasPendingUpdate: mocks.hasPendingUpdate,
installPendingUpdate: mocks.installPendingUpdate,
@@ -52,15 +59,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 ceiling guard is cleared when the work wins).
@@ -77,15 +91,25 @@ 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 +118,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);
@@ -145,6 +169,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",
@@ -186,7 +211,9 @@ describe("quit orchestrator", () => {
mocks.flushWindowSession.mockImplementation(slow("flushWindow", 1000));
initQuitFlow(adapter);
- quitRequested!();
+ quitRequested();
+ await vi.advanceTimersByTimeAsync(0);
+ quitTeardown();
await vi.advanceTimersByTimeAsync(30_000);
expect(order).toContain("flushWindow");
@@ -213,7 +240,11 @@ describe("quit orchestrator", () => {
const adapter = fakeAdapter(order);
initQuitFlow(adapter);
- quitRequested!();
+ quitRequested();
+ await vi.advanceTimersByTimeAsync(0);
+ // Voted; Rust walks this window, and the wedged write must not hold the
+ // drain behind it past its own budget.
+ quitTeardown();
await vi.advanceTimersByTimeAsync(1000);
expect(order.slice(-2)).toEqual(["drain", "quit_proceed"]);
@@ -256,6 +287,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");
});
@@ -284,9 +318,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.
@@ -321,7 +357,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);
@@ -357,7 +393,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");
});
@@ -405,7 +441,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.
@@ -421,6 +457,8 @@ describe("quit orchestrator", () => {
confirmQuit();
await settle();
+ quitTeardown();
+ await settle();
expect(mocks.removeSurface).toHaveBeenCalledWith("pane-a");
expect(adapter.requestSessionFlush).toHaveBeenCalled();
@@ -444,7 +482,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");
@@ -457,7 +497,7 @@ describe("quit orchestrator", () => {
mocks.archiveSurfaceNotes.mockReturnValue(new Promise(() => {})); // never settles
const adapter = fakeAdapter();
initQuitFlow(adapter);
- quitRequested!();
+ quitRequested();
await vi.advanceTimersByTimeAsync(3000);
@@ -482,7 +522,7 @@ describe("quit orchestrator", () => {
return new Promise(() => {}); // never settles
});
initQuitFlow(fakeAdapter());
- quitRequested!();
+ quitRequested();
await Promise.resolve();
expect(signal?.aborted).toBe(false);
@@ -495,6 +535,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());
+ quitRequested(1);
+ await settle();
+ expect(gate.mock.calls[0]![1]).toEqual({ kind: "quit" });
+
+ _resetForTesting();
+ gate.mockClear();
+ setQuitConfirmGate(gate);
+ initQuitFlow(fakeAdapter());
+ 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 19360c6b8..3233685d8 100644
--- a/standalone/src/quit.ts
+++ b/standalone/src/quit.ts
@@ -1,129 +1,72 @@
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 } from "./quit-confirm-store";
+import { createTeardownFlow, describeWindow, type TeardownConfirmGate } from "./teardown-flow";
import { hasPendingUpdate, installPendingUpdate } from "./updater";
-import { withDeadline, withTimeout } from "./with-timeout";
+import { withTimeout } from "./with-timeout";
+import { listenToWindow } from "./window-label";
/**
- * 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.
+ *
+ * The ack / confirm / archive half is `createTeardownFlow`, shared with the
+ * per-window close; what is quit-specific is voting, and the teardown below.
*/
-// 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";
// The adapter to tear down, captured at init.
let quitAdapter: TauriAdapter | null = null;
// 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.
-export interface QuitConfirmContext {
- confirm: () => void;
- cancel: () => void;
-}
-type QuitConfirmGate = (ctx: QuitConfirmContext) => void;
-let quitConfirmGate: QuitConfirmGate | null = null;
+// `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 —
+// which is what a composition with no dialog host gets.
+let quitConfirmGate: TeardownConfirmGate | null = null;
/** Register (or clear with null) the running-work confirmation gate. */
-export function setQuitConfirmGate(gate: QuitConfirmGate | null): void {
+export function setQuitConfirmGate(gate: TeardownConfirmGate | null): void {
quitConfirmGate = gate;
}
+const flow = createTeardownFlow({
+ kind: "quit",
+ ack: "quit_ack",
+ cancelCommand: "quit_cancel",
+ gate: () => quitConfirmGate,
+ // This window is ready to be torn down. The last vote starts the walk; the
+ // teardown itself arrives later, when the walk reaches this window.
+ proceed: () => void invoke("quit_vote").catch(() => {}),
+});
+
export function initQuitFlow(adapter: TauriAdapter): void {
quitAdapter = adapter;
- void listen("dormouse://quit-requested", handleQuitRequested);
-}
-
-function handleQuitRequested(): void {
- // Ack first — stands Rust's phase-1 watchdog down even when the trigger is
- // deduped below (a repeated trigger re-emits, so re-acking is expected).
- void invoke("quit_ack").catch(() => {});
-
- if (quitPhase !== "idle") return;
-
- 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.`,
- );
- } catch (err) {
- gaveUp.abort();
- throw err;
- }
-}
-
-// 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 {
- // 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";
- try {
- await archiveNotesBeforeQuit();
- } 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.
- 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.
- for (const id of notepadSurfaceIds()) removeSurface(id);
- void runQuitTeardown();
- },
- // Cancel is the one branch that drops the pending quit in Rust.
- cancel: cancelQuit,
- });
- return;
- }
- await runQuitTeardown();
+ void listenToWindow<{ windows?: number }>("dormouse://quit-requested", (event) => {
+ const windows = event.payload?.windows ?? 1;
+ // Named only when there is more than one window to tell apart.
+ flow.request({ kind: "quit", ...(windows > 1 ? { windowName: describeWindow() } : {}) });
+ });
+ // Another window said no. Nothing was destroyed; drop this window's dialog
+ // and go back to idle so a later quit asks again. No call back into Rust —
+ // the cancel already happened, somewhere else.
+ void listenToWindow("dormouse://quit-cancelled", () => {
+ flow.reset();
+ // Only a quit's dialog: this window may instead be asking about its own
+ // close, which another window's decision has no say over.
+ dismissQuitConfirm("quit");
+ });
+ // Every window voted yes, and it is now this window's turn.
+ void listenToWindow<{ last?: boolean }>("dormouse://quit-teardown", (event) => {
+ void runQuitTeardown(event.payload?.last === true);
+ });
}
// Each teardown step's own bound, and the ceiling derived from them. The two
@@ -155,11 +98,13 @@ const STEP_BUDGET_TOTAL_MS =
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 {
- quitPhase = "tearing-down";
+// 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 {
const adapter = quitAdapter;
try {
void invoke("quit_progress").catch(() => {}); // teardown phase begins
@@ -170,12 +115,10 @@ 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
- 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 });
@@ -192,30 +135,28 @@ 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 `main` —
+ // the window the walk tears down last, and the only one holding `updater:*`
+ // (`capabilities/main-only.json`; 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.
-function cancelQuit(): void {
- quitPhase = "idle";
- void invoke("quit_cancel").catch(() => {});
-}
-
/** @internal Reset module state for testing. */
export function _resetForTesting(): void {
- quitPhase = "idle";
+ flow.reset();
quitAdapter = null;
quitConfirmGate = null;
}
diff --git a/standalone/src/tauri-adapter.test.ts b/standalone/src/tauri-adapter.test.ts
index 049265cce..929ea4084 100644
--- a/standalone/src/tauri-adapter.test.ts
+++ b/standalone/src/tauri-adapter.test.ts
@@ -184,7 +184,7 @@ describe("TauriAdapter window persistence", () => {
return undefined;
});
await expect(adapter.captureAgentRecovery(1300)).resolves.toBeUndefined();
- expect(invoke).toHaveBeenCalledWith("capture_agent_recovery", { ids: null, timeout: 1300 });
+ expect(invoke).toHaveBeenCalledWith("capture_agent_recovery", { timeout: 1300 });
adapter.shutdown();
});
});
diff --git a/standalone/src/tauri-adapter.ts b/standalone/src/tauri-adapter.ts
index d3000ec97..5cbf96a09 100644
--- a/standalone/src/tauri-adapter.ts
+++ b/standalone/src/tauri-adapter.ts
@@ -1,6 +1,5 @@
import type { HelperIdentity, TerminalContextRequest, TerminalContextInfo } from '../../lib/src/lib/terminal-context-types';
import { invoke as rawInvoke } from "@tauri-apps/api/core";
-import { listen } from "@tauri-apps/api/event";
import { open } from "@tauri-apps/plugin-shell";
import { coalesceCwds } from "./coalesce-cwds";
import type {
@@ -17,6 +16,8 @@ import type {
PlatformAdapter,
PtyDataDetail,
PtyInfo,
+ PtyListDetail,
+ PtyReplayDetail,
BurrowLink,
SessionFlushRequest,
} from "dormouse-lib/lib/platform/types";
@@ -46,6 +47,7 @@ import { normalizeExternalUri } from "dormouse-lib/lib/external-links";
import type { PersistedAlertState, PersistedWindow } from "dormouse-lib/lib/session-types";
import { TauriSessionStore } from "./tauri-session-store";
import { claimRecoveryCommands, windowStateSlot } from "./window-recovery";
+import { listenToWindow } from "./window-label";
import { withTimeout } from "./with-timeout";
import {
applyTerminalProtocolEvents,
@@ -88,10 +90,14 @@ const errMessage = (err: unknown): string =>
export class TauriAdapter implements PlatformAdapter {
private dataHandlers = new Set<(detail: PtyDataDetail) => void>();
private exitHandlers = new Set<(detail: { id: string; exitCode: number }) => void>();
- private listHandlers = new Set<(detail: { ptys: PtyInfo[] }) => void>();
- private replayHandlers = new Set<(detail: { id: string; data: string }) => void>();
+ private listHandlers = new Set<(detail: PtyListDetail) => void>();
+ private replayHandlers = new Set<(detail: PtyReplayDetail) => void>();
private filesDroppedHandlers = new Set<(paths: string[]) => void>();
private alertStateHandlers = new Set<(detail: AlertStateDetail) => void>();
+ // The two app-global stores are the sidecar's, so this window applies what
+ // comes back rather than what it sent (docs/specs/alert.md → "Alarm settings").
+ private watchedCommandHandlers = new Set<(names: string[]) => void>();
+ private alertSettingsHandlers = new Set<(settings: AlertSettings) => void>();
private unlistenFns: Array<() => void> = [];
private alertManager = new AlertManager();
private static STATE_KEY = 'dormouse.session';
@@ -141,7 +147,7 @@ export class TauriAdapter implements PlatformAdapter {
// Already parsed by the sidecar, which owns the PTY: the pair arrives as
// it is, and its events arrive as the two messages below
// (docs/specs/terminal-escapes.md → "Parsing location").
- listen("pty:data", (event) => {
+ listenToWindow("pty:data", (event) => {
const { id, data, textData } = event.payload;
// Feed visible data to alert manager for visual activity monitoring.
this.alertManager.onData(id);
@@ -150,66 +156,66 @@ export class TauriAdapter implements PlatformAdapter {
}
}),
- listen<{ id: string; events: TerminalProtocolEvent[] }>("terminal:protocolEvents", (event) => {
+ listenToWindow<{ id: string; events: TerminalProtocolEvent[] }>("terminal:protocolEvents", (event) => {
applyTerminalProtocolEvents(this.alertManager, event.payload.id, event.payload.events);
}),
- listen<{ id: string; events: TerminalSemanticEvent[] }>("terminal:semanticEvents", (event) => {
+ listenToWindow<{ id: string; events: TerminalSemanticEvent[] }>("terminal:semanticEvents", (event) => {
const { id, events } = event.payload;
this.alertManager.applyTerminalSemanticEvents(id, events);
applyTerminalSemanticEvents(id, events);
}),
- listen<{ id: string; exitCode: number }>("pty:exit", (event) => {
+ listenToWindow<{ id: string; exitCode: number }>("pty:exit", (event) => {
this.alertManager.onExit(event.payload.id, event.payload.exitCode);
for (const handler of this.exitHandlers) {
handler(event.payload);
}
}),
- listen<{ ptys: PtyInfo[] }>("pty:list", (event) => {
+ listenToWindow<{ ptys: PtyInfo[]; requestId?: string }>("pty:list", (event) => {
for (const pty of event.payload.ptys) if (pty.helper) this.alertManager.setHelper(pty.id, true);
for (const handler of this.listHandlers) {
handler(event.payload);
}
}),
- listen<{ id: string; data: string }>("pty:replay", (event) => {
+ listenToWindow<{ id: string; data: string; requestId?: string }>("pty:replay", (event) => {
// Replay arrives as raw buffered output, the one stream the sidecar does
// not parse. A one-shot parser here repopulates semantic state and
// strips OSCs before xterm sees them; its responses are dropped, since
// the asker is long gone (docs/specs/terminal-escapes.md). It still
// needs the theme: a *declined* colour query is not consumed, so it
// reaches xterm.js instead, and answering is the owner's alone.
- const { id, data } = event.payload;
+ const { id, data, requestId } = event.payload;
const parsed = new TerminalProtocolParser(themeColorProvider).process(data);
applyTerminalSemanticEvents(id, collectTerminalSemanticEvents(parsed.events));
for (const handler of this.replayHandlers) {
- handler({ id, data: parsed.visibleData });
+ handler({ id, data: parsed.visibleData, requestId });
}
}),
// Inert while dragDropEnabled=false in tauri.conf.json. See diffplug/dormouse#38 and tauri-apps/tauri#14373.
- listen<{ paths: string[] }>("dormouse://files-dropped", (event) => {
+ listenToWindow<{ paths: string[] }>("dormouse://files-dropped", (event) => {
const paths = event.payload.paths ?? [];
if (paths.length === 0) return;
for (const handler of this.filesDroppedHandlers) handler(paths);
}),
- listen(BURROW_RESULT_EVENT, (event) => {
+ listenToWindow(BURROW_RESULT_EVENT, (event) => {
this.burrowClient.onResult(event.payload);
}),
- listen(BURROW_ASK_EVENT, (event) => {
+ listenToWindow(BURROW_ASK_EVENT, (event) => {
const ask = event.payload;
this.burrowClient.onAsk(ask.burrowRequestId, ask.op, ask.params);
}),
- listen<{ name?: string }>(BURROW_EVENT_EVENT, (event) => {
+ listenToWindow<{ name?: string }>(BURROW_EVENT_EVENT, (event) => {
this.burrowClient.onEvent(event.payload);
}),
- listen("dor:controlRequest", (event) => {
+ listenToWindow("dor:controlRequest", (event) => {
const payload = event.payload;
dispatchDorControlRequest(payload, (response) => {
rawInvoke("dor_control_response", {
@@ -227,9 +233,24 @@ export class TauriAdapter implements PlatformAdapter {
// up, or its own deadline fired). Rust forwards it verbatim: `dor-*`
// request ids never collide with its own `req-*` invoke ids, so the
// pending-invoke lookup misses and the event reaches us.
- listen("dor:controlCancel", (event) => {
+ listenToWindow("dor:controlCancel", (event) => {
cancelDorControlRequest(event.payload.requestId);
}),
+
+ // The sidecar's canonical snapshots, broadcast to every window. This
+ // window's own `AlertManager` is one more consumer of them.
+ listenToWindow<{ names?: string[] }>("alert:watchedCommands", (event) => {
+ const names = event.payload?.names ?? [];
+ this.alertManager.setWatchedCommands(names);
+ for (const handler of this.watchedCommandHandlers) handler(names);
+ }),
+
+ listenToWindow<{ settings?: AlertSettings }>("alert:settings", (event) => {
+ const settings = event.payload?.settings;
+ if (!settings) return;
+ this.alertManager.applySettings(settings);
+ for (const handler of this.alertSettingsHandlers) handler(settings);
+ }),
])));
await this.hydrateSessionStore();
@@ -319,13 +340,9 @@ export class TauriAdapter implements PlatformAdapter {
* the sidecar record what it detects. Warn-and-proceed: a quit must never wedge
* on this (docs/specs/standalone.md -> "Agent recovery").
*/
- async captureAgentRecovery(timeoutMs: number, ids?: string[]): Promise {
- // `ids` has no caller yet — a whole-Window quit interrupts everything — and
- // is plumbed to the sidecar anyway, because closing one Window of several has
- // to capture only that Window's panes (docs/specs/layout.md -> "Future",
- // Scope: workspaces-rollout).
+ async captureAgentRecovery(timeoutMs: number): Promise {
try {
- await rawInvoke("capture_agent_recovery", { ids: ids ?? null, timeout: timeoutMs });
+ await rawInvoke("capture_agent_recovery", { timeout: timeoutMs });
} catch (err) {
console.warn("[tauri-adapter] captureAgentRecovery failed; proceeding", err);
}
@@ -350,13 +367,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.
+ *
+ * The target set is what this window owns, and Rust alone decides it
+ * (`docs/specs/standalone.md` -> "Windows"), so a sibling's terminals are not
+ * nameable from here. 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): Promise {
try {
- await rawInvoke("pty_graceful_kill_all", { timeout: timeoutMs });
+ await rawInvoke("pty_graceful_kill", { timeout: timeoutMs });
} catch (err) {
- console.warn("[tauri-adapter] gracefulKillAllPtys failed; proceeding", err);
+ console.warn("[tauri-adapter] gracefulKillPtys failed; proceeding", err);
}
}
@@ -503,8 +527,11 @@ export class TauriAdapter implements PlatformAdapter {
this.exitHandlers.delete(handler);
}
- requestInit(): void {
- invoke("pty_request_init");
+ requestInit(requestId?: string): void {
+ // The token rides through to the sidecar's `list`, which echoes it on the
+ // answer: one window can have a boot collection and an arriving Workspace's
+ // outstanding at once (docs/specs/transport.md -> "Reconnection").
+ invoke("pty_request_init", { requestId: requestId ?? null });
this.pushThemeColors();
}
@@ -521,19 +548,19 @@ export class TauriAdapter implements PlatformAdapter {
});
}
- onPtyList(handler: (detail: { ptys: PtyInfo[] }) => void): void {
+ onPtyList(handler: (detail: PtyListDetail) => void): void {
this.listHandlers.add(handler);
}
- offPtyList(handler: (detail: { ptys: PtyInfo[] }) => void): void {
+ offPtyList(handler: (detail: PtyListDetail) => void): void {
this.listHandlers.delete(handler);
}
- onPtyReplay(handler: (detail: { id: string; data: string }) => void): void {
+ onPtyReplay(handler: (detail: PtyReplayDetail) => void): void {
this.replayHandlers.add(handler);
}
- offPtyReplay(handler: (detail: { id: string; data: string }) => void): void {
+ offPtyReplay(handler: (detail: PtyReplayDetail) => void): void {
this.replayHandlers.delete(handler);
}
@@ -594,15 +621,23 @@ export class TauriAdapter implements PlatformAdapter {
this.alertManager.remove(id);
}
+ /** Offer this window's persisted rule set as the host's startup seed; only
+ * the first window's offer is taken. */
alertSetWatchedCommands(names: string[]): void {
- this.alertManager.setWatchedCommands(names);
+ invoke("alert_command", { payload: { op: "initializeWatchedCommands", names } });
}
+ /** A delta, never a replacement, so a window that has not heard about a rule
+ * cannot drop it. */
alertSetCommandWatched(name: string, watched: boolean): void {
- this.alertManager.setCommandWatched(name, watched);
+ invoke("alert_command", { payload: { op: "setCommandWatched", name, watched } });
}
- alertPublishSettings(settings: AlertSettings): void { this.alertManager.applySettings(settings); }
+ alertPublishSettings(settings: AlertSettings, opts: { seed: boolean }): void {
+ invoke("alert_command", {
+ payload: { op: opts.seed ? "initializeSettings" : "updateSettings", settings },
+ });
+ }
alertDismiss(id: string): void {
this.alertManager.dismissAlert(id);
@@ -640,11 +675,13 @@ export class TauriAdapter implements PlatformAdapter {
this.alertStateHandlers.add(handler);
}
- // Single webview owning the AlertManager, so localStorage is the only store
- // and there is no canonical snapshot to broadcast back.
- onWatchedCommands(_handler: (names: string[]) => void): void {}
+ onWatchedCommands(handler: (names: string[]) => void): void {
+ this.watchedCommandHandlers.add(handler);
+ }
- onAlertSettings(_handler: (settings: AlertSettings) => void): void {}
+ onAlertSettings(handler: (settings: AlertSettings) => void): void {
+ this.alertSettingsHandlers.add(handler);
+ }
// --- State persistence ---
diff --git a/standalone/src/teardown-arbiter.test.ts b/standalone/src/teardown-arbiter.test.ts
new file mode 100644
index 000000000..78d457c35
--- /dev/null
+++ b/standalone/src/teardown-arbiter.test.ts
@@ -0,0 +1,328 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import type { TauriAdapter } from "./tauri-adapter";
+
+/**
+ * The quit flow and the per-window close flow are separate machines over one
+ * window, one dialog and one human. Both orderings must leave **no flow stuck
+ * and every context settled** (`docs/specs/standalone.md` → "Per-window close",
+ * Arbitration).
+ *
+ * Both real modules are loaded here — that is the point — so the mocks are the
+ * union of what each one's own suite needs.
+ */
+const mocks = vi.hoisted(() => ({
+ invoke: vi.fn(async (_cmd: string) => undefined as unknown),
+ listen: vi.fn(),
+ countRunningSessions: vi.fn(() => 0),
+ hasPendingUpdate: vi.fn(() => false),
+ installPendingUpdate: vi.fn(async () => {}),
+ archiveSurfaceNotes: vi.fn(async (_ids: readonly string[], _opts?: { signal?: AbortSignal }) => {}),
+ notepadSurfaceIds: vi.fn(() => [] as string[]),
+ removeSurface: vi.fn(),
+ flushWindowSession: vi.fn(async () => {}),
+ getWorkspacesSnapshot: vi.fn(() => ({ workspaces: [{ id: "w1", name: "Deploys" }], activeId: "w1" })),
+}));
+
+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,
+}));
+vi.mock("dormouse-lib/lib/window-session-aggregator", () => ({
+ flushWindowSession: mocks.flushWindowSession,
+}));
+vi.mock("dormouse-lib/lib/workspace-store", () => ({
+ getWorkspacesSnapshot: mocks.getWorkspacesSnapshot,
+}));
+vi.mock("./updater", () => ({
+ hasPendingUpdate: mocks.hasPendingUpdate,
+ installPendingUpdate: mocks.installPendingUpdate,
+}));
+
+import { initQuitFlow, setQuitConfirmGate, _resetForTesting } from "./quit";
+import { initWindowClose, _resetWindowCloseForTesting } from "./window-close";
+import { _resetTeardownArbiterForTesting } from "./teardown-flow";
+import {
+ cancelQuit,
+ confirmQuit,
+ getQuitConfirmIntent,
+ getQuitConfirmPhase,
+ openQuitConfirm,
+ _resetQuitConfirmForTesting,
+} from "./quit-confirm-store";
+
+const listeners = new Map void>();
+const fire = (event: string, payload?: unknown) => listeners.get(event)?.({ payload });
+const quitRequested = (windows = 2) => fire("dormouse://quit-requested", { windows });
+const quitCancelled = () => fire("dormouse://quit-cancelled");
+const closeRequested = () => fire("dormouse://window-close-requested");
+const settle = () => new Promise((r) => setTimeout(r, 0));
+const commands = () => mocks.invoke.mock.calls.map((call) => call[0]);
+const count = (cmd: string) => commands().filter((name) => name === cmd).length;
+
+function fakeAdapter(): TauriAdapter {
+ return {
+ captureAgentRecovery: vi.fn(async () => {}),
+ requestSessionFlush: vi.fn(async () => {}),
+ gracefulKillPtys: vi.fn(async () => {}),
+ drainSessionSaves: vi.fn(async () => {}),
+ } as unknown as TauriAdapter;
+}
+
+describe("one window, two teardown flows", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ _resetForTesting();
+ _resetWindowCloseForTesting();
+ _resetTeardownArbiterForTesting();
+ _resetQuitConfirmForTesting();
+ listeners.clear();
+ mocks.listen.mockImplementation((event: string, cb: (e: { payload?: unknown }) => void) => {
+ listeners.set(event, cb);
+ return Promise.resolve(() => {});
+ });
+ mocks.countRunningSessions.mockReturnValue(1);
+ mocks.hasPendingUpdate.mockReturnValue(false);
+ mocks.invoke.mockResolvedValue(undefined);
+ mocks.archiveSurfaceNotes.mockResolvedValue(undefined);
+ mocks.notepadSurfaceIds.mockReturnValue([]);
+ const adapter = fakeAdapter();
+ initQuitFlow(adapter);
+ initWindowClose(adapter);
+ setQuitConfirmGate(openQuitConfirm);
+ });
+
+ afterEach(() => {
+ setQuitConfirmGate(null);
+ _resetTeardownArbiterForTesting();
+ });
+
+ it("a quit arriving while a close is confirming takes the dialog over", async () => {
+ closeRequested();
+ await settle();
+ expect(getQuitConfirmIntent().kind).toBe("close-window");
+
+ quitRequested();
+ await settle();
+
+ // The close is answered rather than dropped: Rust is holding the window
+ // open on a `prevent_close` waiting for exactly this.
+ expect(count("window_close_cancel")).toBe(1);
+ // …and the quit owns the dialog now.
+ expect(getQuitConfirmPhase()).toBe("open");
+ expect(getQuitConfirmIntent().kind).toBe("quit");
+
+ // Confirming quits: the flow that took over is the one that runs.
+ cancelQuit();
+ await settle();
+ expect(commands()).toContain("quit_cancel");
+ // The close never proceeded.
+ expect(commands()).not.toContain("close_window");
+ });
+
+ it("a close arriving while a quit is confirming is refused at once", async () => {
+ quitRequested();
+ await settle();
+ expect(getQuitConfirmIntent().kind).toBe("quit");
+
+ closeRequested();
+ await settle();
+
+ // Acked, then refused: the window stays and Rust's close watchdog stands
+ // down. Never `quit_cancel` — one window's close does not abort the app's
+ // quit on behalf of every other window.
+ expect(count("window_close_ack")).toBe(1);
+ expect(count("window_close_cancel")).toBe(1);
+ expect(commands()).not.toContain("quit_cancel");
+ // The quit's dialog is untouched, and its context is still live.
+ expect(getQuitConfirmPhase()).toBe("open");
+ expect(getQuitConfirmIntent().kind).toBe("quit");
+
+ cancelQuit();
+ await settle();
+ expect(count("quit_cancel")).toBe(1);
+ });
+
+ it("another window's cancel drops a quit dialog and never a close one", async () => {
+ closeRequested();
+ await settle();
+ expect(getQuitConfirmIntent().kind).toBe("close-window");
+
+ // A quit cancelled elsewhere says nothing about this window's own close.
+ quitCancelled();
+ await settle();
+ expect(getQuitConfirmPhase()).toBe("open");
+ expect(getQuitConfirmIntent().kind).toBe("close-window");
+
+ // …and the close still runs when the user confirms it.
+ cancelQuit();
+ await settle();
+ expect(count("window_close_cancel")).toBe(1);
+ });
+
+ it("a quit meeting a committed close votes rather than starting a second teardown", async () => {
+ mocks.countRunningSessions.mockReturnValue(0); // no dialog: the close commits
+ closeRequested();
+ await settle();
+ expect(commands()).toContain("close_window");
+
+ quitRequested();
+ await settle();
+
+ // Acked, so Rust's ack watchdog stands down, and voted: this window is
+ // ending anyway, and a window that never votes leaves the quit machine in
+ // `Voting` with no dialog for anyone to answer. Never `quit_cancel`, which
+ // would abort the app's quit on behalf of a window already going away.
+ expect(commands()).toContain("quit_ack");
+ expect(count("quit_vote")).toBe(1);
+ expect(commands()).not.toContain("quit_cancel");
+ // …and it started no teardown of its own: the close owns the window.
+ expect(count("close_window")).toBe(1);
+ });
+
+ it("re-asks a quit deferred to a close that then retreated and was cancelled", async () => {
+ // The close commits with no dialog and parks inside its archive — committed,
+ // and so not something the quit may take the dialog away from.
+ mocks.countRunningSessions.mockReturnValue(0);
+ mocks.notepadSurfaceIds.mockReturnValue(["pane-a"]);
+ let failArchive = (_err: Error) => {};
+ mocks.archiveSurfaceNotes.mockReturnValue(
+ new Promise((_resolve, reject) => { failArchive = reject; }),
+ );
+ closeRequested();
+ await settle();
+ expect(commands()).not.toContain("close_window");
+
+ // The quit takes the committed close as a yes and votes.
+ mocks.countRunningSessions.mockReturnValue(1);
+ quitRequested();
+ await settle();
+ expect(count("quit_vote")).toBe(1);
+
+ // The archive fails: the committed close retreats to a question only a
+ // human can answer, which is where it can still be talked out of ending.
+ failArchive(new Error("the archive is locked"));
+ await settle();
+ expect(getQuitConfirmPhase()).toBe("archive-failed");
+
+ // The user declines the archive failure: the close is off and the window
+ // stays — so the quit's own question, never asked, is asked now.
+ cancelQuit();
+ await settle();
+ expect(count("window_close_cancel")).toBe(1);
+ expect(getQuitConfirmPhase()).toBe("open");
+ expect(getQuitConfirmIntent().kind).toBe("quit");
+ expect(count("quit_ack")).toBe(2);
+ });
+
+ /** A close committed with no dialog and parked inside its archive, with a quit
+ * deferred to it and voted; then the archive fails, so the close retreats to
+ * `archive-failed` — undecided again, with the deferred quit still standing. */
+ async function deferredQuitAgainstRetreatedClose(): Promise {
+ mocks.countRunningSessions.mockReturnValue(0);
+ mocks.notepadSurfaceIds.mockReturnValue(["pane-a"]);
+ let failArchive = (_err: Error) => {};
+ mocks.archiveSurfaceNotes.mockReturnValueOnce(
+ new Promise((_resolve, reject) => { failArchive = reject; }),
+ );
+ closeRequested();
+ await settle();
+ mocks.countRunningSessions.mockReturnValue(1);
+ quitRequested();
+ await settle();
+ expect(count("quit_vote")).toBe(1);
+ failArchive(new Error("the archive is locked"));
+ await settle();
+ expect(getQuitConfirmPhase()).toBe("archive-failed");
+ }
+
+ it("gates a quit re-driven through a retreated close exactly once", async () => {
+ await deferredQuitAgainstRetreatedClose();
+
+ // Cmd+Q again. The retreated close is undecided, so the quit abandons it —
+ // and the close's cancel re-drives the deferred quit from inside that call,
+ // opening the quit dialog before the second trigger's own `request`
+ // resumes. That outer request must not gate the intent a second time: the
+ // store refuses a second dialog by cancelling it, which is `quit_cancel`
+ // for the whole app under a "Quit Dormouse?" the user is looking at.
+ quitRequested();
+ await settle();
+ expect(count("window_close_cancel")).toBe(1);
+ expect(getQuitConfirmPhase()).toBe("open");
+ expect(getQuitConfirmIntent().kind).toBe("quit");
+ expect(commands()).not.toContain("quit_cancel");
+
+ // …and the one dialog carries the one vote.
+ confirmQuit();
+ await settle();
+ expect(count("quit_vote")).toBe(2);
+ expect(count("quit_cancel")).toBe(0);
+ });
+
+ it("archives and votes once for a re-driven quit that needs no dialog", async () => {
+ await deferredQuitAgainstRetreatedClose();
+ const archives = mocks.archiveSurfaceNotes.mock.calls.length;
+
+ // Nothing running now: the re-driven quit goes straight to its archive.
+ mocks.countRunningSessions.mockReturnValue(0);
+ quitRequested();
+ await settle();
+ expect(count("window_close_cancel")).toBe(1);
+ expect(mocks.archiveSurfaceNotes.mock.calls.length - archives).toBe(1);
+ // The deferred vote, then the re-driven one — never a third.
+ expect(count("quit_vote")).toBe(2);
+ expect(getQuitConfirmPhase()).toBeNull();
+ });
+
+ it("a quit cancelled elsewhere is not re-driven when the close it deferred to retreats", async () => {
+ mocks.countRunningSessions.mockReturnValue(0);
+ mocks.notepadSurfaceIds.mockReturnValue(["pane-a"]);
+ let failArchive = (_err: Error) => {};
+ mocks.archiveSurfaceNotes.mockReturnValueOnce(
+ new Promise((_resolve, reject) => { failArchive = reject; }),
+ );
+ closeRequested();
+ await settle();
+ mocks.countRunningSessions.mockReturnValue(1);
+ quitRequested();
+ await settle();
+ expect(count("quit_vote")).toBe(1);
+
+ // Another window declined: Rust abandoned that quit, and this window's flow
+ // went back to idle — forgetting what it deferred, not only its own phase.
+ quitCancelled();
+ await settle();
+
+ // Later the close's archive fails and the user declines it. The close is
+ // off and the window stays; a "Quit Dormouse?" for the quit Rust abandoned
+ // would vote into an idle machine and hang on "Quitting…".
+ failArchive(new Error("the archive is locked"));
+ await settle();
+ expect(getQuitConfirmPhase()).toBe("archive-failed");
+ cancelQuit();
+ await settle();
+ expect(count("window_close_cancel")).toBe(1);
+ expect(getQuitConfirmPhase()).toBeNull();
+ expect(count("quit_ack")).toBe(1);
+ });
+
+ it("a refused dialog always settles its context", async () => {
+ // The arbiter should keep this from happening at all; the store is the
+ // backstop, because an unsettled context parks its flow forever.
+ quitRequested();
+ await settle();
+ const ctx = { confirm: vi.fn(), cancel: vi.fn() };
+ openQuitConfirm(ctx, { kind: "close-window" });
+ expect(ctx.cancel).toHaveBeenCalledTimes(1);
+ expect(ctx.confirm).not.toHaveBeenCalled();
+ // …and the standing dialog is untouched.
+ expect(getQuitConfirmIntent().kind).toBe("quit");
+ });
+});
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/teardown-flow.ts b/standalone/src/teardown-flow.ts
new file mode 100644
index 000000000..26563913c
--- /dev/null
+++ b/standalone/src/teardown-flow.ts
@@ -0,0 +1,231 @@
+import { invoke } from "@tauri-apps/api/core";
+import { countRunningSessions } from "dormouse-lib/lib/terminal-registry";
+import { notepadSurfaceIds, removeSurface } from "dormouse-lib/lib/notepad/notepad-store";
+import { getWorkspacesSnapshot } from "dormouse-lib/lib/workspace-store";
+import {
+ dismissQuitConfirm,
+ openQuitArchiveFailure,
+ type QuitConfirmIntent,
+} from "./quit-confirm-store";
+import { archiveNotesBeforeTeardown } from "./teardown-archive";
+
+/**
+ * The shape a quit and a per-window close share: **ack, ask, archive, act**.
+ *
+ * Both are the host preventing an ending, this window deciding whether to take
+ * it, and the host being called back. What differs is only the last step — a
+ * quit votes and waits for its turn in the walk, a close tears down there and
+ * then — and the commands each names. Protocols: `docs/specs/standalone.md` →
+ * "Quit flow" and "Per-window close".
+ *
+ * The two are separate machines over **one** window, one dialog and one human,
+ * so they arbitrate: see `claim` below.
+ */
+
+export interface TeardownConfirmContext {
+ confirm: () => void;
+ cancel: () => void;
+}
+
+/** Puts the running-work question. Owns the decision, and must eventually call
+ * one side of the context. */
+export type TeardownConfirmGate = (ctx: TeardownConfirmContext, intent: QuitConfirmIntent) => void;
+
+export interface TeardownFlow {
+ /** The host asked this window to end. Acks first, then gates. */
+ request(intent: QuitConfirmIntent): void;
+ /** Abort from this window (a declined dialog). */
+ cancel(): void;
+ /** @internal Back to idle, for a decision made elsewhere and for tests. */
+ reset(): void;
+}
+
+/**
+ * How a window names itself in a dialog: by the Workspace it is showing, which
+ * is the only name a user has for one (`docs/specs/standalone.md` → "Quit flow",
+ * Confirmation dialog).
+ */
+export function describeWindow(): string | undefined {
+ const { workspaces, activeId } = getWorkspacesSnapshot();
+ return workspaces.find((workspace) => workspace.id === activeId)?.name;
+}
+
+/**
+ * The one teardown this window is running, if any.
+ *
+ * **A quit outranks a close, and nothing outranks a committed flow.** Both
+ * machines put their question through the same single-slot dialog store and both
+ * owe the host an answer, so the second one to arrive used to be dropped in
+ * silence: its context never settled, and the host waited on a decision that
+ * could not come. Precedence instead:
+ *
+ * | Arriving | Holder | Outcome |
+ * |---|---|---|
+ * | quit | close, undecided | the close is cancelled; the quit takes over |
+ * | quit | anything committed | the quit acks **and votes** — the window is ending anyway, and a quit that never votes leaves the machine in `Voting` with no dialog anywhere |
+ * | close | quit, any state | refused with `window_close_cancel`; the window stays |
+ */
+interface TeardownClaim {
+ kind: QuitConfirmIntent["kind"];
+ /** Whether the flow can still be given up: it is holding a dialog, not
+ * running a teardown. */
+ undecided(): boolean;
+ /** Drop the dialog and settle with the host. Only called while undecided. */
+ abandon(): void;
+}
+let holder: TeardownClaim | null = null;
+
+/**
+ * A quit that voted on behalf of a committed holder, kept until that holder is
+ * done with the window.
+ *
+ * **A committed flow can still retreat**: `archive-failed` puts the notes it
+ * could not store to the user, and a decline there cancels the close and leaves
+ * the window standing — with a quit already voted for it and its own question
+ * never asked. Re-driving the quit intent is what puts that question back.
+ *
+ * `by` is the quit flow that registered it, `against` the holder it waits on.
+ * A quit cancelled elsewhere resets `by` and must forget the entry — Rust has
+ * abandoned that quit, and re-driving it later would open a dialog whose vote
+ * goes into an idle machine.
+ */
+let deferredQuit: { by: TeardownClaim; against: TeardownClaim; rerun: () => void } | null = null;
+
+/** @internal Forget the window-wide claim (tests). */
+export function _resetTeardownArbiterForTesting(): void {
+ holder = null;
+ deferredQuit = null;
+}
+
+export function createTeardownFlow(options: {
+ /** Which machine this is, for the arbiter's precedence. */
+ kind: QuitConfirmIntent["kind"];
+ /** Command that stands the host's ack watchdog down. */
+ ack: string;
+ /** Command that tells the host this window declined. */
+ cancelCommand: string;
+ /** Read at request time, never captured: the quit's gate is registered during
+ * bootstrap, in no fixed order against the flow's own wiring. */
+ gate: () => TeardownConfirmGate | null;
+ /** Whether this window has something to ask about. Defaults to running work. */
+ mustConfirm?: () => boolean;
+ /** Past both gates. A quit votes; a close runs its teardown. */
+ proceed: () => void | Promise;
+}): TeardownFlow {
+ // One flow at a time in this window: repeated triggers are ignored while a
+ // confirmation is outstanding, the archive gate is asking about notes it could
+ // not store, or this window has committed.
+ let phase: "idle" | "confirming" | "archive-failed" | "committed" = "idle";
+
+ const claim: TeardownClaim = {
+ kind: options.kind,
+ undecided: () => phase === "confirming" || phase === "archive-failed",
+ abandon: () => {
+ // The dialog is this flow's — the arbiter allows no other — and it is
+ // dropped rather than cancelled through the store, because `cancel` below
+ // is what owes the host its answer.
+ dismissQuitConfirm();
+ cancel();
+ },
+ };
+
+ function enter(next: "confirming" | "archive-failed" | "committed"): void {
+ phase = next;
+ holder = claim;
+ }
+
+ const cancel = (): void => {
+ phase = "idle";
+ if (holder === claim) holder = null;
+ void invoke(options.cancelCommand).catch(() => {});
+ // This window is not ending after all, and a quit deferred to it never got
+ // to ask its own question. Ask it now.
+ if (deferredQuit?.against === claim) {
+ const { rerun } = deferredQuit;
+ deferredQuit = null;
+ rerun();
+ }
+ };
+
+ async function archiveThenProceed(intent: QuitConfirmIntent): Promise {
+ // Committed from here: the archive is an await, so without this a second
+ // trigger arriving mid-archive would start a parallel flow.
+ enter("committed");
+ try {
+ await archiveNotesBeforeTeardown();
+ } catch (err) {
+ // The host's wait past the ack is unbounded precisely because it waits on
+ // a human, and cancelling here would retire the watchdog that a later
+ // "anyway" still needs. Hold in `archive-failed`, which dedupes a repeat
+ // trigger exactly as a pending confirmation does.
+ enter("archive-failed");
+ openQuitArchiveFailure(
+ err instanceof Error ? err.message : String(err),
+ {
+ confirm: () => {
+ // The user accepts losing these notes: forget them and take the
+ // teardown that now has nothing left to archive.
+ for (const id of notepadSurfaceIds()) removeSurface(id);
+ enter("committed");
+ void options.proceed();
+ },
+ cancel,
+ },
+ intent,
+ );
+ return;
+ }
+ await options.proceed();
+ }
+
+ const flow: TeardownFlow = {
+ request(intent) {
+ // Ack first — stands the host's ack watchdog down even when the trigger is
+ // deduped below (a repeated trigger re-emits, so re-acking is expected).
+ void invoke(options.ack).catch(() => {});
+ if (phase !== "idle") return;
+ if (holder && holder !== claim) {
+ if (options.kind !== "quit") {
+ // A close refused here is answered, never dropped: Rust is holding
+ // the window open on a `prevent_close` waiting for exactly this.
+ cancel();
+ return;
+ }
+ if (!holder.undecided()) {
+ // The holder has committed: this window is being torn down whatever
+ // the quit decides, so the quit takes it as a yes rather than saying
+ // nothing — a window that never votes holds the whole app in `Voting`
+ // with no dialog for the user to answer. Kept, in case that holder
+ // retreats and is cancelled (`deferredQuit`).
+ deferredQuit = { by: claim, against: holder, rerun: () => flow.request(intent) };
+ void options.proceed();
+ return;
+ }
+ holder.abandon();
+ // Abandoning a holder that retreated re-drives the quit deferred to it —
+ // re-entering this `request` from inside the holder's `cancel`, with
+ // the intent already gated by the time control returns here. Gating it
+ // again would open a second dialog into the store's refusal, whose
+ // `cancel` aborts the whole quit under the dialog the rerun opened.
+ if (phase !== "idle") return;
+ }
+
+ // The registry is per webview, so this is already this window's own work.
+ const gate = options.gate();
+ const mustConfirm = options.mustConfirm ?? (() => countRunningSessions() > 0);
+ if (mustConfirm() && gate) {
+ enter("confirming");
+ gate({ confirm: () => void archiveThenProceed(intent), cancel }, intent);
+ return;
+ }
+ void archiveThenProceed(intent);
+ },
+ cancel,
+ reset() {
+ phase = "idle";
+ if (holder === claim) holder = null;
+ if (deferredQuit?.by === claim || deferredQuit?.against === claim) deferredQuit = null;
+ },
+ };
+ return flow;
+}
diff --git a/standalone/src/window-close.test.ts b/standalone/src/window-close.test.ts
new file mode 100644
index 000000000..520093a8f
--- /dev/null
+++ b/standalone/src/window-close.test.ts
@@ -0,0 +1,214 @@
+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(),
+ getWorkspacesSnapshot: vi.fn(() => ({ workspaces: [{ id: "w1", name: "Deploys" }], activeId: "w1" })),
+ hasPendingUpdate: vi.fn(() => false),
+}));
+
+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,
+}));
+// How a window names itself in its dialog: the Workspace it is showing.
+vi.mock("dormouse-lib/lib/workspace-store", () => ({
+ getWorkspacesSnapshot: mocks.getWorkspacesSnapshot,
+}));
+// Closing a window throws away the download it is holding, so the close asks
+// about that too (`docs/specs/auto-update.md` → "Quit-time install").
+vi.mock("./updater", () => ({ hasPendingUpdate: mocks.hasPendingUpdate }));
+
+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([]);
+ mocks.hasPendingUpdate.mockReturnValue(false);
+ });
+
+ 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",
+ "close_window",
+ ]);
+ // 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);
+ 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("close_window");
+ 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("close_window");
+ });
+
+ it("asks about an approved download even with nothing running", async () => {
+ // The download lives in this webview's memory, so closing the window is the
+ // one ending that silently discards it.
+ mocks.countRunningSessions.mockReturnValue(0);
+ mocks.hasPendingUpdate.mockReturnValue(true);
+ const adapter = fakeAdapter();
+ initWindowClose(adapter);
+ closeRequested();
+ await settle();
+
+ expect(getQuitConfirmPhase()).toBe("open");
+ expect(getQuitConfirmIntent()).toMatchObject({ kind: "close-window", discardsUpdate: true });
+ expect(adapter.gracefulKillPtys).not.toHaveBeenCalled();
+ });
+
+ it("says nothing about an update when none is downloaded", async () => {
+ mocks.countRunningSessions.mockReturnValue(1);
+ initWindowClose(fakeAdapter());
+ closeRequested();
+ await settle();
+
+ expect(getQuitConfirmIntent().discardsUpdate).toBeUndefined();
+ });
+
+ 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("close_window");
+ });
+});
diff --git a/standalone/src/window-close.ts b/standalone/src/window-close.ts
new file mode 100644
index 000000000..0d4897a48
--- /dev/null
+++ b/standalone/src/window-close.ts
@@ -0,0 +1,83 @@
+import { invoke } from "@tauri-apps/api/core";
+import { countRunningSessions } from "dormouse-lib/lib/terminal-registry";
+import { openQuitConfirm } from "./quit-confirm-store";
+import { createTeardownFlow, describeWindow } from "./teardown-flow";
+import type { TauriAdapter } from "./tauri-adapter";
+import { hasPendingUpdate } from "./updater";
+import { withTimeout } from "./with-timeout";
+import { listenToWindow } from "./window-label";
+
+/**
+ * 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 `close_window`. 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.
+ *
+ * The ack / confirm / archive half is `createTeardownFlow`, shared with the
+ * quit; what is close-specific is the teardown below.
+ */
+
+const GRACEFUL_KILL_MS = 2000;
+/** The whole teardown, past the human decision. Well under Rust's own budget. */
+const CLOSE_TEARDOWN_CEILING_MS = 8000;
+
+let closeAdapter: TauriAdapter | null = null;
+
+const flow = createTeardownFlow({
+ kind: "close-window",
+ ack: "window_close_ack",
+ cancelCommand: "window_close_cancel",
+ // Always asked, never optional: a close is one window's own decision, and the
+ // dialog host is mounted in every window.
+ gate: () => openQuitConfirm,
+ // An approved download lives in this webview's memory, so closing throws it
+ // away — worth asking about even with nothing running.
+ mustConfirm: () => countRunningSessions() > 0 || hasPendingUpdate(),
+ proceed: runCloseTeardown,
+});
+
+export function initWindowClose(adapter: TauriAdapter): void {
+ closeAdapter = adapter;
+ void listenToWindow("dormouse://window-close-requested", () => {
+ flow.request({
+ kind: "close-window",
+ windowName: describeWindow(),
+ ...(hasPendingUpdate() ? { discardsUpdate: true } : {}),
+ });
+ });
+}
+
+async function runCloseTeardown(): Promise {
+ 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 withTimeout(
+ adapter.gracefulKillPtys(GRACEFUL_KILL_MS),
+ CLOSE_TEARDOWN_CEILING_MS,
+ `[window-close] kill exceeded ${CLOSE_TEARDOWN_CEILING_MS}ms; closing anyway`,
+ );
+ }
+ } 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("close_window").catch(() => {});
+ }
+}
+
+/** @internal Reset module state for testing. */
+export function _resetWindowCloseForTesting(): void {
+ flow.reset();
+ closeAdapter = null;
+}
diff --git a/standalone/src/window-label.ts b/standalone/src/window-label.ts
new file mode 100644
index 000000000..facbda0c3
--- /dev/null
+++ b/standalone/src/window-label.ts
@@ -0,0 +1,64 @@
+/**
+ * Which window this webview is, resolved once at boot.
+ *
+ * The Tauri label is a Window's persistence identity (`docs/specs/glossary.md`)
+ * and the only window that runs the periodic update check is `main`
+ * (`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`.
+ */
+
+import { listen, type Event, type UnlistenFn } from '@tauri-apps/api/event';
+
+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 while it is open, and the only one
+ * that runs the periodic update check or holds `updater:*`
+ * (`capabilities/main-only.json`). With `main` closed the walk still ends on
+ * some window, which has nothing to install (docs/specs/auto-update.md). */
+export function isMainWindow(): boolean {
+ return label === MAIN_WINDOW_LABEL;
+}
+
+/** @internal Set the label directly (tests). */
+export function _setWindowLabelForTesting(next: string): void {
+ label = next;
+}
+
+/**
+ * Listen for an event **addressed to this window**.
+ *
+ * Always this, never the bare `listen`: a listener registered with the default
+ * `Any` target receives every event in the process, including the ones Rust
+ * addressed to another window (`match_any_or_filter` in Tauri's event
+ * listener). Every window would then take every other window's terminal
+ * output, its `pty:list`, its Workspace arrivals and its teardown order — the
+ * routing in `standalone/src-tauri/src/routing.rs` would be decoration.
+ *
+ * A broadcast still arrives: Rust emits those to `EventTarget::Any`, which is
+ * delivered with no filter at all.
+ */
+export function listenToWindow(
+ event: string,
+ handler: (event: Event) => void,
+): Promise {
+ return listen(event, handler, { target: currentWindowLabel() });
+}
diff --git a/standalone/src/window-restore.ts b/standalone/src/window-restore.ts
index 46332ecc1..247639c7f 100644
--- a/standalone/src/window-restore.ts
+++ b/standalone/src/window-restore.ts
@@ -11,7 +11,7 @@
*/
import type { PlatformAdapter, PtyInfo } from "dormouse-lib/lib/platform/types";
-import { collectLivePtys, resumeOrRestoreFrom, type LivePtys } from "dormouse-lib/lib/reconnect";
+import { collectLivePtys, LIST_RETRY_MS, resumeOrRestoreFrom, type LivePtys } from "dormouse-lib/lib/reconnect";
import {
flushWindowSession,
installWindowSessionWriter,
@@ -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,8 +121,23 @@ 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);
+ // Asked twice when the host says nothing at all and this Window has terminal
+ // panes to lose: `resumeOrRestoreFrom` reads a timed-out list as "no live
+ // PTYs" and cold-restores, which starts a second set of shells over the ones
+ // still running (`docs/specs/transport.md` → "Reconnection").
+ const hasTerminalPanes = (saved?.workspaces ?? []).some((workspace) =>
+ workspace.session.panes.some((pane) => pane.surfaceType !== "browser"));
+ const live: LivePtys = await collectLivePtys(platform, {
+ ...(hasTerminalPanes ? { retryTimeoutMs: LIST_RETRY_MS } : {}),
+ });
const restoring: Array<{ id: WorkspaceId; session: PersistedSession | null }> =
saved?.workspaces ?? [{ id: DEFAULT_WORKSPACE_ID, session: null }];
const activeId = saved?.activeWorkspaceId ?? DEFAULT_WORKSPACE_ID;
diff --git a/standalone/src/workspace-drag.test.ts b/standalone/src/workspace-drag.test.ts
new file mode 100644
index 000000000..d9b4a38e4
--- /dev/null
+++ b/standalone/src/workspace-drag.test.ts
@@ -0,0 +1,258 @@
+// @vitest-environment jsdom
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+/**
+ * The host side of the strip drag: where the cursor is, what the release does,
+ * and the caret it leaves in the window it is over
+ * (`docs/specs/standalone.md` → "Dragging a Workspace between windows").
+ */
+
+const mocks = vi.hoisted(() => ({
+ invoke: vi.fn(async (_cmd: string, _args?: unknown) => undefined as unknown),
+ transferWorkspaceTo: vi.fn(async () => {}),
+ tearOutWorkspace: vi.fn(async () => {}),
+}));
+vi.mock("@tauri-apps/api/core", () => ({ invoke: mocks.invoke }));
+vi.mock("./workspace-move", async (importOriginal) => ({
+ ...(await importOriginal()),
+ transferWorkspaceTo: mocks.transferWorkspaceTo,
+ tearOutWorkspace: mocks.tearOutWorkspace,
+}));
+
+import {
+ onDragBackInsideStrip,
+ onDragCancelled,
+ onDragOutsideWindow,
+ onDropOnOtherWindow,
+ _resetWorkspaceDragForTesting,
+} from "./workspace-drag";
+import { _setWindowLabelForTesting } from "./window-label";
+
+const settle = () => vi.advanceTimersByTimeAsync(0);
+/** Past the hit-test throttle, so the next move probes again. */
+const throttleElapsed = () => vi.advanceTimersByTimeAsync(100);
+const hovers = (): Array> =>
+ mocks.invoke.mock.calls
+ .filter(([cmd]) => cmd === "hover_workspace_target")
+ .map(([, args]) => args as Record);
+const lastHover = () => hovers()[hovers().length - 1];
+const probes = () => mocks.invoke.mock.calls.filter(([cmd]) => cmd === "window_at_cursor").length;
+
+/** What `window_at_cursor` answers next. */
+let hit: { label: string; x: number; y: number } | null = null;
+
+/** One tab in this window's strip, so the tear-out grab offset can measure it. */
+function strip(): void {
+ document.body.innerHTML = "";
+ const tab = document.createElement("div");
+ tab.dataset.workspaceTab = "ws-1";
+ tab.getBoundingClientRect = () => ({ left: 0, width: 180, height: 24 }) as DOMRect;
+ document.body.append(tab);
+}
+
+beforeEach(() => {
+ vi.useFakeTimers();
+ vi.clearAllMocks();
+ _resetWorkspaceDragForTesting();
+ _setWindowLabelForTesting("main");
+ hit = null;
+ mocks.invoke.mockImplementation(async (cmd: string) => (cmd === "window_at_cursor" ? hit : undefined));
+ strip();
+});
+
+afterEach(() => vi.useRealTimers());
+
+describe("hit testing while dragging", () => {
+ it("probes at most once per throttle window", async () => {
+ for (let i = 0; i < 10; i += 1) onDragOutsideWindow({ clientX: i, clientY: 100 });
+ await settle();
+ expect(probes()).toBe(1);
+ });
+
+ it("never probes for a pointer that has not moved", async () => {
+ onDragOutsideWindow({ clientX: 900, clientY: 8 });
+ await settle();
+ expect(probes()).toBe(1);
+ // A repeated move at the same point is not a new question, so it must not
+ // cost a round trip once the throttle window is over.
+ await throttleElapsed();
+ onDragOutsideWindow({ clientX: 900, clientY: 8 });
+ await settle();
+ expect(probes()).toBe(1);
+ });
+
+ it("shows a caret in the window under the cursor and clears the one it left", async () => {
+ hit = { label: "ws-2", x: 40, y: 8 };
+ onDragOutsideWindow({ clientX: 900, clientY: 8 });
+ await settle();
+ expect(hovers()).toEqual([{ label: "ws-2", x: 40, y: 8 }]);
+
+ // Moved onto a different window: Rust clears the previous caret itself, so
+ // the host only names the new one.
+ hit = { label: "ws-3", x: 12, y: 8 };
+ await throttleElapsed();
+ onDragOutsideWindow({ clientX: 1400, clientY: 8 });
+ await settle();
+ expect(lastHover()).toEqual({ label: "ws-3", x: 12, y: 8 });
+
+ // Over nothing at all.
+ hit = null;
+ await throttleElapsed();
+ onDragOutsideWindow({ clientX: 2000, clientY: 800 });
+ await settle();
+ expect(lastHover()).toEqual({ label: null, x: 0, y: 0 });
+ });
+
+ it("follows the pointer across the target's tabs instead of lighting one caret", async () => {
+ hit = { label: "ws-2", x: 10, y: 8 };
+ onDragOutsideWindow({ clientX: 900, clientY: 8 });
+ await settle();
+ expect(hovers()).toHaveLength(1);
+
+ // Same window, well past the next tab: the target has to be told, or its
+ // caret stays where the pointer first entered.
+ hit = { label: "ws-2", x: 260, y: 8 };
+ await throttleElapsed();
+ onDragOutsideWindow({ clientX: 1150, clientY: 8 });
+ await settle();
+ expect(lastHover()).toEqual({ label: "ws-2", x: 260, y: 8 });
+
+ // A pixel of travel inside the same slot is not worth an IPC hop.
+ hit = { label: "ws-2", x: 261, y: 8 };
+ await throttleElapsed();
+ onDragOutsideWindow({ clientX: 1151, clientY: 8 });
+ await settle();
+ expect(hovers()).toHaveLength(2);
+ });
+
+ it("probes again where the pointer came to rest", async () => {
+ // The leading edge alone never sees the resting position, which is the one
+ // the caret must show and the one the drop uses.
+ hit = { label: "ws-2", x: 10, y: 8 };
+ onDragOutsideWindow({ clientX: 900, clientY: 8 });
+ await settle();
+ expect(probes()).toBe(1);
+
+ // Inside the same throttle window, and then the pointer stops.
+ hit = { label: "ws-2", x: 300, y: 8 };
+ onDragOutsideWindow({ clientX: 1190, clientY: 8 });
+ await settle();
+ expect(probes()).toBe(1);
+
+ await throttleElapsed();
+ expect(probes()).toBe(2);
+ expect(lastHover()).toEqual({ label: "ws-2", x: 300, y: 8 });
+ });
+
+ it("clears the caret when the pointer comes back over its own strip", async () => {
+ hit = { label: "ws-2", x: 40, y: 8 };
+ onDragOutsideWindow({ clientX: 900, clientY: 8 });
+ await settle();
+ expect(lastHover()).toEqual({ label: "ws-2", x: 40, y: 8 });
+
+ // The in-strip reorder takes the gesture back; a caret left burning in the
+ // other window claims a drop that is no longer going to happen.
+ onDragBackInsideStrip();
+ await settle();
+ expect(lastHover()).toEqual({ label: null, x: 0, y: 0 });
+ });
+
+ it("never shows a caret in its own window", async () => {
+ hit = { label: "main", x: 40, y: 8 };
+ onDragOutsideWindow({ clientX: 100, clientY: 400 });
+ await settle();
+ expect(hovers()).toEqual([]);
+ });
+});
+
+describe("releasing the drag", () => {
+ it("does nothing when released back inside its own strip", async () => {
+ // The strip controller owns its own box, so it says so rather than leaving
+ // this to re-derive it from the DOM.
+ onDropOnOtherWindow("ws-1", { clientX: 100, clientY: 10 }, true);
+ await settle();
+ expect(mocks.transferWorkspaceTo).not.toHaveBeenCalled();
+ expect(mocks.tearOutWorkspace).not.toHaveBeenCalled();
+ });
+
+ it("transfers to the window it was released over", async () => {
+ hit = { label: "ws-2", x: 120, y: 9 };
+ onDropOnOtherWindow("ws-1", { clientX: 900, clientY: 9 }, false);
+ await settle();
+ expect(mocks.transferWorkspaceTo).toHaveBeenCalledWith("ws-1", "ws-2", { x: 120, y: 9 });
+ expect(mocks.tearOutWorkspace).not.toHaveBeenCalled();
+ });
+
+ it("tears out when released over no window", async () => {
+ hit = null;
+ onDropOnOtherWindow("ws-1", { clientX: 2000, clientY: 800 }, false);
+ await settle();
+ expect(mocks.tearOutWorkspace).toHaveBeenCalledWith("ws-1", expect.objectContaining({ x: 90, y: 12 }));
+ });
+
+ it("tears out when released inside its own window but outside the strip", async () => {
+ // The browser gesture: drag the tab down into the body and let go.
+ hit = { label: "main", x: 300, y: 400 };
+ onDropOnOtherWindow("ws-1", { clientX: 300, clientY: 400 }, false);
+ await settle();
+ expect(mocks.tearOutWorkspace).toHaveBeenCalled();
+ expect(mocks.transferWorkspaceTo).not.toHaveBeenCalled();
+ });
+
+ it("clears the hover caret on release", async () => {
+ hit = { label: "ws-2", x: 40, y: 8 };
+ onDragOutsideWindow({ clientX: 900, clientY: 8 });
+ await settle();
+ mocks.invoke.mockClear();
+
+ onDropOnOtherWindow("ws-1", { clientX: 900, clientY: 8 }, true);
+ await settle();
+ expect(hovers()[0]).toEqual({ label: null, x: 0, y: 0 });
+ });
+
+ it("ignores a probe that lands after the release", async () => {
+ // A caret is lit, so the release has something to clear.
+ hit = { label: "ws-2", x: 40, y: 8 };
+ onDragOutsideWindow({ clientX: 900, clientY: 8 });
+ await settle();
+ expect(lastHover()).toEqual({ label: "ws-2", x: 40, y: 8 });
+
+ // A probe is an IPC round trip that can outlive the gesture. Park one.
+ let answerProbe!: (hit: { label: string; x: number; y: number } | null) => void;
+ let parked = false;
+ mocks.invoke.mockImplementation(async (cmd: string) => {
+ if (cmd !== "window_at_cursor" || parked) return undefined;
+ parked = true;
+ return new Promise((resolve) => { answerProbe = resolve; });
+ });
+ await throttleElapsed();
+ onDragOutsideWindow({ clientX: 1400, clientY: 8 });
+ await settle();
+
+ onDropOnOtherWindow("ws-1", { clientX: 1400, clientY: 8 }, true);
+ await settle();
+ expect(lastHover()).toEqual({ label: null, x: 0, y: 0 });
+ const cleared = hovers().length;
+
+ // The stale answer arrives — and must not re-light a caret in a window the
+ // drag has already left, where it would burn until the next drag.
+ answerProbe({ label: "ws-3", x: 12, y: 8 });
+ await settle();
+ expect(hovers()).toHaveLength(cleared);
+ });
+
+ it("clears the hover caret when the drag is abandoned", async () => {
+ hit = { label: "ws-2", x: 40, y: 8 };
+ onDragOutsideWindow({ clientX: 900, clientY: 8 });
+ await settle();
+ mocks.invoke.mockClear();
+
+ // pointercancel, or Escape: nothing moves, and the caret must not be left
+ // burning in the window the pointer happened to be over.
+ onDragCancelled();
+ await settle();
+ expect(hovers()[0]).toEqual({ label: null, x: 0, y: 0 });
+ expect(mocks.transferWorkspaceTo).not.toHaveBeenCalled();
+ expect(mocks.tearOutWorkspace).not.toHaveBeenCalled();
+ });
+});
diff --git a/standalone/src/workspace-drag.ts b/standalone/src/workspace-drag.ts
new file mode 100644
index 000000000..b0c3acfbe
--- /dev/null
+++ b/standalone/src/workspace-drag.ts
@@ -0,0 +1,199 @@
+import { invoke } from "@tauri-apps/api/core";
+import { throttleTrailing } from "dormouse-lib/lib/throttle";
+import type { WorkspaceId } from "dormouse-lib/lib/session-types";
+import type { StripDragPoint } from "dormouse-lib/components/workspace-strip-drag";
+import { currentWindowLabel } from "./window-label";
+import { tearOutWorkspace, transferWorkspaceTo } from "./workspace-move";
+import { workspaceTabRect } from "./workspace-tabs";
+
+/**
+ * The host side of the Workspace strip's drag, past the edge of its own strip
+ * (`docs/specs/standalone.md` → "Dragging a Workspace between windows"). The
+ * strip owns the in-window reorder and never asks anything here for it; this
+ * only answers "which window is the pointer over, and what happens on release".
+ *
+ * A pointer captured on a tab keeps delivering `pointermove` and `pointerup`
+ * outside the window (rationale), so the gesture is the webview's throughout
+ * and the host is only asked where the cursor is.
+ */
+
+/** The cursor probe is an IPC round trip; a pointermove is per frame. */
+const HIT_TEST_THROTTLE_MS = 60;
+/** How far the pointer must travel inside the target before its caret is worth
+ * redrawing. A tab is 180px at most, so this cannot skip a whole slot. */
+const HOVER_BUCKET_PX = 12;
+
+/** Where the cursor is, in the hit window's own logical client space. */
+interface CursorHit {
+ label: string;
+ x: number;
+ y: number;
+}
+
+let probing = false;
+/** A probe was wanted while one was in flight; ask again when it lands. */
+let missed = false;
+let lastPoint: StripDragPoint | null = null;
+let hoverLabel: string | null = null;
+let hoverBucket = -1;
+/**
+ * Bumped by every release and every abandon. A probe is an IPC round trip that
+ * can land after the gesture is over, and its answer would re-light a caret in a
+ * window the drag has already left — burning there until the next drag.
+ */
+let generation = 0;
+
+async function probe(): Promise {
+ try {
+ return (await invoke("window_at_cursor")) ?? null;
+ } catch (err) {
+ console.error("[workspace-drag] window_at_cursor failed", err);
+ return null;
+ }
+}
+
+/**
+ * Show (or clear, with null) the drop caret in another window. Rust clears the
+ * previous one, so a caret can never be left behind in a window the pointer has
+ * left.
+ *
+ * Deduped on the window **and** where in it: keyed on the label alone the target
+ * would draw its caret once and then hold it while the pointer crossed every
+ * remaining tab.
+ */
+function hover(hit: CursorHit | null): void {
+ const label = hit && hit.label !== currentWindowLabel() ? hit.label : null;
+ const bucket = label ? Math.round(hit!.x / HOVER_BUCKET_PX) : -1;
+ if (label === hoverLabel && bucket === hoverBucket) return;
+ hoverLabel = label;
+ hoverBucket = bucket;
+ void invoke("hover_workspace_target", {
+ label,
+ x: hit?.x ?? 0,
+ y: hit?.y ?? 0,
+ }).catch((err) => console.error("[workspace-drag] hover_workspace_target failed", err));
+}
+
+function probeNow(): void {
+ const mine = generation;
+ probing = true;
+ void probe()
+ .then((hit) => {
+ if (mine === generation) hover(hit);
+ })
+ .finally(() => {
+ probing = false;
+ // The window closed on an in-flight probe: the pointer has moved since,
+ // and the caret would otherwise hold wherever that answer put it.
+ if (missed && mine === generation) {
+ missed = false;
+ askToProbe();
+ }
+ });
+}
+
+/**
+ * Probe on the leading edge and again on the trailing one
+ * (`throttleTrailing`).
+ *
+ * The leading edge alone never sees where the pointer came to **rest**, and the
+ * resting position is the one the caret must show — a pointer that stops moving
+ * inside the last throttle window would otherwise leave the caret a tab behind
+ * the drop it is about to make.
+ */
+const askToProbe = throttleTrailing(() => {
+ if (probing) {
+ missed = true;
+ return;
+ }
+ probeNow();
+}, HIT_TEST_THROTTLE_MS);
+
+/** The pointer left this window's strip mid-drag. */
+export function onDragOutsideWindow(point: StripDragPoint): void {
+ // A pointer that has not actually moved must not cost a round trip per
+ // throttle window; a coalesced or repeated move reports the same point.
+ if (lastPoint?.clientX === point.clientX && lastPoint?.clientY === point.clientY) return;
+ lastPoint = point;
+ askToProbe();
+}
+
+/**
+ * The gesture is over, whichever way it ended. Bumping the generation is what
+ * makes an in-flight probe's answer inert: it lands after the caret has been
+ * cleared, and re-lighting one in a window the drag has left would burn there
+ * until the next drag.
+ */
+function endGesture(): void {
+ askToProbe.cancel();
+ missed = false;
+ generation += 1;
+ lastPoint = null;
+ hover(null);
+}
+
+/**
+ * The pointer came back over this window's own strip. The in-strip reorder takes
+ * over from here, and a caret still lit in another window would sit there
+ * claiming a drop that is no longer going to happen.
+ */
+export function onDragBackInsideStrip(): void {
+ endGesture();
+}
+
+/**
+ * The drag was released. `insideStrip` is the strip controller's own answer —
+ * it owns the strip box — and means the live reorder has already committed the
+ * move, so there is nothing left to do but drop the caret. Over another window
+ * it transfers; over nothing — or over this window but outside its strip — it
+ * tears out into a new one.
+ */
+export function onDropOnOtherWindow(
+ id: WorkspaceId,
+ _point: StripDragPoint,
+ insideStrip: boolean,
+): void {
+ // Before the fresh probe below, so an in-flight one's answer cannot re-light
+ // the caret this is about to clear.
+ endGesture();
+ if (insideStrip) return;
+ const grab = grabOffset(id);
+ void (async () => {
+ // Probed fresh rather than reusing the throttled answer: up to
+ // HIT_TEST_THROTTLE_MS of pointer travel could otherwise choose the window.
+ const hit = await probe();
+ if (hit && hit.label !== currentWindowLabel()) {
+ await transferWorkspaceTo(id, hit.label, { x: hit.x, y: hit.y });
+ return;
+ }
+ await tearOutWorkspace(id, grab);
+ })();
+}
+
+/**
+ * Where the dragged tab should sit relative to the new window's top-left, so
+ * the tab lands under the cursor. Centered on the tab rather than tracking the
+ * exact grab point: the pointer left the tab long before the release, so its
+ * offset within it is no longer a position the user is aiming with.
+ */
+function grabOffset(workspaceId: WorkspaceId): { x: number; y: number } {
+ const rect = workspaceTabRect(workspaceId);
+ return { x: (rect?.width ?? 180) / 2, y: (rect?.height ?? 24) / 2 };
+}
+
+/** The drag was abandoned — `pointercancel`, or Escape. Nothing moves, but a
+ * caret lit in another window would otherwise be stranded there. */
+export function onDragCancelled(): void {
+ endGesture();
+}
+
+/** @internal Reset module state for testing. */
+export function _resetWorkspaceDragForTesting(): void {
+ askToProbe.cancel();
+ missed = false;
+ probing = false;
+ lastPoint = null;
+ hoverLabel = null;
+ hoverBucket = -1;
+ generation += 1;
+}
diff --git a/standalone/src/workspace-drop-caret.ts b/standalone/src/workspace-drop-caret.ts
new file mode 100644
index 000000000..ad1d5b906
--- /dev/null
+++ b/standalone/src/workspace-drop-caret.ts
@@ -0,0 +1,54 @@
+import { listenToWindow } from "./window-label";
+import { workspaceDropTarget } from "./workspace-tabs";
+
+/**
+ * The caret another window's drag draws in this window's strip
+ * (`docs/specs/standalone.md` → "Dragging a Workspace between windows"). Rust
+ * pushes the hovered point, and clears it in the window the pointer left, so a
+ * caret can never be stranded.
+ *
+ * Its whole job is to make the hit test's guess visible before the release:
+ * the OS exposes no z-order, so a drag over stacked windows picks the most
+ * recently focused, and this is where a wrong guess shows.
+ */
+
+/** Viewport x of the insertion line, or null while nothing is hovering. */
+let caretX: number | null = null;
+const listeners = new Set<() => void>();
+
+export function subscribeDropCaret(listener: () => void): () => void {
+ listeners.add(listener);
+ return () => void listeners.delete(listener);
+}
+
+export function getDropCaretX(): number | null {
+ return caretX;
+}
+
+function set(next: number | null): void {
+ if (next === caretX) return;
+ caretX = next;
+ for (const listener of listeners) listener();
+}
+
+/** Where the tab would be inserted, as a viewport x. */
+function caretFor(point: { x: number; y: number }): number | null {
+ const { index, rect } = workspaceDropTarget(point.x);
+ // An empty strip has no tab to draw against, so the caret sits at its start.
+ if (!rect) {
+ return document.querySelector("[data-workspace-strip]")?.getBoundingClientRect().left ?? null;
+ }
+ return index === undefined ? rect.right : rect.left;
+}
+
+export function initDropCaret(): void {
+ void listenToWindow<{ x: number; y: number } | null>("dormouse://workspace-drop-hover", (event) => {
+ set(event.payload ? caretFor(event.payload) : null);
+ });
+}
+
+/** @internal Reset module state for testing. */
+export function _resetDropCaretForTesting(): void {
+ caretX = null;
+ listeners.clear();
+}
diff --git a/standalone/src/workspace-move.test.ts b/standalone/src/workspace-move.test.ts
new file mode 100644
index 000000000..92f169edf
--- /dev/null
+++ b/standalone/src/workspace-move.test.ts
@@ -0,0 +1,599 @@
+// @vitest-environment jsdom
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { PlatformAdapter, PtyInfo } from "dormouse-lib/lib/platform/types";
+import type {
+ PreparedWorkspaceTransfer,
+ 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,
+ _resetWorkspaceMovesForTesting,
+} from "./workspace-move";
+import { workspaceDropTarget, workspaceTabRect } from "./workspace-tabs";
+import { registerWallHandle, resetWallHandles, stubWallHandle } from "dormouse-lib/components/wall/wall-handles";
+import {
+ getWorkspaceBootPlan,
+ resetWorkspaceBootPlans,
+} from "dormouse-lib/components/wall/workspace-boot-plans";
+import { createWorkspace, getWorkspacesSnapshot, resetWorkspaces } from "dormouse-lib/lib/workspace-store";
+import { getNotes, clearAllNotepads } from "dormouse-lib/lib/notepad/notepad-store";
+import {
+ getWindowSnapshot,
+ publishWorkspaceSession,
+ resetWindowSessionAggregator,
+} from "dormouse-lib/lib/window-session-aggregator";
+import { getTerminalInstance } from "dormouse-lib/lib/terminal-registry";
+import { setPlatform } from "dormouse-lib/lib/platform";
+import { FakePtyAdapter } from "dormouse-lib/lib/platform/fake-adapter";
+
+const WORKSPACE_ID = "ws-moving";
+
+/** Drain the microtask chain the arrival drain runs on. */
+const settle = () => new Promise((r) => setTimeout(r, 0));
+
+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,
+ };
+}
+
+/** Rust's arrival table: what is in flight into this window, keyed by Workspace. */
+let arrivals: WorkspaceTransferPayload[] = [];
+
+/** A prepared transfer whose commit is observable. */
+function prepared(
+ onCommit: () => void = () => {},
+ overrides: Partial = {},
+): PreparedWorkspaceTransfer {
+ return { payload: payload(overrides), commit: onCommit };
+}
+
+/**
+ * The host half of the protocol, in memory: an arrival lives from the source's
+ * invoke until `adopt_done` or `adopt_failed` retires it, `take_arrivals` does
+ * not consume, and `adopt_ready` answers with **exactly that arrival's** ids.
+ *
+ * The adapter's `pty:list` / `pty:replay` answer only once something asks —
+ * which is the property the `adopt_ready` hop exists to guarantee.
+ */
+function fakePlatform(order: string[] = [], opts: { answer?: boolean } = {}): PlatformAdapter {
+ const platform = new FakePtyAdapter();
+ let listHandler: ((detail: { ptys: PtyInfo[]; requestId?: string }) => void) | null = null;
+ let replayHandler: ((detail: { id: string; data: string; requestId?: 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, args?: unknown) => {
+ order.push(cmd);
+ const workspaceId = (args as { workspaceId?: string } | undefined)?.workspaceId;
+ const settle = () => {
+ const at = arrivals.findIndex((arrival) => arrival.workspaceId === workspaceId);
+ if (at < 0) throw new Error(`no arrival of '${workspaceId}'`);
+ arrivals.splice(at, 1);
+ };
+ if (cmd === "take_arrivals") return arrivals.map((arrival) => ({ ...arrival }));
+ if (cmd === "adopt_done" || cmd === "adopt_failed") { settle(); return undefined; }
+ if (cmd === "adopt_ready") {
+ const arrival = arrivals.find((entry) => entry.workspaceId === workspaceId);
+ if (!arrival) throw new Error(`no arrival of '${workspaceId}'`);
+ if (opts.answer === false) return undefined;
+ order.push(`answered:${workspaceId}`);
+ // The host echoes the collector's own token, and lists exactly this
+ // arrival's ids: two arrivals at once must not finish on each other's.
+ const requestId = (args as { requestId?: string } | undefined)?.requestId;
+ listHandler?.({
+ ptys: arrival.terminalIds.map((id) => ({ id, alive: true }) as PtyInfo),
+ requestId,
+ });
+ for (const id of arrival.terminalIds) {
+ replayHandler?.({ id, data: `scrollback:${id}`, requestId });
+ }
+ }
+ return undefined;
+ });
+ setPlatform(platform);
+ return platform;
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ arrivals = [];
+ mocks.invoke.mockResolvedValue(undefined);
+ mocks.listen.mockResolvedValue(() => {});
+ resetWallHandles();
+ resetWorkspaceBootPlans();
+ resetWorkspaces();
+ resetWindowSessionAggregator();
+ clearAllNotepads();
+ _resetWorkspaceMovesForTesting();
+});
+
+/** 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);
+ await new Promise((r) => setTimeout(r, 0));
+};
+
+describe("the source half", () => {
+ it("prepares the Workspace, tells the host, and commits only when it lands", async () => {
+ const order: string[] = [];
+ mocks.invoke.mockImplementation(async (cmd: string) => void order.push(cmd));
+ registerWallHandle(stubWallHandle(WORKSPACE_ID, {
+ prepareWorkspaceTransfer: async () => {
+ order.push("prepare");
+ return prepared(() => order.push("commit"));
+ },
+ }));
+ initWorkspaceMoves(fakePlatform());
+ mocks.invoke.mockImplementation(async (cmd: string) => void order.push(cmd));
+
+ await transferWorkspaceTo(WORKSPACE_ID, "ws-2", { x: 10, y: 4 });
+
+ // The record is built while the Sessions are live. Nothing is released at
+ // the invoke: the target can still refuse, and a Workspace released here
+ // would have no Sessions and no window that owned them.
+ expect(order).toEqual(["prepare", "transfer_workspace"]);
+ const [, args] = mocks.invoke.mock.calls.find(([cmd]) => cmd === "transfer_workspace")!;
+ expect(args).toMatchObject({ to: "ws-2", payload: { at: { x: 10, y: 4 }, terminalIds: ["pane-a"] } });
+
+ await emit("dormouse://workspace-departed", { workspaceId: WORKSPACE_ID });
+ expect(order).toContain("commit");
+ });
+
+ it("keeps a transferring Workspace out of every snapshot until it settles", async () => {
+ // Its shells already belong to the target, so a quit in the gap must not
+ // write the same Workspace into two Windows and restore it twice.
+ initWorkspaceMoves(fakePlatform());
+ publishWorkspaceSession(WORKSPACE_ID, payload().workspace.session);
+ createWorkspace({ id: WORKSPACE_ID, name: "Deploys" });
+ registerWallHandle(stubWallHandle(WORKSPACE_ID, {
+ prepareWorkspaceTransfer: async () => prepared(),
+ }));
+ expect(getWindowSnapshot().workspaces.map((w) => w.id)).toContain(WORKSPACE_ID);
+
+ await transferWorkspaceTo(WORKSPACE_ID, "ws-2", { x: 10, y: 4 });
+ expect(getWindowSnapshot().workspaces.map((w) => w.id)).not.toContain(WORKSPACE_ID);
+
+ // Refused: it is this Window's again, snapshot included.
+ await emit("dormouse://workspace-arrival-failed", { workspaceId: WORKSPACE_ID, reason: "closed" });
+ expect(getWindowSnapshot().workspaces.map((w) => w.id)).toContain(WORKSPACE_ID);
+ });
+
+ it("keeps the Workspace, with its Sessions, when the target never adopts it", async () => {
+ // The target window closed mid-arrival: Rust hands the shells back and says
+ // so. Nothing was released, so there is nothing to restore.
+ const committed = vi.fn();
+ initWorkspaceMoves(fakePlatform());
+ registerWallHandle(stubWallHandle(WORKSPACE_ID, {
+ prepareWorkspaceTransfer: async () => prepared(committed),
+ }));
+ createWorkspace({ id: WORKSPACE_ID, name: "Deploys" });
+
+ await transferWorkspaceTo(WORKSPACE_ID, "ws-2", { x: 10, y: 4 });
+ await emit("dormouse://workspace-arrival-failed", {
+ workspaceId: WORKSPACE_ID,
+ reason: "the target window closed mid-arrival",
+ });
+
+ expect(committed).not.toHaveBeenCalled();
+ expect(getWorkspacesSnapshot().workspaces.map((w) => w.id)).toContain(WORKSPACE_ID);
+ });
+
+ it("releases only the Workspace that departed", async () => {
+ // Two in flight into the same window: one landing must not take the other
+ // with it (Rust announces one departure per arrival, from its `adopt_done`).
+ const committed = { first: vi.fn(), second: vi.fn() };
+ initWorkspaceMoves(fakePlatform());
+ for (const [id, commit] of [["ws-a", committed.first], ["ws-b", committed.second]] as const) {
+ createWorkspace({ id, name: id });
+ registerWallHandle(stubWallHandle(id, {
+ prepareWorkspaceTransfer: async () => prepared(commit, { workspaceId: id }),
+ }));
+ await transferWorkspaceTo(id, "ws-2", { x: 0, y: 0 });
+ }
+
+ await emit("dormouse://workspace-departed", { workspaceId: "ws-a" });
+
+ expect(committed.first).toHaveBeenCalledTimes(1);
+ expect(committed.second).not.toHaveBeenCalled();
+ expect(getWorkspacesSnapshot().workspaces.map((w) => w.id)).toContain("ws-b");
+ });
+
+ it("leaves the source Workspace intact when the host refuses", async () => {
+ // The target window can close between the drag's last probe and the drop.
+ mocks.invoke.mockRejectedValue(new Error("no window 'ws-2'"));
+ const committed = vi.fn();
+ registerWallHandle(stubWallHandle(WORKSPACE_ID, {
+ prepareWorkspaceTransfer: async () => prepared(committed),
+ }));
+
+ await transferWorkspaceTo(WORKSPACE_ID, "ws-2", { x: 10, y: 4 });
+
+ expect(committed).not.toHaveBeenCalled();
+ });
+
+ it("leaves the source Workspace intact when the tear-out cannot build a window", async () => {
+ mocks.invoke.mockRejectedValue(new Error("build window ws-3: no display"));
+ const committed = vi.fn();
+ registerWallHandle(stubWallHandle(WORKSPACE_ID, {
+ prepareWorkspaceTransfer: async () => prepared(committed),
+ }));
+
+ await tearOutWorkspace(WORKSPACE_ID, { x: 90, y: 12 });
+
+ expect(committed).not.toHaveBeenCalled();
+ });
+
+ it("tears out into a new window carrying the tab's grab offset", async () => {
+ registerWallHandle(stubWallHandle(WORKSPACE_ID, { prepareWorkspaceTransfer: async () => prepared() }));
+ // Only Rust knows where the cursor is on screen, so the payload carries
+ // where the tab should sit inside the new window rather than a position.
+ await tearOutWorkspace(WORKSPACE_ID, { x: 90, y: 12 });
+ expect(mocks.invoke).toHaveBeenCalledWith("open_workspace_window", {
+ payload: expect.objectContaining({ grab: { x: 90, y: 12 } }),
+ });
+ });
+
+ 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", () => {
+ it("arms its collector, asks by Workspace, mounts, and only then releases the source", async () => {
+ const order: string[] = [];
+ const platform = fakePlatform(order);
+ arrivals = [payload()];
+ initWorkspaceMoves(platform);
+ await settle();
+
+ // The `adopt_ready` hop is what removes the "arrived before armed" bug
+ // class: nothing is listed or replayed until the collector is listening.
+ // `adopt_done` is last, because it is what tells the source to let go.
+ expect(order).toEqual(["take_arrivals", "adopt_ready", `answered:${WORKSPACE_ID}`, "adopt_done"]);
+ expect(mocks.invoke).toHaveBeenCalledWith("adopt_ready", expect.objectContaining({ workspaceId: WORKSPACE_ID }));
+ // 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("resumes each of two simultaneous arrivals over its own PTYs", async () => {
+ // A tear-out with a second tab dropped on it moments later. A window-wide
+ // answer would let each collector finish on the other's shells.
+ const order: string[] = [];
+ const platform = fakePlatform(order);
+ arrivals = [
+ payload({ workspaceId: "ws-a", terminalIds: ["pane-a"], allIds: ["pane-a"] }),
+ payload({
+ workspaceId: "ws-b",
+ workspace: {
+ id: "ws-b",
+ name: "Builds",
+ session: {
+ version: 3,
+ panes: [{ id: "pane-b", title: "b", cwd: "/tmp", untouched: false, alert: null }],
+ },
+ },
+ notepad: { surfaces: [], stagedDeletions: {} },
+ terminalIds: ["pane-b"],
+ allIds: ["pane-b"],
+ }),
+ ];
+ arrivals[0]!.workspace = { ...arrivals[0]!.workspace, id: "ws-a" };
+
+ initWorkspaceMoves(platform);
+ await settle();
+
+ expect(order.filter((entry) => entry.startsWith("answered")))
+ .toEqual(["answered:ws-a", "answered:ws-b"]);
+ expect(getWorkspaceBootPlan("ws-a")?.initialPaneIds).toEqual(["pane-a"]);
+ expect(getWorkspaceBootPlan("ws-b")?.initialPaneIds).toEqual(["pane-b"]);
+ // Both settled, so Rust is holding nothing.
+ expect(arrivals).toEqual([]);
+ });
+
+ it("mounts a browser-only arrival, which names no PTYs at all", async () => {
+ // Distinguishable from a swept suppression precisely because the record
+ // says `terminalIds: []`: the host answers with an empty list at once
+ // rather than leaving the collector to sit out its timeout.
+ const platform = fakePlatform();
+ arrivals = [payload({ terminalIds: [], allIds: ["browser-1"] })];
+
+ initWorkspaceMoves(platform);
+ await settle();
+
+ expect(getWorkspacesSnapshot().workspaces.map((w) => w.name)).toContain("Deploys");
+ expect(mocks.invoke).toHaveBeenCalledWith("adopt_done", { workspaceId: WORKSPACE_ID });
+ });
+
+ it("mounts each arrival once, however often the queue is drained", async () => {
+ // `take_arrivals` does not consume: the record settles at `adopt_done`, and
+ // the boot drain and the nudge overlap by design.
+ const platform = fakePlatform();
+ arrivals = [payload()];
+ initWorkspaceMoves(platform);
+ await emit("dormouse://workspace-arriving", undefined);
+ await settle();
+
+ expect(getWorkspacesSnapshot().workspaces.filter((w) => w.id === WORKSPACE_ID)).toHaveLength(1);
+ });
+
+ it("seeds a persisted TODO into this window's own AlertManager", async () => {
+ const platform = fakePlatform();
+ const alert = { kind: "todo" } as never;
+ const moving = payload();
+ moving.workspace.session.panes[0]!.alert = alert;
+ arrivals = [moving];
+
+ initWorkspaceMoves(platform);
+ await settle();
+
+ expect(platform.alertSeed).toHaveBeenCalledWith("pane-a", alert);
+ });
+
+ it("hands the Workspace back when the host never answers, rather than restarting live shells", async () => {
+ // A timed-out collection is not a collection that found no PTYs: those
+ // shells are still running, and a cold restore would start a second set.
+ vi.useFakeTimers();
+ try {
+ const platform = fakePlatform([], { answer: false });
+ arrivals = [payload()];
+ initWorkspaceMoves(platform);
+ await vi.advanceTimersByTimeAsync(5000);
+
+ expect(getWorkspacesSnapshot().workspaces.map((w) => w.name)).not.toContain("Deploys");
+ expect(getWorkspaceBootPlan(WORKSPACE_ID)).toEqual({});
+ expect(mocks.invoke).toHaveBeenCalledWith("adopt_failed", expect.objectContaining({
+ workspaceId: WORKSPACE_ID,
+ }));
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it("unwinds the mount when adopt_done is refused, releasing the Sessions rather than killing them", async () => {
+ const platform = fakePlatform();
+ const killPty = vi.spyOn(platform, "killPty");
+ const host = mocks.invoke.getMockImplementation()!;
+ mocks.invoke.mockImplementation(async (cmd: string, args?: unknown) => {
+ // The `ARRIVAL_MAX` watchdog retired the record while this window was
+ // wedged between the drain and the mount: Rust has handed the shells
+ // back to the source, so `adopt_done` finds no arrival to settle.
+ if (cmd === "adopt_done") arrivals = [];
+ return host(cmd, args);
+ });
+ // The Wall this window mounts for the arrival, with its release observable.
+ const released = vi.fn();
+ registerWallHandle(stubWallHandle(WORKSPACE_ID, {
+ prepareWorkspaceTransfer: async () => prepared(released),
+ }));
+ arrivals = [payload()];
+ initWorkspaceMoves(platform);
+ await settle();
+ await settle();
+
+ expect(mocks.invoke).toHaveBeenCalledWith("adopt_done", { workspaceId: WORKSPACE_ID });
+ // The source kept the Workspace, so this window holds none of it: not in
+ // the store, not in the snapshot it writes, no plan parked for it.
+ expect(getWorkspacesSnapshot().workspaces.map((w) => w.id)).not.toContain(WORKSPACE_ID);
+ expect(getWindowSnapshot().workspaces.map((w) => w.id)).not.toContain(WORKSPACE_ID);
+ expect(getWorkspaceBootPlan(WORKSPACE_ID)).toEqual({});
+ // Its Sessions were released — the shells are the source's again — and
+ // nothing was killed.
+ expect(released).toHaveBeenCalledTimes(1);
+ expect(killPty).not.toHaveBeenCalled();
+ expect(mocks.invoke).not.toHaveBeenCalledWith("adopt_failed", expect.anything());
+ });
+
+ it("closes the window when its last Workspace leaves, instead of emptying it", async () => {
+ initWorkspaceMoves(fakePlatform());
+ const workspaceId = getWorkspacesSnapshot().activeId;
+ registerWallHandle(stubWallHandle(workspaceId, {
+ prepareWorkspaceTransfer: async () => prepared(() => {}, { workspaceId }),
+ }));
+ await transferWorkspaceTo(workspaceId, "ws-2", { x: 0, y: 0 });
+
+ await emit("dormouse://workspace-departed", { workspaceId });
+
+ // 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");
+ expect(getWorkspacesSnapshot().workspaces).toHaveLength(1);
+ });
+});
+
+describe("a torn-out window's boot", () => {
+ it("boots from the queued payload rather than from disk", async () => {
+ const order: string[] = [];
+ arrivals = [payload()];
+ const platform = fakePlatform(order);
+
+ const plans = await bootFromTearOut(platform);
+
+ expect(order.slice(0, 2)).toEqual(["take_arrivals", "adopt_ready"]);
+ expect(order).toContain("adopt_done");
+ 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("boots fresh without installing a refused tear-out or retaining its Sessions", async () => {
+ const platform = fakePlatform();
+ const kill = vi.spyOn(platform, "killPty");
+ const host = mocks.invoke.getMockImplementation()!;
+ mocks.invoke.mockImplementation(async (cmd, args) => {
+ if (cmd === "adopt_done") throw new Error("arrival expired");
+ return host(cmd, args);
+ });
+ arrivals = [payload()];
+ expect(await bootFromTearOut(platform)).toBeNull();
+ expect(getWorkspacesSnapshot().workspaces.map((w) => w.id)).not.toContain(WORKSPACE_ID);
+ expect(getWindowSnapshot().workspaces.map((w) => w.id)).not.toContain(WORKSPACE_ID);
+ expect(getNotes("pane-a")).toHaveLength(0);
+ expect(getTerminalInstance("pane-a")).toBeNull();
+ expect(kill).not.toHaveBeenCalled();
+ });
+
+ it("boots fresh, never blank, when the sole arrival cannot be resumed", async () => {
+ // `planArrival` throwing into `bootstrap()` would take the whole launch
+ // down before `render`. Null instead: the caller restores a fresh Window.
+ vi.useFakeTimers();
+ try {
+ const platform = fakePlatform([], { answer: false });
+ arrivals = [payload()];
+ const plans = bootFromTearOut(platform);
+ await vi.advanceTimersByTimeAsync(5000);
+
+ await expect(plans).resolves.toBeNull();
+ expect(mocks.invoke).toHaveBeenCalledWith("adopt_failed", expect.objectContaining({
+ workspaceId: WORKSPACE_ID,
+ }));
+ // Nothing half-installed: the fresh restore owns the Window from here.
+ expect(getWorkspacesSnapshot().workspaces.map((w) => w.name)).not.toContain("Deploys");
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it("returns null for an ordinary window", async () => {
+ const platform = fakePlatform();
+ expect(await bootFromTearOut(platform)).toBeNull();
+ });
+
+ it("leaves a window with a snapshot to restore itself", async () => {
+ // A drop that landed while an ordinary window was booting is mounted over
+ // the restore, not instead of it.
+ const platform = fakePlatform();
+ arrivals = [payload()];
+ (platform as unknown as { getWindowState: () => unknown }).getWindowState = () => ({
+ version: 1,
+ workspaces: [{ id: "saved", name: "Saved", session: { version: 3, panes: [] } }],
+ activeWorkspaceId: "saved",
+ });
+
+ expect(await bootFromTearOut(platform)).toBeNull();
+ expect(mocks.invoke).not.toHaveBeenCalledWith("adopt_ready", expect.anything());
+ });
+});
+
+/** One scan of the strip, shared by the drop index, the caret, and the tear-out
+ * grab offset (`standalone/src/workspace-tabs.ts`). */
+describe("workspaceDropTarget", () => {
+ function strip(count: number): void {
+ document.body.innerHTML = "";
+ for (let index = 0; index < count; index += 1) {
+ const tab = document.createElement("div");
+ tab.dataset.workspaceTab = `w${index}`;
+ tab.getBoundingClientRect = () =>
+ ({ left: index * 100, right: index * 100 + 100, width: 100, height: 24 }) as DOMRect;
+ document.body.append(tab);
+ }
+ }
+
+ it("inserts before the first tab whose center the drop is left of", () => {
+ strip(3);
+ expect(workspaceDropTarget(10).index).toBe(0);
+ // Between tab 1's center (150) and tab 2's (250): it takes index 2.
+ expect(workspaceDropTarget(160).index).toBe(2);
+ // Past the last tab's center: appended, which is what undefined means.
+ expect(workspaceDropTarget(900).index).toBeUndefined();
+ });
+
+ it("hands back the box the caret draws against, and null with no tabs", () => {
+ strip(3);
+ // The tab the caret goes to the left of...
+ expect(workspaceDropTarget(160).rect?.left).toBe(200);
+ // ...and, when appending, the last tab, whose right edge it goes after.
+ expect(workspaceDropTarget(900).rect?.right).toBe(300);
+ strip(0);
+ expect(workspaceDropTarget(10)).toEqual({ index: undefined, rect: null });
+ });
+
+ it("finds one Workspace's own tab, and answers null for one not rendered", () => {
+ strip(3);
+ expect(workspaceTabRect("w1")?.left).toBe(100);
+ expect(workspaceTabRect("gone")).toBeNull();
+ });
+});
diff --git a/standalone/src/workspace-move.ts b/standalone/src/workspace-move.ts
new file mode 100644
index 000000000..7516f3b6a
--- /dev/null
+++ b/standalone/src/workspace-move.ts
@@ -0,0 +1,382 @@
+import { invoke } from "@tauri-apps/api/core";
+import { releaseSession } from "dormouse-lib/lib/terminal-registry";
+import { forgetHelper } from "dormouse-lib/lib/helper-terminal";
+import { collectLivePtys, resumeOrRestoreFrom } from "dormouse-lib/lib/reconnect";
+import { hydrateNotepadFromVolatile, removeSurface } from "dormouse-lib/lib/notepad/notepad-store";
+import { getWallHandle } from "dormouse-lib/components/wall/wall-handles";
+import { forgetWorkspaceBootPlan, setWorkspaceBootPlan } from "dormouse-lib/components/wall/workspace-boot-plans";
+import { wallBootFromResult, type WallBootPlans } from "dormouse-lib/components/wall/wall-types";
+import type { PreparedWorkspaceTransfer, WorkspaceTransferPayload } from "dormouse-lib/components/wall/workspace-transfer";
+import {
+ clearWorkspaceTransferring,
+ forgetWorkspaceSession,
+ markWorkspaceTransferring,
+ 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";
+import { listenToWindow } from "./window-label";
+import { workspaceDropTarget } from "./workspace-tabs";
+
+/**
+ * Moving a Workspace between Windows (`docs/specs/standalone.md` → "Transfer",
+ * "Tear-out" and "Arrival queue"). Both halves live here, because they are one
+ * protocol, and the whole of it is **one transaction keyed by `workspaceId`**:
+ * Rust holds an arrival record from the source's invoke until the target adopts
+ * the Workspace or dies, and every step below either settles that record or
+ * waits on it.
+ *
+ * - **Source**: build the payload, hand it to Rust, and mark the Workspace
+ * *transferring* — still mounted, still holding its Sessions, but in no
+ * snapshot this Window writes. It commits on `workspace-departed` and puts
+ * itself back on `workspace-arrival-failed`.
+ * - **Target**: drain the arrivals, arm a collector, ask Rust for *that
+ * arrival's* PTYs, mount the Workspace, and call `adopt_done` — which is what
+ * releases the source.
+ *
+ * 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 };
+ /** Where the dragged tab should sit inside the new window, so it lands under
+ * the cursor. Rust turns it into the window's position, because only Rust
+ * knows where the cursor is on the screen. */
+ grab?: { 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 ------------------------------------------------------------------
+
+/**
+ * Workspaces this Window has handed over and not yet released, by id.
+ *
+ * **Nothing is released at the invoke.** The target can refuse the arrival, or
+ * close before it takes it, and Rust hands the shells straight back — so the
+ * Wall stays mounted, the notes stay put, and the only thing that changed here
+ * is that the Workspace is in no snapshot (`markWorkspaceTransferring`).
+ */
+const inFlight = new Map();
+
+async function prepare(workspaceId: WorkspaceId): Promise {
+ const handle = getWallHandle(workspaceId);
+ if (!handle) return null;
+ return handle.prepareWorkspaceTransfer();
+}
+
+/**
+ * Hand the prepared Workspace to Rust and mark it transferring **only on
+ * success**.
+ *
+ * A rejected invoke is an ordinary state — the target window can close between
+ * the drag's last probe and the drop — and Rust hands the PTYs back to this
+ * window before it returns the error, so this Window is left exactly as it was.
+ */
+async function handOff(
+ prepared: PreparedWorkspaceTransfer,
+ command: string,
+ args: Record,
+): Promise {
+ try {
+ await invoke(command, args);
+ } catch (err) {
+ console.warn(`[workspace-move] ${command} refused; the Workspace stays here`, err);
+ return;
+ }
+ const { workspaceId } = prepared.payload;
+ inFlight.set(workspaceId, prepared);
+ markWorkspaceTransferring(workspaceId);
+}
+
+/** Hand this Workspace to a window that already exists. */
+export async function transferWorkspaceTo(
+ workspaceId: WorkspaceId,
+ to: string,
+ at: { x: number; y: number },
+): Promise {
+ const prepared = await prepare(workspaceId);
+ if (!prepared) return;
+ await handOff(prepared, "transfer_workspace", {
+ to,
+ payload: { ...prepared.payload, at } satisfies MovePayload,
+ });
+}
+
+/** Tear this Workspace out into a new window under the cursor. */
+export async function tearOutWorkspace(
+ workspaceId: WorkspaceId,
+ grab: { x: number; y: number },
+): Promise {
+ const prepared = await prepare(workspaceId);
+ if (!prepared) return;
+ await handOff(prepared, "open_workspace_window", {
+ payload: { ...prepared.payload, grab } satisfies MovePayload,
+ });
+}
+
+/**
+ * The target adopted it: **the point of no return**. Detach every Session (never
+ * kill one — they are running in the other Window now), drop the notes, and take
+ * the Workspace out of the strip.
+ */
+function handleDeparted(workspaceId: WorkspaceId): void {
+ const prepared = inFlight.get(workspaceId);
+ if (!prepared) {
+ console.warn("[workspace-move] a departure for a Workspace that was not in flight", workspaceId);
+ return;
+ }
+ inFlight.delete(workspaceId);
+ prepared.commit();
+ // 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").catch((err) =>
+ console.error("[workspace-move] close_window failed", err));
+ return;
+ }
+ closeWorkspace(workspaceId);
+ forgetWorkspaceSession(workspaceId);
+}
+
+/**
+ * The target never took it. Nothing was released, so there is nothing to put
+ * back: drop the transferring mark and the Workspace is simply still here, its
+ * xterms receiving output again the moment Rust unsuppresses them.
+ */
+function handleArrivalFailed(workspaceId: WorkspaceId, reason: string): void {
+ if (!inFlight.delete(workspaceId)) return;
+ clearWorkspaceTransferring(workspaceId);
+ console.warn(`[workspace-move] ${workspaceId} was not adopted (${reason}); it stays here`);
+}
+
+// --- Target ------------------------------------------------------------------
+
+/**
+ * Workspaces this Window is mounting right now. `take_arrivals` answers with
+ * every record still in flight — the boot drain and the listener drain overlap
+ * by design — so the same payload can be handed over twice before `adopt_done`
+ * has retired it.
+ */
+const adopting = new Set();
+
+/**
+ * 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, and it answers with **exactly this arrival's** PTYs, so two
+ * Workspaces landing at once cannot resume over each other's.
+ *
+ * **Throws rather than cold-restoring when the host never answers.** An arrival
+ * whose `pty:list` did not come back is not an arrival with no PTYs: those
+ * shells are still running, and restoring from the record would start a second
+ * set over them. Every caller turns the throw into `adopt_failed`.
+ */
+async function planArrival(
+ platform: PlatformAdapter,
+ payload: MovePayload,
+): Promise {
+ const ptyIds = new Set(payload.terminalIds);
+ const live = await collectLivePtys(platform, {
+ // The token rides through Rust to the sidecar's `list` and comes back on the
+ // answer, so two Workspaces arriving at once cannot finish on each other's.
+ trigger: (requestId) =>
+ void invoke("adopt_ready", { workspaceId: payload.workspaceId, requestId }).catch((err) =>
+ console.error("[workspace-move] adopt_ready failed", err)),
+ accept: (id) => ptyIds.has(id),
+ timeoutMs: ARRIVAL_TIMEOUT_MS,
+ });
+ if (live.timedOut) {
+ throw new Error(
+ `the arriving Workspace's PTYs did not answer within ${ARRIVAL_TIMEOUT_MS}ms; `
+ + "refusing rather than restarting shells that are still running",
+ );
+ }
+ 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);
+}
+
+/** Settle one arrival with Rust, whichever way it went. */
+function settle(command: "adopt_done" | "adopt_failed", workspaceId: WorkspaceId, reason?: string): void {
+ void invoke(command, { workspaceId, ...(reason === undefined ? {} : { reason }) }).catch((err) =>
+ console.error(`[workspace-move] ${command} failed`, err));
+}
+
+/** Mount an arriving Workspace, then release its source. */
+async function adoptWorkspace(platform: PlatformAdapter, payload: MovePayload): Promise {
+ const { id, name, session } = payload.workspace;
+ if (adopting.has(id)) return;
+ adopting.add(id);
+ try {
+ 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);
+ // Where in this window's strip the pointer released. This window alone knows
+ // its own tabs, which is why the source sends a point rather than an index.
+ const index = payload.at ? workspaceDropTarget(payload.at.x).index : undefined;
+ createWorkspace({ id, name });
+ if (index !== undefined) moveWorkspace(id, index);
+ setActiveWorkspace(id);
+ // Last, and only now: it is what tells the source to let the Workspace go.
+ // Awaited, because a refusal is the one signal that the transaction was
+ // retired underneath this window.
+ try {
+ await invoke("adopt_done", { workspaceId: id });
+ } catch (err) {
+ console.error("[workspace-move] adopt_done refused; unwinding the mount", err);
+ await unwindAdoption(id);
+ }
+ } catch (err) {
+ console.error("[workspace-move] adoption failed; handing the Workspace back", err);
+ settle("adopt_failed", id, err instanceof Error ? err.message : String(err));
+ } finally {
+ adopting.delete(id);
+ }
+}
+
+/**
+ * `adopt_done` was refused: the `ARRIVAL_MAX` watchdog had already expired the
+ * record and handed the shells back, and the source cleared its transferring
+ * mark and kept the Workspace. Left mounted here too, the same Workspace would
+ * be live in two windows and persisted by both — the next launch restoring it
+ * twice over one set of PTYs. Take it out the way a departure does: the
+ * Sessions released, **never killed**, because the shells are the source's
+ * again; the notes dropped; the record and the parked plan forgotten.
+ */
+async function unwindAdoption(id: WorkspaceId): Promise {
+ const handle = getWallHandle(id);
+ if (handle) (await handle.prepareWorkspaceTransfer()).commit();
+ closeWorkspace(id);
+ forgetWorkspaceSession(id);
+ forgetWorkspaceBootPlan(id);
+}
+
+/**
+ * Every Workspace Rust is still holding for this window.
+ *
+ * Drained rather than pushed: an `emit_to` a window with no listener yet is
+ * lost, and a window still booting — or torn out moments ago — is a legal drop
+ * target (`docs/specs/standalone.md` → "Arrival queue"). The answer is not
+ * consumed by the drain, so `adopting` is what keeps one from being mounted
+ * twice.
+ */
+async function drainArrivals(): Promise {
+ try {
+ return (await invoke("take_arrivals")) ?? [];
+ } catch (err) {
+ console.error("[workspace-move] take_arrivals failed", err);
+ return [];
+ }
+}
+
+/** Listen for Workspaces arriving in, and leaving, this window. */
+export function initWorkspaceMoves(platform: PlatformAdapter): void {
+ const adoptQueued = async () => {
+ for (const payload of await drainArrivals()) await adoptWorkspace(platform, payload);
+ };
+ void listenToWindow("dormouse://workspace-arriving", () => {
+ void adoptQueued();
+ });
+ void listenToWindow<{ workspaceId: WorkspaceId }>("dormouse://workspace-departed", (event) => {
+ handleDeparted(event.payload.workspaceId);
+ });
+ void listenToWindow<{ workspaceId: WorkspaceId; reason?: string }>(
+ "dormouse://workspace-arrival-failed",
+ (event) => handleArrivalFailed(event.payload.workspaceId, event.payload.reason ?? "no reason given"),
+ );
+ // Immediately, and not only on the nudge: a Workspace dropped on this window
+ // while it was still booting is already in the queue, and its `emit_to`
+ // reached no listener.
+ void adoptQueued();
+}
+
+/**
+ * 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 queues
+ * it and the new webview takes it here. Returns null for an ordinary window —
+ * and for a tear-out whose Workspace could not be resumed, which then boots
+ * fresh rather than blank.
+ */
+export async function bootFromTearOut(platform: PlatformAdapter): Promise {
+ // A window with a snapshot is an ordinary one restoring itself, even if
+ // something was dropped on it while it booted: that arrival is mounted by
+ // `initWorkspaceMoves`, over the Window this restores.
+ if (platform.getWindowState?.()) return null;
+ const [first, ...rest] = await drainArrivals();
+ if (!first?.workspace) return null;
+ const { id, name, session } = first.workspace;
+ adopting.add(id);
+ let plan: WallBootPlans[string];
+ try {
+ plan = await planArrival(platform, first);
+ } catch (err) {
+ // Never into `bootstrap()`: the caller falls back to a fresh Window, which
+ // is a window the user can use rather than a blank one. Anything else in
+ // the queue is left for `initWorkspaceMoves` to drain over it.
+ console.error("[workspace-move] the torn-out Workspace could not be resumed", err);
+ settle("adopt_failed", id, err instanceof Error ? err.message : String(err));
+ adopting.delete(id);
+ return null;
+ }
+ try {
+ await invoke("adopt_done", { workspaceId: id });
+ } catch (err) {
+ console.error("[workspace-move] torn-out adoption refused; starting fresh", err);
+ for (const surfaceId of first.allIds) {
+ removeSurface(surfaceId);
+ forgetHelper(surfaceId);
+ }
+ for (const terminalId of first.terminalIds) releaseSession(terminalId);
+ adopting.delete(id);
+ return null;
+ }
+ // Nothing on disk yet: this window's first aggregator flush writes its
+ // snapshot, and from there it is an ordinary restorable window. After the
+ // plan, so a refused arrival leaves no half-installed Window behind.
+ installWindowPersistence(platform, { version: 1, workspaces: [{ id, name, session }], activeWorkspaceId: id });
+ publishWorkspaceSession(id, session);
+ adopting.delete(id);
+ // A second Workspace dropped on this window between the tear-out and this
+ // drain rides in the same queue.
+ for (const payload of rest) await adoptWorkspace(platform, payload);
+ return { [id]: plan };
+}
+
+/** @internal Forget what this window is moving (tests). */
+export function _resetWorkspaceMovesForTesting(): void {
+ inFlight.clear();
+ adopting.clear();
+}
diff --git a/standalone/src/workspace-tabs.ts b/standalone/src/workspace-tabs.ts
new file mode 100644
index 000000000..f09dc6667
--- /dev/null
+++ b/standalone/src/workspace-tabs.ts
@@ -0,0 +1,45 @@
+import type { WorkspaceId } from "dormouse-lib/lib/session-types";
+
+/**
+ * One scan of this window's Workspace strip, shared by everything that has to
+ * measure it: the drop index an arriving Workspace takes, the caret another
+ * window's drag draws, and where a torn-out tab should sit under the cursor
+ * (`docs/specs/standalone.md` → "Dragging a Workspace between windows").
+ *
+ * The strip renders in the AppBar, outside every Wall, so the DOM is the only
+ * thing all three of them share.
+ */
+
+/** Every tab, in strip order. */
+function tabs(): HTMLElement[] {
+ return [...document.querySelectorAll("[data-workspace-tab]")];
+}
+
+export interface WorkspaceDropTarget {
+ /** The index a drop takes. Undefined appends, which is also what a drop past
+ * the last tab means. */
+ index: number | undefined;
+ /** The box the caret draws against — the tab at `index`, or the last one when
+ * appending. Null when the strip has no tabs at all. */
+ rect: DOMRect | null;
+}
+
+/** Where a drop at viewport `x` lands in this window's strip. */
+export function workspaceDropTarget(x: number): WorkspaceDropTarget {
+ const elements = tabs();
+ for (const [index, tab] of elements.entries()) {
+ const rect = tab.getBoundingClientRect();
+ if (x < rect.left + rect.width / 2) return { index, rect };
+ }
+ const last = elements[elements.length - 1];
+ return { index: undefined, rect: last ? last.getBoundingClientRect() : null };
+}
+
+/** One Workspace's tab box, or null when it is not rendered. */
+export function workspaceTabRect(workspaceId: WorkspaceId): DOMRect | null {
+ // Scanned rather than selected: a Workspace id is generated, not escaped, and
+ // an attribute selector over one is a needless way to throw.
+ return tabs()
+ .find((tab) => tab.dataset.workspaceTab === workspaceId)
+ ?.getBoundingClientRect() ?? null;
+}
diff --git a/vscode-ext/test/webview-boot.smoketest.ts b/vscode-ext/test/webview-boot.smoketest.ts
index cca86eb70..dcc54a5d5 100644
--- a/vscode-ext/test/webview-boot.smoketest.ts
+++ b/vscode-ext/test/webview-boot.smoketest.ts
@@ -133,7 +133,9 @@ beforeAll(async () => {
await page.goto(`${origin}/`, { waitUntil: 'load' });
// The app mounts behind `resumeOrRestore`, which self-caps at 500ms when no
- // host answers. Poll rather than sleep so a fast boot does not pay for it.
+ // host answers and nothing saved names a terminal pane (the retry that would
+ // add 3 s is gated on one). Poll rather than sleep so a fast boot does not
+ // pay for it.
await page
.waitForFunction(() => (document.getElementById('root')?.childElementCount ?? 0) > 0, {
timeout: 15_000,