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
15 changes: 15 additions & 0 deletions packages/desktop-app/src/lib/actions/core-modal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/**
* coreModal - Svelte action marking an element as one of core's open modals.
*
* Put it on the dialog element of a hand-rolled core modal, next to
* `focusTrap`. While the element is mounted `shell.modalOpen` (host API) is
* true; destroying the element releases it, so the signal follows the
* `{#if}` that shows the modal and cannot stick on. Not exported to
* extensions: their own modals must not count as core's.
*/
import { registerCoreModal } from '$lib/stores/core-modal-presence.svelte';

export function coreModal(_node: HTMLElement) {
const release = registerCoreModal();
return { destroy: release };
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<script lang="ts">
import { getDeleteConfirmationState } from '$lib/services/delete-confirmation.svelte';
import { focusTrap } from '$lib/actions/focus-trap';
import { coreModal } from '$lib/actions/core-modal';

const confirmation = getDeleteConfirmationState();
// Keyboard handling lives elsewhere: focusTrap owns Escape + Tab and lands
Expand All @@ -23,6 +24,7 @@
aria-modal="true"
aria-labelledby="delete-modal-title"
aria-describedby="delete-modal-desc"
use:coreModal
use:focusTrap={{ onEscape: confirmation.cancel }}
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import { createLogger } from '$lib/utils/logger';
import { toError } from '$lib/types/errors';
import { focusTrap } from '$lib/actions/focus-trap';
import { coreModal } from '$lib/actions/core-modal';
import { splitFullName } from '$lib/utils/split-full-name';
import { displayAgentName, formatAgentList } from '$lib/utils/agent-names';

Expand Down Expand Up @@ -379,6 +380,7 @@
<div
class="onboarding-dialog"
bind:this={dialogEl}
use:coreModal
use:focusTrap={{ onEscape: onClose }}
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<script lang="ts">
import { getSubtreeAccessDeniedState } from '$lib/services/subtree-access-denied.svelte';
import { focusTrap } from '$lib/actions/focus-trap';
import { coreModal } from '$lib/actions/core-modal';

const refusal = getSubtreeAccessDeniedState();
</script>
Expand All @@ -13,6 +14,7 @@
aria-modal="true"
aria-labelledby="subtree-access-denied-title"
aria-describedby="subtree-access-denied-desc"
use:coreModal
use:focusTrap={{ onEscape: refusal.dismiss }}
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
Expand Down
21 changes: 21 additions & 0 deletions packages/desktop-app/src/lib/extension-api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import type { CreatedNode, CreateNodeInput } from '$lib/services/backend-adapter
import { backendAdapter } from '$lib/services/backend-adapter';
import { sharedNodeStore } from '$lib/services/shared-node-store.svelte';
import { collectionsData } from '$lib/stores/collections.svelte';
import { isCoreModalOpen } from '$lib/stores/core-modal-presence.svelte';
import { databaseStore, type DatabaseInfo } from '$lib/stores/database.svelte';
import { schemasData } from '$lib/stores/schemas.svelte';
import type { Node } from '$lib/types';
Expand Down Expand Up @@ -152,6 +153,26 @@ export const nodes: ExtensionNodes = {
)
};

// --- Shell ----------------------------------------------------------------------

/** State of the app shell that extension chrome can react to. */
export interface ExtensionShell {
/**
* True while one of core's own modals (the first-launch wizard, the delete
* confirmation, ...) is on screen. A reactive read, usable in a contribution's
* `when()`. An `app-shell-modal` contribution shares the screen with these
* modals and should not open while this is true. It does not cover dialogs
* that extensions open themselves.
*/
readonly modalOpen: boolean;
}

export const shell: ExtensionShell = {
get modalOpen() {
return isCoreModalOpen();
}
};

// --- Collections and schemas ----------------------------------------------------

interface ExtensionReloadable {
Expand Down
30 changes: 30 additions & 0 deletions packages/desktop-app/src/lib/stores/core-modal-presence.svelte.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* Whether one of core's own modals (the first-launch wizard, the delete
* confirmation, ...) is on screen. Extensions read it through
* `shell.modalOpen` in `@nodespace/extension-api` so an `app-shell-modal`
* contribution can wait for core's modals instead of stacking over them.
*
* Each modal holds one registration, released exactly once; the signal is on
* while any registration is held. Registering goes through the `coreModal`
* action, which releases on destroy, so a modal that unmounts (or whose
* component throws and is torn down) cannot leave the signal on.
*/

