From d7e51a999e99b4f06868b27b3cd8bf34934b5a47 Mon Sep 17 00:00:00 2001 From: mstomar125 <185037323+mstomar125@users.noreply.github.com> Date: Thu, 22 Oct 2026 06:17:59 +0200 Subject: [PATCH] Expose core-modal-open signal to extensions as shell.modalOpen Core's hand-rolled modals (first-launch wizard, delete confirmation, subtree-access-denied) register through a coreModal action that releases on destroy; the host API reads it through a reactive shell.modalOpen getter. Refs #3837 Co-Authored-By: Claude Sonnet 5.5 --- .../desktop-app/src/lib/actions/core-modal.ts | 15 +++ .../delete-confirmation-modal.svelte | 2 + .../onboarding/onboarding-wizard.svelte | 2 + .../subtree-access-denied-modal.svelte | 2 + .../src/lib/extension-api/index.ts | 21 +++ .../lib/stores/core-modal-presence.svelte.ts | 30 +++++ .../core-modal-signal.svelte.test.ts | 121 ++++++++++++++++++ 7 files changed, 193 insertions(+) create mode 100644 packages/desktop-app/src/lib/actions/core-modal.ts create mode 100644 packages/desktop-app/src/lib/stores/core-modal-presence.svelte.ts create mode 100644 packages/desktop-app/src/tests/components/core-modal-signal.svelte.test.ts diff --git a/packages/desktop-app/src/lib/actions/core-modal.ts b/packages/desktop-app/src/lib/actions/core-modal.ts new file mode 100644 index 000000000..1e1fa317a --- /dev/null +++ b/packages/desktop-app/src/lib/actions/core-modal.ts @@ -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 }; +} diff --git a/packages/desktop-app/src/lib/components/delete-confirmation-modal.svelte b/packages/desktop-app/src/lib/components/delete-confirmation-modal.svelte index 482699b10..93bec2b14 100644 --- a/packages/desktop-app/src/lib/components/delete-confirmation-modal.svelte +++ b/packages/desktop-app/src/lib/components/delete-confirmation-modal.svelte @@ -1,6 +1,7 @@ @@ -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()} diff --git a/packages/desktop-app/src/lib/extension-api/index.ts b/packages/desktop-app/src/lib/extension-api/index.ts index f6cf5510e..6f2383d4d 100644 --- a/packages/desktop-app/src/lib/extension-api/index.ts +++ b/packages/desktop-app/src/lib/extension-api/index.ts @@ -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'; @@ -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 { diff --git a/packages/desktop-app/src/lib/stores/core-modal-presence.svelte.ts b/packages/desktop-app/src/lib/stores/core-modal-presence.svelte.ts new file mode 100644 index 000000000..cdafbfb28 --- /dev/null +++ b/packages/desktop-app/src/lib/stores/core-modal-presence.svelte.ts @@ -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(); + +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; +} diff --git a/packages/desktop-app/src/tests/components/core-modal-signal.svelte.test.ts b/packages/desktop-app/src/tests/components/core-modal-signal.svelte.test.ts new file mode 100644 index 000000000..bc9fedaea --- /dev/null +++ b/packages/desktop-app/src/tests/components/core-modal-signal.svelte.test.ts @@ -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); + }); +});