From d103f204de31c733a1622b38123dd615d8a2cff3 Mon Sep 17 00:00:00 2001 From: Ned Date: Wed, 21 Oct 2026 00:25:40 -0700 Subject: [PATCH 1/2] Cancel teardown consent when Workspace membership changes --- docs/specs/standalone.md | 2 +- standalone/src/teardown-flow.test.ts | 78 ++++++++++++++++++++++++++++ standalone/src/teardown-flow.ts | 13 ++++- 3 files changed, 90 insertions(+), 3 deletions(-) create mode 100644 standalone/src/teardown-flow.test.ts diff --git a/docs/specs/standalone.md b/docs/specs/standalone.md index f5a661e70..dc6ed128b 100644 --- a/docs/specs/standalone.md +++ b/docs/specs/standalone.md @@ -372,7 +372,7 @@ A **watchdog** thread keeps quit bounded against a dead or wedged webview: **Must use the Workspace typed-letter confirmation for window close and quit**, naming every Workspace in the window, hidden ones included. It opens for running Sessions; an all-idle quit proceeds without a prompt. The letter rule is `docs/specs/layout.md` → Workspaces. -- **Must cancel an unconfirmed request if Workspace membership changes**; switching and reordering preserve it. +- **Must cancel an unconfirmed request if Workspace membership changes**, including while awaiting unsaved-file consent; switching and reordering preserve it. - **Must exclude transfers from host teardown.** An app quit queues while any arrival is pending, a native window close while that window is an arrival endpoint, and each retries through normal confirmation once the transfer settles; quit supersedes queued closes. **Must refuse new transfers while app quit or either endpoint's close is queued, confirming, or tearing down.** Source of truth: `openQuitConfirm` in `standalone/src/quit-confirm-store.ts`; `WorkspaceTeardownModalHost` in `standalone/src/WorkspaceTeardownModal.tsx`. Cross-window voting is pinned by `standalone/src/quit.test.ts`. diff --git a/standalone/src/teardown-flow.test.ts b/standalone/src/teardown-flow.test.ts new file mode 100644 index 000000000..e5f495ab9 --- /dev/null +++ b/standalone/src/teardown-flow.test.ts @@ -0,0 +1,78 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { registerWallHandle, resetWallHandles, stubWallHandle } from 'dormouse-lib/components/wall/wall-handles'; +import { closeWorkspace, createWorkspace, moveWorkspace, renameWorkspace, resetWorkspaces, setActiveWorkspace } from 'dormouse-lib/lib/workspace-store'; +import { recordToolDirty, resetToolDirty } from 'dormouse-lib/lib/tool-dirty-store'; +import { cancelEditorClose, decideEditorClose, getEditorClosePrompt } from 'dormouse-lib/lib/tool-editor'; +import { createTeardownFlow, _resetTeardownArbiterForTesting, type TeardownFlow } from './teardown-flow'; +import { _resetQuitConfirmForTesting } from './quit-confirm-store'; + +const invoke = vi.hoisted(() => vi.fn(async (_command: string) => {})); +vi.mock('@tauri-apps/api/core', () => ({ invoke })); + +const settle = () => new Promise(resolve => setTimeout(resolve, 0)); + +// A native teardown owns its scope from the first consent question, including +// the unsaved-editor gate that comes before the running-work dialog. +describe.each(['quit', 'close-window'] as const)('%s unsaved-editor consent', kind => { + let flow: TeardownFlow; + const proceed = vi.fn(); + const gate = vi.fn(); + beforeEach(() => { + vi.clearAllMocks(); + resetWorkspaces(); + resetWallHandles(); + _resetTeardownArbiterForTesting(); + _resetQuitConfirmForTesting(); + createWorkspace({ id: 'second', name: 'Second' }); + recordToolDirty('editor', true); + registerWallHandle(stubWallHandle('workspace:1', { dirtyToolIds: () => ['editor'] })); + flow = createTeardownFlow({ kind, ack: 'ack', cancelCommand: 'cancel', gate: () => gate, + mustConfirm: () => false, proceed }); + }); + afterEach(() => { + flow.reset(); + cancelEditorClose(); + resetToolDirty(); + resetWallHandles(); + _resetQuitConfirmForTesting(); + }); + + it.each(['arrival', 'departure'] as const)('cancels on a Workspace %s before discard can authorize a changed scope', async change => { + flow.request({ kind }); + expect(getEditorClosePrompt()).not.toBeNull(); + if (change === 'arrival') createWorkspace({ id: 'third' }); + else closeWorkspace('second'); + await decideEditorClose('discard'); + await settle(); + expect(invoke).toHaveBeenCalledWith('cancel'); + expect(getEditorClosePrompt()).toBeNull(); + expect(proceed).not.toHaveBeenCalled(); + expect(gate).not.toHaveBeenCalled(); + createWorkspace({ id: 'fourth' }); + expect(invoke.mock.calls.filter(([command]) => command === 'cancel')).toHaveLength(1); + }); + + it('keeps consent through rename, focus, and reorder, then stops watching after commitment', async () => { + flow.request({ kind }); + const prompt = getEditorClosePrompt(); + renameWorkspace('second', 'Renamed'); + setActiveWorkspace('workspace:1'); + moveWorkspace('second', 0); + expect(getEditorClosePrompt()).toBe(prompt); + await decideEditorClose('discard'); + await settle(); + expect(proceed).toHaveBeenCalledOnce(); + createWorkspace({ id: 'third' }); + expect(invoke).not.toHaveBeenCalledWith('cancel'); + }); + + it('releases the scope watch when another window cancels', async () => { + flow.request({ kind }); + flow.reset(); + createWorkspace({ id: 'third' }); + await settle(); + expect(getEditorClosePrompt()).toBeNull(); + expect(invoke).not.toHaveBeenCalledWith('cancel'); + expect(proceed).not.toHaveBeenCalled(); + }); +}); diff --git a/standalone/src/teardown-flow.ts b/standalone/src/teardown-flow.ts index 57e6693d5..9072fbdcf 100644 --- a/standalone/src/teardown-flow.ts +++ b/standalone/src/teardown-flow.ts @@ -1,4 +1,5 @@ import { invoke } from "@tauri-apps/api/core"; +import { getWorkspacesSnapshot, subscribeToWorkspaces } from "dormouse-lib/lib/workspace-store"; import { listWallHandles } from "dormouse-lib/components/wall/wall-handles"; import { cancelEditorClose, confirmToolEditorsClose } from "dormouse-lib/lib/tool-editor"; import { @@ -88,7 +89,7 @@ export function createTeardownFlow(options: { let phase: "idle" | "confirming" | "committed" = "idle"; // The unsaved-file question this flow is waiting on; replaced or cleared, its // late answer is ignored. - let editorWait: object | null = null; + let editorWait: { stopWatching: () => void } | null = null; const claim: TeardownClaim = { kind: options.kind, @@ -109,6 +110,7 @@ export function createTeardownFlow(options: { function stopEditorWait(): void { if (!editorWait) return; + editorWait.stopWatching(); editorWait = null; cancelEditorClose(); } @@ -162,10 +164,17 @@ export function createTeardownFlow(options: { const dirty = listWallHandles().flatMap((wall) => wall.dirtyToolIds()); if (dirty.length) { enter("confirming"); - const mine = {}; + const ids = new Set(getWorkspacesSnapshot().workspaces.map(workspace => workspace.id)); + const mine = { stopWatching: subscribeToWorkspaces(() => { + const current = getWorkspacesSnapshot().workspaces; + // The unsaved-file question precedes the running-work dialog, but + // its consent is already scoped to the Workspaces present now. + if (current.length !== ids.size || current.some(workspace => !ids.has(workspace.id))) cancel(); + }) }; editorWait = mine; void confirmToolEditorsClose(dirty).then((accepted) => { if (editorWait !== mine) return; + mine.stopWatching(); editorWait = null; if (accepted) askRunning(); else cancel(); }); From a02016c6b41c66fa53a4de12f3fc067f873bfdff Mon Sep 17 00:00:00 2001 From: Ned Date: Wed, 21 Oct 2026 00:36:07 -0700 Subject: [PATCH 2/2] Share Workspace membership checks across teardown consent gates --- standalone/src/quit-confirm-store.ts | 13 +++++++++---- standalone/src/teardown-flow.ts | 10 ++-------- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/standalone/src/quit-confirm-store.ts b/standalone/src/quit-confirm-store.ts index 4bed0e4ee..1b9fec6e9 100644 --- a/standalone/src/quit-confirm-store.ts +++ b/standalone/src/quit-confirm-store.ts @@ -75,13 +75,18 @@ function ownDialog(): void { function captureWorkspaces(): void { const workspaces = getWorkspacesSnapshot().workspaces; workspaceNames = workspaces.map((workspace) => workspace.name); - const ids = new Set(workspaces.map((workspace) => workspace.id)); - unsubscribeWorkspaces = subscribeToWorkspaces(() => { + unsubscribeWorkspaces = watchWorkspaceMembership(cancelQuit); +} + +/** Both teardown consent gates keep the Workspace membership they asked about. */ +export function watchWorkspaceMembership(onChange: () => void): () => void { + const ids = new Set(getWorkspacesSnapshot().workspaces.map(workspace => workspace.id)); + return subscribeToWorkspaces(() => { const current = getWorkspacesSnapshot().workspaces; // A changed destination invalidates consent; focus, rename and order do not. - // Every exit from 'open' stops this watch, so no phase check is needed. + // Each gate releases its watch before settling or committing. if (current.length !== ids.size || current.some((workspace) => !ids.has(workspace.id))) { - cancelQuit(); + onChange(); } }); } diff --git a/standalone/src/teardown-flow.ts b/standalone/src/teardown-flow.ts index 9072fbdcf..8866eae64 100644 --- a/standalone/src/teardown-flow.ts +++ b/standalone/src/teardown-flow.ts @@ -1,10 +1,10 @@ import { invoke } from "@tauri-apps/api/core"; -import { getWorkspacesSnapshot, subscribeToWorkspaces } from "dormouse-lib/lib/workspace-store"; import { listWallHandles } from "dormouse-lib/components/wall/wall-handles"; import { cancelEditorClose, confirmToolEditorsClose } from "dormouse-lib/lib/tool-editor"; import { beginQuitProgress, dismissQuitConfirm, + watchWorkspaceMembership, type QuitConfirmIntent, } from "./quit-confirm-store"; @@ -164,13 +164,7 @@ export function createTeardownFlow(options: { const dirty = listWallHandles().flatMap((wall) => wall.dirtyToolIds()); if (dirty.length) { enter("confirming"); - const ids = new Set(getWorkspacesSnapshot().workspaces.map(workspace => workspace.id)); - const mine = { stopWatching: subscribeToWorkspaces(() => { - const current = getWorkspacesSnapshot().workspaces; - // The unsaved-file question precedes the running-work dialog, but - // its consent is already scoped to the Workspaces present now. - if (current.length !== ids.size || current.some(workspace => !ids.has(workspace.id))) cancel(); - }) }; + const mine = { stopWatching: watchWorkspaceMembership(cancel) }; editorWait = mine; void confirmToolEditorsClose(dirty).then((accepted) => { if (editorWait !== mine) return;