const held = new Set<symbol>();

let count = $state(0);

/** Registers an open core modal. The returned function releases it; extra calls do nothing. */
export function registerCoreModal(): () => void {
const token = Symbol('core-modal');
held.add(token);
count = held.size;
return () => {
if (held.delete(token)) count = held.size;
};
}

/** A reactive read: true while any core modal holds a registration. */
export function isCoreModalOpen(): boolean {
return count > 0;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/**
* `shell.modalOpen` (host API) is true exactly while one of core's hand-rolled
* modals is mounted, and is released on every exit path: close, unmount, and
* a registration released twice.
*/
import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest';
import { flushSync, tick } from 'svelte';
import { render, cleanup } from '@testing-library/svelte';
import { mockTauriCore } from '../helpers/mock-tauri-core';

vi.mock('@tauri-apps/api/core', () =>
mockTauriCore({
invoke: (cmd: string) =>
Promise.resolve(
cmd === 'detect_agents'
? { agents: [], detectionFailed: false }
: {
completed: false,
pathConfigured: false,
skillConfigured: false,
pathAlreadyConfigured: false
}
)
})
);

import { shell } from '@nodespace/extension-api';
import { registerCoreModal } from '$lib/stores/core-modal-presence.svelte';
import DeleteConfirmationModal from '$lib/components/delete-confirmation-modal.svelte';
import SubtreeAccessDeniedModal from '$lib/components/subtree-access-denied-modal.svelte';
import OnboardingWizard from '$lib/components/onboarding/onboarding-wizard.svelte';
import {
confirmNodeDeletion,
getDeleteConfirmationState
} from '$lib/services/delete-confirmation.svelte';
import {
showSubtreeAccessDenied,
getSubtreeAccessDeniedState
} from '$lib/services/subtree-access-denied.svelte';

beforeEach(() => {
expect(shell.modalOpen).toBe(false);
});

afterEach(() => {
cleanup();
getDeleteConfirmationState().cancel();
getSubtreeAccessDeniedState().dismiss();
});

describe('shell.modalOpen', () => {
it('follows a registration and ignores a second release', () => {
const a = registerCoreModal();
const b = registerCoreModal();
expect(shell.modalOpen).toBe(true);
a();
a();
expect(shell.modalOpen).toBe(true); // b still holds it
b();
expect(shell.modalOpen).toBe(false);
});

it('is reactive: a derivation re-runs when it flips', () => {
const seen: boolean[] = [];
const stop = $effect.root(() => {
const open = $derived(shell.modalOpen);
$effect(() => {
seen.push(open);
});
});
flushSync();
const release = registerCoreModal();
flushSync();
release();
flushSync();
stop();
expect(seen).toEqual([false, true, false]);
});

it('is on while the delete confirmation shows and off once it resolves', async () => {
confirmNodeDeletion(1);
await tick();
render(DeleteConfirmationModal);
expect(shell.modalOpen).toBe(true);
getDeleteConfirmationState().cancel();
await tick();
expect(shell.modalOpen).toBe(false);
});

it('is on while the subtree-access-denied modal shows and off once dismissed', async () => {
showSubtreeAccessDenied(2);
await tick();
render(SubtreeAccessDeniedModal);
expect(shell.modalOpen).toBe(true);
getSubtreeAccessDeniedState().dismiss();
await tick();
expect(shell.modalOpen).toBe(false);
});

it('is on while the onboarding wizard is open, off when closed, and off after unmount', async () => {
const { rerender, unmount } = render(OnboardingWizard, {
props: { open: true, onClose: vi.fn() }
});
expect(shell.modalOpen).toBe(true);
await rerender({ open: false, onClose: vi.fn() });
expect(shell.modalOpen).toBe(false);
await rerender({ open: true, onClose: vi.fn() });
expect(shell.modalOpen).toBe(true);
unmount();
expect(shell.modalOpen).toBe(false);
});

it('is off when the modal unmounts while still pending', async () => {
confirmNodeDeletion(1);
await tick();
const { unmount } = render(DeleteConfirmationModal);
expect(shell.modalOpen).toBe(true);
unmount();
expect(shell.modalOpen).toBe(false);
});
});