diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/App.svelte | 5 | ||||
| -rw-r--r-- | src/features/layout/adapter/abi-port.test.ts | 1 | ||||
| -rw-r--r-- | src/features/layout/store.svelte.ts | 27 | ||||
| -rw-r--r-- | src/features/layout/store.test.ts | 38 | ||||
| -rw-r--r-- | src/features/plugins/logic/types.ts | 9 | ||||
| -rw-r--r-- | src/features/plugins/ui/WorkspacePluginModal.svelte | 5 | ||||
| -rw-r--r-- | src/features/plugins/ui/WorkspacePluginModal.test.ts | 55 |
7 files changed, 136 insertions, 4 deletions
diff --git a/src/App.svelte b/src/App.svelte index d19a392..bc9367c 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -77,6 +77,11 @@ return store.layout.providerState === "present"; }, reset: (workspaceId) => store.layout.resetToDefault(workspaceId), + // The plugins modal's per-workspace toggles can change WHICH layout + // plugin serves the workspace (the per-workspace activation rule) — the + // store re-probes only the active workspace (non-active toggles are + // picked up by their own probe on switch). + refresh: (workspaceId) => store.layout.onWorkspacePluginsChanged(workspaceId), }; </script> diff --git a/src/features/layout/adapter/abi-port.test.ts b/src/features/layout/adapter/abi-port.test.ts index d11bf9e..24f9c94 100644 --- a/src/features/layout/adapter/abi-port.test.ts +++ b/src/features/layout/adapter/abi-port.test.ts @@ -28,6 +28,7 @@ function fakeStore( onTreeChange: vi.fn(), resetToDefault: vi.fn(async () => null), onExtensionsChanged: vi.fn(), + onWorkspacePluginsChanged: vi.fn(), ...overrides, } as LayoutStore; } diff --git a/src/features/layout/store.svelte.ts b/src/features/layout/store.svelte.ts index 5fdf100..29e2fa1 100644 --- a/src/features/layout/store.svelte.ts +++ b/src/features/layout/store.svelte.ts @@ -7,9 +7,11 @@ import { migrationTree } from "./logic/migration"; * `LayoutHttp` edge (the plugins-store pattern). Owns the ACTIVE workspace's * tree and the provider lifecycle: * - * - **Provider probe triggers** — boot, every workspace switch, and every - * `extensions-changed` (a provider enabled/disabled mid-session starts/ - * stops serving; the probe picks it up without a reload). + * - **Provider probe triggers** — boot, every workspace switch, every + * `extensions-changed`, and every change to the ACTIVE workspace's + * per-workspace plugin state (a layout plugin enabled/disabled in the + * workspace plugins modal starts/stops serving; the probe picks it up + * without a reload). * - **No-provider mode** — a 404 (or network failure) keeps the tree on its * local default; edits still work but are EPHEMERAL (in-memory only — * D-L4 retired the localStorage write path); logged once, no error UI. @@ -66,6 +68,15 @@ export interface LayoutStore { resetToDefault(workspaceId: string): Promise<string | null>; /** Multi-client coherence (§11): re-probe on `extensions-changed`. */ onExtensionsChanged(): void; + /** + * The active workspace's PER-WORKSPACE PLUGIN STATE changed (the workspace + * plugins modal toggled a layout plugin's enablement — the per-workspace + * activation rule): re-probe so the newly-enabled provider's tree (or the + * no-provider degrade) lands without a reload. A NON-active workspace's + * toggle is ignored — its tree is fetched on its next probe (workspace + * switch); there is nothing to re-render here. + */ + onWorkspacePluginsChanged(workspaceId: string): void; } export interface LayoutStoreOptions { @@ -291,5 +302,15 @@ export function createLayoutStore(opts: LayoutStoreOptions): LayoutStore { // migration path. Staying silent keeps the degrade quiet. }); }, + + onWorkspacePluginsChanged(workspaceId) { + // The per-workspace activation rule: only the ACTIVE workspace's tree + // can change what is rendered here — another workspace's toggle is + // picked up by its own probe (boot/workspace-switch/§2.6 triggers). + if (workspaceId !== activeWorkspaceId) return; + void runProbe(activeWorkspaceId, false).catch(() => { + // Same quiet-degrade guard as onExtensionsChanged. + }); + }, }; } diff --git a/src/features/layout/store.test.ts b/src/features/layout/store.test.ts index 73850ed..331af95 100644 --- a/src/features/layout/store.test.ts +++ b/src/features/layout/store.test.ts @@ -315,6 +315,44 @@ describe("layout store — edits PUT the whole tree + rollback (§2.6)", () => { }); describe("layout store — probe triggers (§2.6)", () => { + it("a per-workspace plugin-state change on the ACTIVE workspace re-probes (the plugins-modal toggle)", async () => { + let absent = true; + const served = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]); + const http = fakeHttp(() => + absent + ? { ok: false, notFound: true, error: "No layout plugin is enabled in this workspace" } + : { ok: true, tree: served }, + ); + const store = makeStore(http); + await store.probe("ws-a"); + expect(store.providerState).toBe("absent"); + + absent = false; // a layout plugin got enabled in THIS workspace + store.onWorkspacePluginsChanged("ws-a"); + await vi.advanceTimersByTimeAsync(0); + expect(store.providerState).toBe("present"); + expect(store.tree).toEqual(served); + }); + + it("a per-workspace plugin-state change on ANOTHER workspace is ignored (its probe fetches on switch)", async () => { + let absent = true; + const served = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]); + const http = fakeHttp(() => + absent + ? { ok: false, notFound: true, error: "No layout plugin is enabled in this workspace" } + : { ok: true, tree: served }, + ); + const store = makeStore(http); + await store.probe("ws-a"); + expect(store.providerState).toBe("absent"); + + absent = false; // a DIFFERENT workspace's toggle — nothing re-renders here + store.onWorkspacePluginsChanged("ws-other"); + await vi.advanceTimersByTimeAsync(0); + expect(http.getCalls).toEqual(["ws-a"]); // no second probe + expect(store.providerState).toBe("absent"); + }); + it("extensions-changed re-probes the active workspace", async () => { let absent = true; const served = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]); diff --git a/src/features/plugins/logic/types.ts b/src/features/plugins/logic/types.ts index ec80c9b..e64b553 100644 --- a/src/features/plugins/logic/types.ts +++ b/src/features/plugins/logic/types.ts @@ -70,6 +70,15 @@ export interface WorkspaceLayoutPort { * success or to the failure reason to display. */ readonly reset: (workspaceId: string) => Promise<string | null>; + /** + * The workspace's PER-WORKSPACE PLUGIN STATE changed (the modal just + * toggled a plugin's enablement): the layout plugin that serves THIS + * workspace may have changed (the per-workspace activation rule) — the + * composition root re-probes the layout so the newly-enabled provider's + * tree (or the no-provider degrade) lands without a reload. Only the + * ACTIVE workspace's toggle re-renders; others are ignored by the store. + */ + readonly refresh: (workspaceId: string) => void; } /** diff --git a/src/features/plugins/ui/WorkspacePluginModal.svelte b/src/features/plugins/ui/WorkspacePluginModal.svelte index d1bb27a..0ba7cd6 100644 --- a/src/features/plugins/ui/WorkspacePluginModal.svelte +++ b/src/features/plugins/ui/WorkspacePluginModal.svelte @@ -38,6 +38,11 @@ async function toggle(pluginId: string, checked: boolean): Promise<void> { await plugins.setWorkspaceState(workspaceId, pluginId, checked ? "enabled" : "disabled"); + // The per-workspace activation rule (layout): toggling a plugin may have + // changed which layout plugin serves THIS workspace — re-probe so the + // newly-enabled provider's tree (or the no-provider degrade) renders + // without a reload. The store ignores non-active workspaces. + layout?.refresh(workspaceId); } // ── The reset affordance (plan 04 §4.4) ───────────────────────────────── diff --git a/src/features/plugins/ui/WorkspacePluginModal.test.ts b/src/features/plugins/ui/WorkspacePluginModal.test.ts index 6bc7179..4c75ab3 100644 --- a/src/features/plugins/ui/WorkspacePluginModal.test.ts +++ b/src/features/plugins/ui/WorkspacePluginModal.test.ts @@ -51,8 +51,9 @@ async function renderModal(http: PluginsHttp = fakeHttp()) { function fakeLayoutPort( providerActive = true, reset: WorkspaceLayoutPort["reset"] = vi.fn(async () => null), + refresh: WorkspaceLayoutPort["refresh"] = vi.fn(), ): WorkspaceLayoutPort { - return { providerActive, reset }; + return { providerActive, reset, refresh }; } describe("WorkspacePluginModal", () => { @@ -117,6 +118,58 @@ describe("WorkspacePluginModal", () => { expect(fake.setWorkspaceState).toHaveBeenCalledWith("ws", "mcp", "disabled"); }); + it("a successful toggle calls the layout port's refresh (the per-workspace activation may change the served tree)", async () => { + const user = userEvent.setup(); + const refresh = vi.fn(); + const store = createPluginsStore(fakeHttp()); + await store.openWorkspace("ws"); + render(WorkspacePluginModal, { + props: { + workspaceId: "ws", + workspaceTitle: "My Workspace", + plugins: store, + layout: fakeLayoutPort( + true, + vi.fn(async () => null), + refresh, + ), + onClose: vi.fn(), + }, + }); + await user.click(screen.getByRole("checkbox", { name: /Enable mcp/i })); + expect(refresh).toHaveBeenCalledWith("ws"); + }); + + it("a REFUSED toggle (state write fails) still calls refresh — the probe is the idempotent catch-up", async () => { + const user = userEvent.setup(); + const refresh = vi.fn(); + const fake = fakeHttp({ + setWorkspaceState: async () => ({ + ok: false, + error: "Plugin is not loaded — load it globally first", + }), + }); + const store = createPluginsStore(fake); + await store.openWorkspace("ws"); + render(WorkspacePluginModal, { + props: { + workspaceId: "ws", + workspaceTitle: "My Workspace", + plugins: store, + layout: fakeLayoutPort( + true, + vi.fn(async () => null), + refresh, + ), + onClose: vi.fn(), + }, + }); + await user.click(screen.getByRole("checkbox", { name: /Enable mcp/i })); + // The attempted state change re-probes either way (a refused write changed + // nothing, so the probe re-syncs the same tree — harmless and consistent). + expect(refresh).toHaveBeenCalledWith("ws"); + }); + it("shows the template hint for the default workspace", async () => { const store = createPluginsStore(fakeHttp()); await store.openWorkspace("default"); |
