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/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.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..8866eae64 100644 --- a/standalone/src/teardown-flow.ts +++ b/standalone/src/teardown-flow.ts @@ -4,6 +4,7 @@ import { cancelEditorClose, confirmToolEditorsClose } from "dormouse-lib/lib/too import { beginQuitProgress, dismissQuitConfirm, + watchWorkspaceMembership, type QuitConfirmIntent, } from "./quit-confirm-store"; @@ -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,11 @@ export function createTeardownFlow(options: { const dirty = listWallHandles().flatMap((wall) => wall.dirtyToolIds()); if (dirty.length) { enter("confirming"); - const mine = {}; + const mine = { stopWatching: watchWorkspaceMembership(cancel) }; editorWait = mine; void confirmToolEditorsClose(dirty).then((accepted) => { if (editorWait !== mine) return; + mine.stopWatching(); editorWait = null; if (accepted) askRunning(); else cancel(); });