Skip to content

Commit d2911e7

Browse files
authored
Cancel quit and close when unsaved-file consent changes scope (#1279)
2 parents 8fd2bf6 + a02016c commit d2911e7

4 files changed

Lines changed: 93 additions & 7 deletions

File tree

‎docs/specs/standalone.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -372,7 +372,7 @@ A **watchdog** thread keeps quit bounded against a dead or wedged webview:
372372

373373
**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.
374374

375-
- **Must cancel an unconfirmed request if Workspace membership changes**; switching and reordering preserve it.
375+
- **Must cancel an unconfirmed request if Workspace membership changes**, including while awaiting unsaved-file consent; switching and reordering preserve it.
376376
- **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.**
377377

378378
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`.

‎standalone/src/quit-confirm-store.ts‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -75,13 +75,18 @@ function ownDialog(): void {
7575
function captureWorkspaces(): void {
7676
const workspaces = getWorkspacesSnapshot().workspaces;
7777
workspaceNames = workspaces.map((workspace) => workspace.name);
78-
const ids = new Set(workspaces.map((workspace) => workspace.id));
79-
unsubscribeWorkspaces = subscribeToWorkspaces(() => {
78+
unsubscribeWorkspaces = watchWorkspaceMembership(cancelQuit);
79+
}
80+
81+
/** Both teardown consent gates keep the Workspace membership they asked about. */
82+
export function watchWorkspaceMembership(onChange: () => void): () => void {
83+
const ids = new Set(getWorkspacesSnapshot().workspaces.map(workspace => workspace.id));
84+
return subscribeToWorkspaces(() => {
8085
const current = getWorkspacesSnapshot().workspaces;
8186
// A changed destination invalidates consent; focus, rename and order do not.
82-
// Every exit from 'open' stops this watch, so no phase check is needed.
87+
// Each gate releases its watch before settling or committing.
8388
if (current.length !== ids.size || current.some((workspace) => !ids.has(workspace.id))) {
84-
cancelQuit();
89+
onChange();
8590
}
8691
});
8792
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2+
import { registerWallHandle, resetWallHandles, stubWallHandle } from 'dormouse-lib/components/wall/wall-handles';
3+
import { closeWorkspace, createWorkspace, moveWorkspace, renameWorkspace, resetWorkspaces, setActiveWorkspace } from 'dormouse-lib/lib/workspace-store';
4+
import { recordToolDirty, resetToolDirty } from 'dormouse-lib/lib/tool-dirty-store';
5+
import { cancelEditorClose, decideEditorClose, getEditorClosePrompt } from 'dormouse-lib/lib/tool-editor';
6+
import { createTeardownFlow, _resetTeardownArbiterForTesting, type TeardownFlow } from './teardown-flow';
7+
import { _resetQuitConfirmForTesting } from './quit-confirm-store';
8+
9+
const invoke = vi.hoisted(() => vi.fn(async (_command: string) => {}));
10+
vi.mock('@tauri-apps/api/core', () => ({ invoke }));
11+
12+
const settle = () => new Promise(resolve => setTimeout(resolve, 0));
13+
14+
// A native teardown owns its scope from the first consent question, including
15+
// the unsaved-editor gate that comes before the running-work dialog.
16+
describe.each(['quit', 'close-window'] as const)('%s unsaved-editor consent', kind => {
17+
let flow: TeardownFlow;
18+
const proceed = vi.fn();
19+
const gate = vi.fn();
20+
beforeEach(() => {
21+
vi.clearAllMocks();
22+
resetWorkspaces();
23+
resetWallHandles();
24+
_resetTeardownArbiterForTesting();
25+
_resetQuitConfirmForTesting();
26+
createWorkspace({ id: 'second', name: 'Second' });
27+
recordToolDirty('editor', true);
28+
registerWallHandle(stubWallHandle('workspace:1', { dirtyToolIds: () => ['editor'] }));
29+
flow = createTeardownFlow({ kind, ack: 'ack', cancelCommand: 'cancel', gate: () => gate,
30+
mustConfirm: () => false, proceed });
31+
});
32+
afterEach(() => {
33+
flow.reset();
34+
cancelEditorClose();
35+
resetToolDirty();
36+
resetWallHandles();
37+
_resetQuitConfirmForTesting();
38+
});
39+
40+
it.each(['arrival', 'departure'] as const)('cancels on a Workspace %s before discard can authorize a changed scope', async change => {
41+
flow.request({ kind });
42+
expect(getEditorClosePrompt()).not.toBeNull();
43+
if (change === 'arrival') createWorkspace({ id: 'third' });
44+
else closeWorkspace('second');
45+
await decideEditorClose('discard');
46+
await settle();
47+
expect(invoke).toHaveBeenCalledWith('cancel');
48+
expect(getEditorClosePrompt()).toBeNull();
49+
expect(proceed).not.toHaveBeenCalled();
50+
expect(gate).not.toHaveBeenCalled();
51+
createWorkspace({ id: 'fourth' });
52+
expect(invoke.mock.calls.filter(([command]) => command === 'cancel')).toHaveLength(1);
53+
});
54+
55+
it('keeps consent through rename, focus, and reorder, then stops watching after commitment', async () => {
56+
flow.request({ kind });
57+
const prompt = getEditorClosePrompt();
58+
renameWorkspace('second', 'Renamed');
59+
setActiveWorkspace('workspace:1');
60+
moveWorkspace('second', 0);
61+
expect(getEditorClosePrompt()).toBe(prompt);
62+
await decideEditorClose('discard');
63+
await settle();
64+
expect(proceed).toHaveBeenCalledOnce();
65+
createWorkspace({ id: 'third' });
66+
expect(invoke).not.toHaveBeenCalledWith('cancel');
67+
});
68+
69+
it('releases the scope watch when another window cancels', async () => {
70+
flow.request({ kind });
71+
flow.reset();
72+
createWorkspace({ id: 'third' });
73+
await settle();
74+
expect(getEditorClosePrompt()).toBeNull();
75+
expect(invoke).not.toHaveBeenCalledWith('cancel');
76+
expect(proceed).not.toHaveBeenCalled();
77+
});
78+
});

‎standalone/src/teardown-flow.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { cancelEditorClose, confirmToolEditorsClose } from "dormouse-lib/lib/too
44
import {
55
beginQuitProgress,
66
dismissQuitConfirm,
7+
watchWorkspaceMembership,
78
type QuitConfirmIntent,
89
} from "./quit-confirm-store";
910

@@ -88,7 +89,7 @@ export function createTeardownFlow(options: {
8889
let phase: "idle" | "confirming" | "committed" = "idle";
8990
// The unsaved-file question this flow is waiting on; replaced or cleared, its
9091
// late answer is ignored.
91-
let editorWait: object | null = null;
92+
let editorWait: { stopWatching: () => void } | null = null;
9293

9394
const claim: TeardownClaim = {
9495
kind: options.kind,
@@ -109,6 +110,7 @@ export function createTeardownFlow(options: {
109110

110111
function stopEditorWait(): void {
111112
if (!editorWait) return;
113+
editorWait.stopWatching();
112114
editorWait = null;
113115
cancelEditorClose();
114116
}
@@ -162,10 +164,11 @@ export function createTeardownFlow(options: {
162164
const dirty = listWallHandles().flatMap((wall) => wall.dirtyToolIds());
163165
if (dirty.length) {
164166
enter("confirming");
165-
const mine = {};
167+
const mine = { stopWatching: watchWorkspaceMembership(cancel) };
166168
editorWait = mine;
167169
void confirmToolEditorsClose(dirty).then((accepted) => {
168170
if (editorWait !== mine) return;
171+
mine.stopWatching();
169172
editorWait = null;
170173
if (accepted) askRunning(); else cancel();
171174
});

0 commit comments

Comments
 (0)