Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/specs/standalone.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
13 changes: 9 additions & 4 deletions standalone/src/quit-confirm-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
});
}
Expand Down
78 changes: 78 additions & 0 deletions standalone/src/teardown-flow.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
7 changes: 5 additions & 2 deletions standalone/src/teardown-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { cancelEditorClose, confirmToolEditorsClose } from "dormouse-lib/lib/too
import {
beginQuitProgress,
dismissQuitConfirm,
watchWorkspaceMembership,
type QuitConfirmIntent,
} from "./quit-confirm-store";

Expand Down Expand Up @@ -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,
Expand All @@ -109,6 +110,7 @@ export function createTeardownFlow(options: {

function stopEditorWait(): void {
if (!editorWait) return;
editorWait.stopWatching();
editorWait = null;
cancelEditorClose();
}
Expand Down Expand Up @@ -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();
});
Expand Down
Loading