From 3a4922a05930b0dc22740bf5621910803e8f275e Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Thu, 3 Sep 2026 18:05:29 +0900 Subject: feat(plugin-system): plan 07 frontend plugin manager ui --- src/features/plugins/adapter/http.test.ts | 177 +++++++++++++ src/features/plugins/adapter/http.ts | 230 +++++++++++++++++ src/features/plugins/index.ts | 20 ++ src/features/plugins/logic/cascade.test.ts | 66 +++++ src/features/plugins/logic/cascade.ts | 42 +++ src/features/plugins/logic/highlight.test.ts | 60 +++++ src/features/plugins/logic/highlight.ts | 37 +++ src/features/plugins/logic/last-provider.test.ts | 70 +++++ src/features/plugins/logic/last-provider.ts | 31 +++ src/features/plugins/logic/prereqs.test.ts | 58 +++++ src/features/plugins/logic/prereqs.ts | 45 ++++ src/features/plugins/logic/test-fixtures.ts | 30 +++ src/features/plugins/logic/types.ts | 44 ++++ src/features/plugins/logic/view-model.test.ts | 129 ++++++++++ src/features/plugins/logic/view-model.ts | 111 ++++++++ src/features/plugins/logic/workspace-row.test.ts | 67 +++++ src/features/plugins/logic/workspace-row.ts | 54 ++++ src/features/plugins/store.svelte.ts | 198 ++++++++++++++ src/features/plugins/store.test.ts | 183 +++++++++++++ src/features/plugins/ui/PluginManager.svelte | 217 ++++++++++++++++ src/features/plugins/ui/PluginManager.test.ts | 284 +++++++++++++++++++++ src/features/plugins/ui/PluginRow.svelte | 116 +++++++++ src/features/plugins/ui/PluginTable.svelte | 85 ++++++ src/features/plugins/ui/UnloadConfirmModal.svelte | 40 +++ .../plugins/ui/WorkspacePluginModal.svelte | 110 ++++++++ .../plugins/ui/WorkspacePluginModal.test.ts | 124 +++++++++ 26 files changed, 2628 insertions(+) create mode 100644 src/features/plugins/adapter/http.test.ts create mode 100644 src/features/plugins/adapter/http.ts create mode 100644 src/features/plugins/index.ts create mode 100644 src/features/plugins/logic/cascade.test.ts create mode 100644 src/features/plugins/logic/cascade.ts create mode 100644 src/features/plugins/logic/highlight.test.ts create mode 100644 src/features/plugins/logic/highlight.ts create mode 100644 src/features/plugins/logic/last-provider.test.ts create mode 100644 src/features/plugins/logic/last-provider.ts create mode 100644 src/features/plugins/logic/prereqs.test.ts create mode 100644 src/features/plugins/logic/prereqs.ts create mode 100644 src/features/plugins/logic/test-fixtures.ts create mode 100644 src/features/plugins/logic/types.ts create mode 100644 src/features/plugins/logic/view-model.test.ts create mode 100644 src/features/plugins/logic/view-model.ts create mode 100644 src/features/plugins/logic/workspace-row.test.ts create mode 100644 src/features/plugins/logic/workspace-row.ts create mode 100644 src/features/plugins/store.svelte.ts create mode 100644 src/features/plugins/store.test.ts create mode 100644 src/features/plugins/ui/PluginManager.svelte create mode 100644 src/features/plugins/ui/PluginManager.test.ts create mode 100644 src/features/plugins/ui/PluginRow.svelte create mode 100644 src/features/plugins/ui/PluginTable.svelte create mode 100644 src/features/plugins/ui/UnloadConfirmModal.svelte create mode 100644 src/features/plugins/ui/WorkspacePluginModal.svelte create mode 100644 src/features/plugins/ui/WorkspacePluginModal.test.ts (limited to 'src/features/plugins') diff --git a/src/features/plugins/adapter/http.test.ts b/src/features/plugins/adapter/http.test.ts new file mode 100644 index 0000000..b1fe477 --- /dev/null +++ b/src/features/plugins/adapter/http.test.ts @@ -0,0 +1,177 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createPluginsHttp } from "./http"; + +/** A stub fetch that records requests and replies from a handler. */ +function stubFetch(handler: (url: string, init?: RequestInit) => Response | Promise) { + const impl = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + return await handler(url, init); + }); + return impl as unknown as typeof fetch; +} + +const okJson = (body: unknown): Response => + ({ ok: true, status: 200, json: async () => body }) as unknown as Response; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("createPluginsHttp — manager routes (plan 05)", () => { + it("GET /plugins returns the parsed list (null on failure)", async () => { + const fetch1 = stubFetch(() => + okJson({ loaded: [], disabledWithReason: [], discovered: [], missing: [] }), + ); + const http = createPluginsHttp("http://x", fetch1); + await expect(http.list()).resolves.toEqual({ + loaded: [], + disabledWithReason: [], + discovered: [], + missing: [], + }); + + const fetch2 = stubFetch(() => ({ ok: false, status: 503 }) as unknown as Response); + await expect(createPluginsHttp("http://x", fetch2).list()).resolves.toBeNull(); + }); + + it("POST /plugins/scan posts to the scan route", async () => { + let seenUrl = ""; + let seenInit: RequestInit | undefined; + const fetch1 = stubFetch((url, init) => { + seenUrl = url; + seenInit = init; + return okJson({ root: "/plugins", newPlugins: [], invalid: [], incompatible: [] }); + }); + const http = createPluginsHttp("http://x", fetch1); + const result = await http.scan(); + expect(result.ok).toBe(true); + expect(seenUrl).toBe("http://x/plugins/scan"); + expect(seenInit?.method).toBe("POST"); + }); + + it("POST /plugins/:id/unload sends the force flag in the body", async () => { + let seenBody: string | undefined; + const fetch1 = stubFetch((_url, init) => { + seenBody = init?.body as string; + return okJson({ ok: true, action: "unload", id: "mcp" }); + }); + const http = createPluginsHttp("http://x", fetch1); + await http.unload("mcp", true); + expect(JSON.parse(seenBody ?? "{}")).toEqual({ force: true }); + }); + + it("maps a blocked mutation (409) to ok:false with reason + dependents", async () => { + const fetch1 = stubFetch( + () => + ({ + ok: false, + status: 409, + json: async () => ({ + ok: false, + action: "unload", + id: "heartbeat", + reason: "cascade-needs-force", + message: "blocked", + dependents: ["transport-http"], + }), + }) as unknown as Response, + ); + const http = createPluginsHttp("http://x", fetch1); + const result = await http.unload("heartbeat", false); + expect(result).toEqual({ + ok: false, + error: "blocked", + reason: "cascade-needs-force", + dependents: ["transport-http"], + }); + }); + + it("load and reload hit their routes with the encoded id", async () => { + const urls: string[] = []; + const fetch1 = stubFetch((url) => { + urls.push(url); + return okJson({ ok: true, action: "load", id: "a/b" }); + }); + const http = createPluginsHttp("http://x", fetch1); + await http.load("a/b"); + await http.reload("a/b"); + expect(urls).toEqual(["http://x/plugins/a%2Fb/load", "http://x/plugins/a%2Fb/reload"]); + }); +}); + +describe("createPluginsHttp — workspace routes (plan 06 §6.6)", () => { + it("GET /workspaces/:id/plugins returns the parsed response (null on failure)", async () => { + const fetch1 = stubFetch(() => + okJson({ workspaceId: "ws", plugins: [], enabledProviderCount: 2 }), + ); + const http = createPluginsHttp("http://x", fetch1); + await expect(http.workspacePlugins("ws")).resolves.toEqual({ + workspaceId: "ws", + plugins: [], + enabledProviderCount: 2, + }); + + const fetch2 = stubFetch(() => ({ ok: false, status: 503 }) as unknown as Response); + await expect(createPluginsHttp("http://x", fetch2).workspacePlugins("ws")).resolves.toBeNull(); + }); + + it("PUT /workspaces/:id/plugins/:pluginId sends the state body", async () => { + let seenUrl = ""; + let seenBody: string | undefined; + const fetch1 = stubFetch((url, init) => { + seenUrl = url; + seenBody = init?.body as string; + return okJson({ workspaceId: "ws", pluginId: "mcp", state: "enabled" }); + }); + const http = createPluginsHttp("http://x", fetch1); + const result = await http.setWorkspaceState("ws", "mcp", "enabled"); + expect(result.ok).toBe(true); + expect(seenUrl).toBe("http://x/workspaces/ws/plugins/mcp"); + expect(JSON.parse(seenBody ?? "{}")).toEqual({ state: "enabled" }); + }); + + it("maps a 409 refusal (core-locked) to ok:false with the reason", async () => { + const fetch1 = stubFetch( + () => + ({ + ok: false, + status: 409, + json: async () => ({ + workspaceId: "ws", + pluginId: "kernel", + reason: "core-locked", + message: "core is locked", + }), + }) as unknown as Response, + ); + const http = createPluginsHttp("http://x", fetch1); + const result = await http.setWorkspaceState("ws", "kernel", "disabled"); + expect(result).toEqual({ ok: false, error: "core is locked", reason: "core-locked" }); + }); + + it("GET providers-status returns the parsed response", async () => { + let seenUrl = ""; + const fetch1 = stubFetch((url) => { + seenUrl = url; + return okJson({ workspaceId: "ws", enabledProviderCount: 0 }); + }); + const http = createPluginsHttp("http://x", fetch1); + await expect(http.providersStatus("ws")).resolves.toEqual({ + workspaceId: "ws", + enabledProviderCount: 0, + }); + expect(seenUrl).toBe("http://x/workspaces/ws/plugins/providers-status"); + }); + + it("network throws degrade reads to null and mutations to ok:false", async () => { + const boom = vi.fn(async () => { + throw new Error("down"); + }) as unknown as typeof fetch; + const http = createPluginsHttp("http://x", boom); + await expect(http.list()).resolves.toBeNull(); + await expect(http.providersStatus("ws")).resolves.toBeNull(); + await expect(http.setWorkspaceState("ws", "mcp", "enabled")).resolves.toMatchObject({ + ok: false, + }); + }); +}); diff --git a/src/features/plugins/adapter/http.ts b/src/features/plugins/adapter/http.ts new file mode 100644 index 0000000..7814f4f --- /dev/null +++ b/src/features/plugins/adapter/http.ts @@ -0,0 +1,230 @@ +import type { + PluginListResponse, + PluginOpResponse, + PluginScanResponse, + SetWorkspacePluginStateBlocked, + SetWorkspacePluginStateRequest, + SetWorkspacePluginStateResponse, + WorkspacePluginProvidersStatusResponse, + WorkspacePluginsResponse, +} from "@dispatch/transport-contract"; + +/** + * Plugin HTTP effects — the injected edge that talks to the backend's plugin + * manager routes (plan 05) + per-workspace enablement routes (plan 06 §6.6). + * Mirrors the workspaces adapter's pattern: `httpBase` + an injected + * `fetchImpl` (testable without the network); reads return data or a safe + * `null` on failure (non-fatal), mutations return a typed result so a caller + * can surface the backend's `{ reason, message }` block. + * + * Endpoints: + * - `GET /plugins` → {@link PluginListResponse} (the two manager tables + missing strip) + * - `POST /plugins/scan` → {@link PluginScanResponse} (manual scan; never auto-loads) + * - `POST /plugins/:id/load` → {@link PluginOpResponse} + * - `POST /plugins/:id/unload` body `{ force }` → {@link PluginOpResponse} + * - `POST /plugins/:id/reload` → {@link PluginOpResponse} + * - `GET /workspaces/:id/plugins` → {@link WorkspacePluginsResponse} (READ triggers + * read-through materialization) + * - `PUT /workspaces/:id/plugins/:pluginId` body `{ state }` → + * {@link SetWorkspacePluginStateResponse} (409 `core-locked` / `unloaded`) + * - `GET /workspaces/:id/plugins/providers-status` → + * {@link WorkspacePluginProvidersStatusResponse} (zero-enabled-providers guardrail) + */ + +/** Outcome of a plugin-manager mutation (load / unload / reload). */ +export type PluginOpResult = + | { readonly ok: true; readonly response: Extract } + | { + readonly ok: false; + readonly error: string; + readonly reason?: string; + /** For `cascade-needs-force`: the loaded dependents a force would remove. */ + readonly dependents?: readonly string[]; + }; + +/** Outcome of a per-workspace state write (`PUT .../plugins/:pluginId`). */ +export type WorkspaceStateResult = + | { readonly ok: true; readonly response: SetWorkspacePluginStateResponse } + | { readonly ok: false; readonly error: string; readonly reason?: string }; + +/** Outcome of the manual scan (`POST /plugins/scan`). */ +export type PluginScanResult = + | { readonly ok: true; readonly response: PluginScanResponse } + | { readonly ok: false; readonly error: string }; + +export interface PluginsHttp { + list(): Promise; + scan(): Promise; + load(id: string): Promise; + unload(id: string, force: boolean): Promise; + reload(id: string): Promise; + /** Read one workspace's effective plugin states (triggers materialization). */ + workspacePlugins(workspaceId: string): Promise; + setWorkspaceState( + workspaceId: string, + pluginId: string, + state: "enabled" | "disabled" | "unset", + ): Promise; + /** The zero-enabled-providers signal for the active workspace (§6.5). */ + providersStatus(workspaceId: string): Promise; +} + +async function errorBody(res: Response): Promise<{ + error?: string; + reason?: string; + dependents?: readonly string[]; + message?: string; +}> { + try { + return (await res.json()) as { + error?: string; + reason?: string; + dependents?: readonly string[]; + message?: string; + }; + } catch { + return {}; + } +} + +/** + * Map a plugin-manager block/error response to a `PluginOpResult`. The + * backend's blocked shape is `{ ok: false, reason, message, dependents? }`; + * other failures degrade to `{ error }` only. + */ +async function opResult(res: Response): Promise { + const body = await errorBody(res); + const message = body.message ?? body.error ?? `HTTP ${res.status}`; + return { + ok: false, + error: message, + ...(body.reason !== undefined ? { reason: body.reason } : {}), + ...(body.dependents !== undefined ? { dependents: body.dependents } : {}), + }; +} + +export function createPluginsHttp(httpBase: string, fetchImpl: typeof fetch): PluginsHttp { + const post = (path: string, body?: unknown): Promise => + fetchImpl(`${httpBase}${path}`, { + method: "POST", + ...(body !== undefined + ? { headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) } + : {}), + }); + + return { + async list(): Promise { + try { + const res = await fetchImpl(`${httpBase}/plugins`); + if (!res.ok) return null; + return (await res.json()) as PluginListResponse; + } catch { + return null; + } + }, + + async scan(): Promise { + try { + const res = await post("/plugins/scan"); + if (!res.ok) + return { ok: false, error: (await errorBody(res)).error ?? `HTTP ${res.status}` }; + return { ok: true, response: (await res.json()) as PluginScanResponse }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : "Scan request failed" }; + } + }, + + async load(id): Promise { + try { + const res = await post(`/plugins/${encodeURIComponent(id)}/load`); + if (!res.ok) return await opResult(res); + return { + ok: true, + response: (await res.json()) as Extract, + }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : "Load request failed" }; + } + }, + + async unload(id, force): Promise { + try { + const res = await post(`/plugins/${encodeURIComponent(id)}/unload`, { force }); + if (!res.ok) return await opResult(res); + return { + ok: true, + response: (await res.json()) as Extract, + }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : "Unload request failed" }; + } + }, + + async reload(id): Promise { + try { + const res = await post(`/plugins/${encodeURIComponent(id)}/reload`); + if (!res.ok) return await opResult(res); + return { + ok: true, + response: (await res.json()) as Extract, + }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : "Reload request failed" }; + } + }, + + async workspacePlugins(workspaceId): Promise { + try { + const res = await fetchImpl( + `${httpBase}/workspaces/${encodeURIComponent(workspaceId)}/plugins`, + ); + if (!res.ok) return null; + return (await res.json()) as WorkspacePluginsResponse; + } catch { + return null; + } + }, + + async setWorkspaceState(workspaceId, pluginId, state): Promise { + const body: SetWorkspacePluginStateRequest = { state }; + try { + const res = await fetchImpl( + `${httpBase}/workspaces/${encodeURIComponent(workspaceId)}/plugins/${encodeURIComponent(pluginId)}`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ); + if (!res.ok) { + const blocked = (await errorBody(res)) as Partial & { + error?: string; + }; + return { + ok: false, + error: blocked.message ?? blocked.error ?? `HTTP ${res.status}`, + ...(blocked.reason !== undefined ? { reason: blocked.reason } : {}), + }; + } + return { ok: true, response: (await res.json()) as SetWorkspacePluginStateResponse }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Set plugin state request failed", + }; + } + }, + + async providersStatus(workspaceId): Promise { + try { + const res = await fetchImpl( + `${httpBase}/workspaces/${encodeURIComponent(workspaceId)}/plugins/providers-status`, + ); + if (!res.ok) return null; + return (await res.json()) as WorkspacePluginProvidersStatusResponse; + } catch { + return null; + } + }, + }; +} diff --git a/src/features/plugins/index.ts b/src/features/plugins/index.ts new file mode 100644 index 0000000..0f2abbc --- /dev/null +++ b/src/features/plugins/index.ts @@ -0,0 +1,20 @@ +export type { + PluginOpResult, + PluginScanResult, + PluginsHttp, + WorkspaceStateResult, +} from "./adapter/http"; +export { createPluginsHttp } from "./adapter/http"; +export { computeCascade } from "./logic/cascade"; +export type { HighlightSets } from "./logic/highlight"; +export { dependencyHighlightSets } from "./logic/highlight"; +export { isLastProvider, unloadBlockReason } from "./logic/last-provider"; +export { autoCheckBackendPrereqs } from "./logic/prereqs"; +export type { PluginNode, PluginSection } from "./logic/types"; +export { TIER_ORDER, tierLabel } from "./logic/types"; +export { groupByTier, pluginNodes, splitSections } from "./logic/view-model"; +export { rowBlockReason, rowChecked, rowSelectable, stateLabel } from "./logic/workspace-row"; +export type { PluginsStore, WorkspacePluginsView } from "./store.svelte"; +export { createPluginsStore } from "./store.svelte"; +export { default as PluginManager } from "./ui/PluginManager.svelte"; +export { default as WorkspacePluginModal } from "./ui/WorkspacePluginModal.svelte"; diff --git a/src/features/plugins/logic/cascade.test.ts b/src/features/plugins/logic/cascade.test.ts new file mode 100644 index 0000000..109c08b --- /dev/null +++ b/src/features/plugins/logic/cascade.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { computeCascade } from "./cascade"; +import { node } from "./test-fixtures"; + +describe("computeCascade", () => { + it("returns [] when the target has no dependents", () => { + const nodes = [node({ id: "a" }), node({ id: "b" })]; + expect(computeCascade("a", nodes)).toEqual([]); + }); + + it("returns the direct loaded dependents", () => { + const nodes = [ + node({ id: "heartbeat" }), + node({ id: "transport-http", dependsOn: ["heartbeat"] }), + ]; + expect(computeCascade("heartbeat", nodes)).toEqual(["transport-http"]); + }); + + it("is transitive (dependent of dependent)", () => { + const nodes = [ + node({ id: "a" }), + node({ id: "b", dependsOn: ["a"] }), + node({ id: "c", dependsOn: ["b"] }), + ]; + expect(computeCascade("a", nodes)).toEqual(["b", "c"]); + }); + + it("does NOT traverse through an UNLOADED intermediate (loaded-subgraph closure — server mirror)", () => { + // c depends on b, b is unloaded: unloading a cascades to b only (b is not + // collected — unloaded) and does NOT reach c (its dep chain is already unmet). + const nodes = [ + node({ id: "a" }), + node({ id: "b", loaded: false, section: "discovered", dependsOn: ["a"] }), + node({ id: "c", dependsOn: ["b"] }), + ]; + expect(computeCascade("a", nodes)).toEqual([]); + }); + + it("ignores plugins that merely share a dependency (direction matters)", () => { + const nodes = [node({ id: "dep" }), node({ id: "x", dependsOn: ["dep"] })]; + expect(computeCascade("x", nodes)).toEqual([]); + }); + + it("handles diamond dependencies without duplicates", () => { + const nodes = [ + node({ id: "root" }), + node({ id: "left", dependsOn: ["root"] }), + node({ id: "right", dependsOn: ["root"] }), + node({ id: "bottom", dependsOn: ["left", "right"] }), + ]; + expect(computeCascade("root", nodes)).toEqual(["bottom", "left", "right"]); + }); + + it("returns [] for a target that is not in the list", () => { + expect(computeCascade("ghost", [node({ id: "a" })])).toEqual([]); + }); + + it("is deterministic (alphabetical order)", () => { + const nodes = [ + node({ id: "root" }), + node({ id: "zeta", dependsOn: ["root"] }), + node({ id: "alpha", dependsOn: ["root"] }), + ]; + expect(computeCascade("root", nodes)).toEqual(["alpha", "zeta"]); + }); +}); diff --git a/src/features/plugins/logic/cascade.ts b/src/features/plugins/logic/cascade.ts new file mode 100644 index 0000000..0cc6834 --- /dev/null +++ b/src/features/plugins/logic/cascade.ts @@ -0,0 +1,42 @@ +import type { PluginNode } from "./types"; + +/** + * The transitive set of LOADED dependents that would also be unloaded if + * `targetId` were unloaded — the §8 cascade-uncheck warning's list ("unloading + * X also unchecks Y, Z"). Client mirror of the backend's + * `computeUnloadCascade` (plan 05): the reverse-dependency closure computed + * over the LOADED subgraph only (an unloaded intermediate breaks the chain — + * a loaded dependent behind it is already unmet and is not part of THIS + * cascade). The server still enforces the real cascade; the FE computes the + * same set to warn before the request goes out. + * + * Pure input → output; deterministic order (alphabetical by id) so tests and + * renderings are stable. + */ +export function computeCascade(targetId: string, nodes: readonly PluginNode[]): readonly string[] { + // Reverse edges within the loaded subgraph: dep id → loaded dependent ids. + const dependentsOf = new Map(); + for (const node of nodes) { + if (!node.loaded) continue; + for (const dep of node.dependsOn) { + const list = dependentsOf.get(dep); + if (list) list.push(node.id); + else dependentsOf.set(dep, [node.id]); + } + } + + // BFS over reverse edges, collecting every reached loaded dependent. + const out: string[] = []; + const seen = new Set([targetId]); + const queue = [targetId]; + while (queue.length > 0) { + const current = queue.shift() as string; + for (const dependent of dependentsOf.get(current) ?? []) { + if (seen.has(dependent)) continue; + seen.add(dependent); + out.push(dependent); + queue.push(dependent); + } + } + return out.sort((a, b) => a.localeCompare(b)); +} diff --git a/src/features/plugins/logic/highlight.test.ts b/src/features/plugins/logic/highlight.test.ts new file mode 100644 index 0000000..616f7ad --- /dev/null +++ b/src/features/plugins/logic/highlight.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { dependencyHighlightSets } from "./highlight"; +import { node } from "./test-fixtures"; + +describe("dependencyHighlightSets", () => { + const nodes = [ + node({ id: "lib", loaded: false, section: "discovered" }), + node({ id: "mid", dependsOn: ["lib"] }), + node({ id: "app", dependsOn: ["mid", "lib"] }), + ]; + + it("returns empty sets for no hover", () => { + const sets = dependencyHighlightSets(null, nodes); + expect(sets.deps.size).toBe(0); + expect(sets.dependents.size).toBe(0); + }); + + it("hovering an item highlights its DIRECT dependencies (one hop)", () => { + const sets = dependencyHighlightSets("app", nodes); + expect(sets.deps).toEqual(new Set(["mid", "lib"])); + expect(sets.dependents.size).toBe(0); + }); + + it("hovering a dependency highlights its dependents (both directions work)", () => { + const sets = dependencyHighlightSets("lib", nodes); + expect(sets.deps.size).toBe(0); + expect(sets.dependents).toEqual(new Set(["mid", "app"])); + }); + + it("hovering a middle node highlights BOTH directions", () => { + const sets = dependencyHighlightSets("mid", nodes); + expect(sets.deps).toEqual(new Set(["lib"])); + expect(sets.dependents).toEqual(new Set(["app"])); + }); + + it("is one hop — not the transitive closure", () => { + // app → mid → lib: hovering app highlights mid + lib (both direct deps), + // but hovering lib highlights only DIRECT dependents (mid, app) — already + // one hop. The one-hop rule shows in: a plugin two hops away via an + // unrelated chain is not highlighted. + const chain = [ + node({ id: "a" }), + node({ id: "b", dependsOn: ["a"] }), + node({ id: "c", dependsOn: ["b"] }), + ]; + expect(dependencyHighlightSets("c", chain).deps).toEqual(new Set(["b"])); + }); + + it("returns empty sets for an unknown hovered id", () => { + const sets = dependencyHighlightSets("ghost", nodes); + expect(sets.deps.size).toBe(0); + expect(sets.dependents.size).toBe(0); + }); + + it("works across sections (a loaded row highlights an unloaded dependency)", () => { + // `app` loaded, `lib` discovered: hovering app still highlights lib. + const sets = dependencyHighlightSets("app", nodes); + expect(sets.deps.has("lib")).toBe(true); + }); +}); diff --git a/src/features/plugins/logic/highlight.ts b/src/features/plugins/logic/highlight.ts new file mode 100644 index 0000000..1bcfaad --- /dev/null +++ b/src/features/plugins/logic/highlight.ts @@ -0,0 +1,37 @@ +import type { PluginNode } from "./types"; + +/** The two highlight sets for the §8 bidirectional dependency hover. */ +export interface HighlightSets { + /** The hovered row's DIRECT dependencies (highlighted when hovering an item). */ + readonly deps: ReadonlySet; + /** The loaded/unloaded rows that DIRECTLY depend on the hovered row + * (highlighted when hovering a dependency). */ + readonly dependents: ReadonlySet; +} + +const EMPTY: HighlightSets = { deps: new Set(), dependents: new Set() }; + +/** + * Bidirectional hover highlighting (§8): hover any item → its DIRECT + * dependencies highlight; hover a dependency → its dependents highlight. + * One hop in each direction (the plan's grammar is "its dependencies" / + * "its dependents" — the visual neighborhood, not the transitive closure; + * the transitive closures live in `computeCascade` / `autoCheckBackendPrereqs`). + * + * Works across BOTH manager tables (a loaded row can highlight an unloaded + * dependency row and vice versa) — the node list spans the whole catalog. + * Pure input → output; `hoveredId` null (no hover) → both sets empty. + */ +export function dependencyHighlightSets( + hoveredId: string | null, + nodes: readonly PluginNode[], +): HighlightSets { + if (hoveredId === null) return EMPTY; + const hovered = nodes.find((n) => n.id === hoveredId); + if (hovered === undefined) return EMPTY; + const deps = new Set(hovered.dependsOn); + const dependents = new Set( + nodes.filter((n) => n.id !== hoveredId && n.dependsOn.includes(hoveredId)).map((n) => n.id), + ); + return { deps, dependents }; +} diff --git a/src/features/plugins/logic/last-provider.test.ts b/src/features/plugins/logic/last-provider.test.ts new file mode 100644 index 0000000..f85ba89 --- /dev/null +++ b/src/features/plugins/logic/last-provider.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { isLastProvider, unloadBlockReason } from "./last-provider"; +import { node } from "./test-fixtures"; + +describe("isLastProvider", () => { + it("is true for a loaded provider when it is the only one", () => { + const nodes = [node({ id: "opencode", tier: "providers" })]; + expect(isLastProvider("opencode", nodes)).toBe(true); + }); + + it("is false for a loaded provider when another provider is loaded", () => { + const nodes = [ + node({ id: "opencode", tier: "providers" }), + node({ id: "kimi", tier: "providers" }), + ]; + expect(isLastProvider("opencode", nodes)).toBe(false); + }); + + it("is false for a non-provider target (even when alone)", () => { + const nodes = [node({ id: "solo", tier: "standard" })]; + expect(isLastProvider("solo", nodes)).toBe(false); + }); + + it("is false for an UNLOADED provider", () => { + const nodes = [node({ id: "opencode", tier: "providers", loaded: false })]; + expect(isLastProvider("opencode", nodes)).toBe(false); + }); + + it("is false for an unknown id", () => { + expect(isLastProvider("ghost", [])).toBe(false); + }); + + it("is false for a core-only list (no providers at all)", () => { + const nodes = [node({ id: "kernel", tier: "core", locked: true })]; + expect(isLastProvider("kernel", nodes)).toBe(false); + }); +}); + +describe("unloadBlockReason", () => { + it("explains the core lock", () => { + const nodes = [node({ id: "kernel", tier: "core", locked: true })]; + const reason = unloadBlockReason("kernel", nodes); + expect(reason).toContain("Core plugins cannot be unloaded"); + }); + + it("explains the last-provider lock", () => { + const nodes = [node({ id: "opencode", tier: "providers" })]; + const reason = unloadBlockReason("opencode", nodes); + expect(reason).toContain("last provider"); + }); + + it("is null for an interactive row", () => { + const nodes = [ + node({ id: "opencode", tier: "providers" }), + node({ id: "kimi", tier: "providers" }), + node({ id: "app" }), + ]; + expect(unloadBlockReason("app", nodes)).toBeNull(); + expect(unloadBlockReason("opencode", nodes)).toBeNull(); + }); + + it("prefers the core lock when both would apply", () => { + const nodes = [node({ id: "kernel", tier: "core", locked: true })]; + expect(unloadBlockReason("kernel", nodes)).toContain("Core"); + }); + + it("is null for an unknown id", () => { + expect(unloadBlockReason("ghost", [])).toBeNull(); + }); +}); diff --git a/src/features/plugins/logic/last-provider.ts b/src/features/plugins/logic/last-provider.ts new file mode 100644 index 0000000..279df4d --- /dev/null +++ b/src/features/plugins/logic/last-provider.ts @@ -0,0 +1,31 @@ +import type { PluginNode } from "./types"; + +/** + * Whether unloading `targetId` would remove the LAST loaded provider — the + * §8 hard lock (the one unload the server refuses outright, never force-able). + * The UI disables the control with an explanatory tooltip; the server still + * enforces the rule. Pure input → output. + */ +export function isLastProvider(targetId: string, nodes: readonly PluginNode[]): boolean { + const target = nodes.find((n) => n.id === targetId); + if (target === undefined || !target.loaded || target.tier !== "providers") return false; + const loadedProviders = nodes.filter((n) => n.loaded && n.tier === "providers"); + return loadedProviders.length <= 1; +} + +/** + * Why the row's load/unload control is disabled, or null when it is + * interactive. Composes the two client-mirrored locks: + * - tier "core" (`locked`) → the server refuses to unload it (§3/§5); + * - the last loaded provider → the §8 hard lock. + * Missing rows have no control at all (handled separately by the UI). + */ +export function unloadBlockReason(targetId: string, nodes: readonly PluginNode[]): string | null { + const target = nodes.find((n) => n.id === targetId); + if (target === undefined) return null; + if (target.locked) return "Core plugins cannot be unloaded — they are part of the runtime."; + if (isLastProvider(targetId, nodes)) { + return "Cannot unload the last provider — Dispatch needs at least one to pick models."; + } + return null; +} diff --git a/src/features/plugins/logic/prereqs.test.ts b/src/features/plugins/logic/prereqs.test.ts new file mode 100644 index 0000000..704399c --- /dev/null +++ b/src/features/plugins/logic/prereqs.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { autoCheckBackendPrereqs } from "./prereqs"; +import { node } from "./test-fixtures"; + +describe("autoCheckBackendPrereqs", () => { + it("returns [] when all dependencies are already loaded", () => { + const nodes = [node({ id: "dep" }), node({ id: "app", dependsOn: ["dep"] })]; + expect(autoCheckBackendPrereqs("app", nodes)).toEqual([]); + }); + + it("returns the unloaded direct dependencies (to auto-check)", () => { + const nodes = [ + node({ id: "dep", loaded: false, section: "discovered" }), + node({ id: "other", loaded: false, section: "discovered" }), + node({ id: "loaded-dep" }), + node({ id: "app", dependsOn: ["dep", "other", "loaded-dep"] }), + ]; + // Only the UNLOADED deps are returned (loaded-dep needs no auto-check). + expect(autoCheckBackendPrereqs("app", nodes)).toEqual(["dep", "other"]); + }); + + it("is transitive (dep of a dep)", () => { + const nodes = [ + node({ id: "base", loaded: false, section: "discovered" }), + node({ id: "mid", loaded: false, section: "discovered", dependsOn: ["base"] }), + node({ id: "app", dependsOn: ["mid"] }), + ]; + // Load order: dependencies BEFORE their dependents. + expect(autoCheckBackendPrereqs("app", nodes)).toEqual(["mid", "base"]); + }); + + it("is one-directional: it never pulls DEPENDENTS in (no reverse traversal)", () => { + // Checking `dep` must not auto-check `app` (app depends on dep, not vice versa). + const nodes = [ + node({ id: "dep", loaded: false, section: "discovered" }), + node({ id: "app", dependsOn: ["dep"] }), + ]; + expect(autoCheckBackendPrereqs("dep", nodes)).toEqual([]); + }); + + it("excludes the target itself and skips already-seen nodes (diamond)", () => { + const nodes = [ + node({ id: "base", loaded: false, section: "discovered" }), + node({ id: "left", loaded: false, section: "discovered", dependsOn: ["base"] }), + node({ id: "right", loaded: false, section: "discovered", dependsOn: ["base"] }), + node({ id: "app", dependsOn: ["left", "right"] }), + ]; + expect(autoCheckBackendPrereqs("app", nodes)).toEqual(["left", "right", "base"]); + }); + + it("returns [] for an unknown target", () => { + expect(autoCheckBackendPrereqs("ghost", [node({ id: "a" })])).toEqual([]); + }); + + it("returns [] for a target with no dependencies", () => { + expect(autoCheckBackendPrereqs("app", [node({ id: "app" })])).toEqual([]); + }); +}); diff --git a/src/features/plugins/logic/prereqs.ts b/src/features/plugins/logic/prereqs.ts new file mode 100644 index 0000000..9066413 --- /dev/null +++ b/src/features/plugins/logic/prereqs.ts @@ -0,0 +1,45 @@ +import type { PluginNode } from "./types"; + +/** + * When CHECKING (loading) `targetId`, the transitive set of its dependencies + * that are NOT currently loaded — the plugins that must be auto-checked + * (loaded) first (§8: "Checking a frontend plugin auto-checks its required + * backend plugins (transitively)"). + * + * One-directional by construction: only `dependsOn` edges FROM the target are + * followed (frontend→backend / backend→backend). The reverse direction is + * NEVER traversed here — backend plugins never depend on frontend ones (§8), + * so a dependency auto-check never pulls the other way. + * + * Returns the ids in deterministic load order (dependencies before their + * dependents — alphabetical within a level) so the caller can issue the load + * requests in a safe sequence. Pure input → output. + */ +export function autoCheckBackendPrereqs( + targetId: string, + nodes: readonly PluginNode[], +): readonly string[] { + const byId = new Map(nodes.map((n) => [n.id, n])); + + // BFS over dependency edges (target → deps), collecting UNLOADED deps. + const out: string[] = []; + const seen = new Set([targetId]); + // Queue of [id, depth] — sorted per level so ties resolve alphabetically. + let frontier: string[] = [targetId]; + while (frontier.length > 0) { + const next: string[] = []; + for (const id of frontier) { + const node = byId.get(id); + for (const dep of node?.dependsOn ?? []) { + if (seen.has(dep)) continue; + seen.add(dep); + next.push(dep); + if (!byId.get(dep)?.loaded) out.push(dep); + } + } + frontier = next.sort((a, b) => a.localeCompare(b)); + } + // Load order: dependencies before dependents (BFS level order already is), + // alphabetical within a level. + return out; +} diff --git a/src/features/plugins/logic/test-fixtures.ts b/src/features/plugins/logic/test-fixtures.ts new file mode 100644 index 0000000..e7b3f5d --- /dev/null +++ b/src/features/plugins/logic/test-fixtures.ts @@ -0,0 +1,30 @@ +import type { PluginListResponse } from "@dispatch/transport-contract"; +import type { PluginNode } from "./types"; +import { pluginNodes } from "./view-model"; + +/** + * Build a PluginNode directly (the graph-logic tests' fixture). Defaults: + * loaded, standard tier, no deps. + */ +export function node(overrides: Partial = {}): PluginNode { + return { + id: "p", + name: "p", + version: "1.0.0", + tier: "standard", + trust: "bundled", + loaded: true, + locked: false, + dependsOn: [], + reason: null, + section: "loaded", + ...overrides, + }; +} + +/** An empty `GET /plugins` response. */ +export function emptyListResponse(): PluginListResponse { + return { loaded: [], disabledWithReason: [], discovered: [], missing: [] }; +} + +export { pluginNodes }; diff --git a/src/features/plugins/logic/types.ts b/src/features/plugins/logic/types.ts new file mode 100644 index 0000000..401913a --- /dev/null +++ b/src/features/plugins/logic/types.ts @@ -0,0 +1,44 @@ +import type { PluginTier, PluginTrustLevel } from "@dispatch/transport-contract"; + +/** Which manager table a plugin row belongs to (the response's row kinds). */ +export type PluginSection = "loaded" | "discovered" | "missing"; + +/** + * The manager's normalized view of one plugin — a single shape across the + * `GET /plugins` response's row kinds (loaded / disabledWithReason / + * discovered / missing) so the pure graph logic (cascade / prereqs / + * highlight / last-provider) and the manager tables work over ONE node type. + * Built by `pluginNodes` (`logic/view-model`) from a `PluginListResponse`. + */ +export interface PluginNode { + readonly id: string; + readonly name: string; + readonly version: string; + readonly tier: PluginTier; + /** Trust level, when the row carries one (missing rows don't). */ + readonly trust: PluginTrustLevel | null; + /** Loaded and live — the manager's checkbox reflects this. */ + readonly loaded: boolean; + /** True for tier "core" — the server refuses to unload it (locked control). */ + readonly locked: boolean; + /** Declared dependencies (plugin ids), possibly empty. */ + readonly dependsOn: readonly string[]; + /** Why the plugin is not loaded (disabled-with-reason) or gone (missing). */ + readonly reason: string | null; + readonly section: PluginSection; +} + +/** Display labels for the classification tiers (backend-canonical values). */ +export function tierLabel(tier: PluginTier): string { + switch (tier) { + case "core": + return "Core"; + case "providers": + return "Provider"; + case "standard": + return "Standard"; + } +} + +/** Display order of the tiers within a manager table (Core first). */ +export const TIER_ORDER: readonly PluginTier[] = ["core", "providers", "standard"]; diff --git a/src/features/plugins/logic/view-model.test.ts b/src/features/plugins/logic/view-model.test.ts new file mode 100644 index 0000000..c4a4547 --- /dev/null +++ b/src/features/plugins/logic/view-model.test.ts @@ -0,0 +1,129 @@ +import type { PluginListResponse } from "@dispatch/transport-contract"; +import { describe, expect, it } from "vitest"; +import { emptyListResponse } from "./test-fixtures"; +import { groupByTier, pluginNodes, splitSections } from "./view-model"; + +function listResponse(overrides: Partial = {}): PluginListResponse { + return { + loaded: [ + { + id: "kernel", + name: "Kernel", + version: "1.0.0", + tier: "core", + trust: "bundled", + locked: true, + dependsOn: [], + }, + { + id: "opencode", + name: "OpenCode", + version: "2.0.0", + tier: "providers", + trust: "bundled", + locked: false, + dependsOn: ["kernel"], + }, + ], + disabledWithReason: [ + { + id: "broken", + name: "Broken", + version: "0.1.0", + tier: "standard", + trust: "local", + reason: "activation threw", + }, + ], + discovered: [ + { + id: "fresh", + name: "Fresh", + version: "3.0.0", + tier: "standard", + trust: "external", + dir: "/plugins/fresh", + }, + ], + missing: [{ id: "gone", reason: "directory is gone", disabledAt: "2026-01-01" }], + ...overrides, + }; +} + +describe("pluginNodes", () => { + it("normalizes every row kind into one node shape", () => { + const nodes = pluginNodes(listResponse()); + expect(nodes.map((n) => n.id)).toEqual(["kernel", "opencode", "broken", "fresh", "gone"]); + }); + + it("maps loaded rows (loaded: true, locked from the row, deps kept)", () => { + const nodes = pluginNodes(listResponse()); + const kernel = nodes.find((n) => n.id === "kernel"); + expect(kernel).toMatchObject({ + loaded: true, + locked: true, + tier: "core", + section: "loaded", + dependsOn: [], + reason: null, + }); + const opencode = nodes.find((n) => n.id === "opencode"); + expect(opencode).toMatchObject({ loaded: true, locked: false, dependsOn: ["kernel"] }); + }); + + it("maps disabledWithReason rows (unloaded, reason kept, discovered section)", () => { + const nodes = pluginNodes(listResponse()); + const broken = nodes.find((n) => n.id === "broken"); + expect(broken).toMatchObject({ + loaded: false, + locked: false, + reason: "activation threw", + section: "discovered", + }); + }); + + it("maps discovered rows (unloaded, no reason)", () => { + const nodes = pluginNodes(listResponse()); + const fresh = nodes.find((n) => n.id === "fresh"); + expect(fresh).toMatchObject({ loaded: false, reason: null, section: "discovered" }); + }); + + it("maps missing rows (greyed strip; no trust/version)", () => { + const nodes = pluginNodes(listResponse()); + const gone = nodes.find((n) => n.id === "gone"); + expect(gone).toMatchObject({ + loaded: false, + section: "missing", + reason: "directory is gone", + trust: null, + version: "", + }); + }); + + it("handles the empty response", () => { + expect(pluginNodes(emptyListResponse())).toEqual([]); + }); +}); + +describe("groupByTier", () => { + it("clusters rows in tier display order (Core → Providers → Standard)", () => { + const nodes = pluginNodes(listResponse()); + const groups = groupByTier(nodes.filter((n) => n.section === "loaded")); + expect(groups.map((g) => g.tier)).toEqual(["core", "providers"]); + expect(groups[0]?.rows.map((r) => r.id)).toEqual(["kernel"]); + }); + + it("omits empty tiers", () => { + const groups = groupByTier([pluginNodes(listResponse())[2] ?? []].flat()); + expect(groups.map((g) => g.tier)).toEqual(["standard"]); + }); +}); + +describe("splitSections", () => { + it("splits the node list into the two tables + the missing strip", () => { + const sections = splitSections(pluginNodes(listResponse())); + expect(sections.loaded.map((n) => n.id)).toEqual(["kernel", "opencode"]); + expect(sections.discovered.map((n) => n.id)).toEqual(["broken", "fresh"]); + expect(sections.missing.map((n) => n.id)).toEqual(["gone"]); + }); +}); diff --git a/src/features/plugins/logic/view-model.ts b/src/features/plugins/logic/view-model.ts new file mode 100644 index 0000000..23d57d9 --- /dev/null +++ b/src/features/plugins/logic/view-model.ts @@ -0,0 +1,111 @@ +import type { PluginListResponse } from "@dispatch/transport-contract"; +import { type PluginNode, type PluginSection, TIER_ORDER } from "./types"; + +/** + * Normalize a `GET /plugins` response into the flat `PluginNode` list the + * manager tables + graph logic consume. Each response row kind maps to a + * node: + * - `loaded` → section "loaded" (`loaded: true`, `locked` from the row — + * tier "core" is always locked); + * - `disabledWithReason` → section "discovered" with the refusal `reason` + * (present on disk, refused/failed — greyed rows with a visible why); + * - `discovered` → section "discovered" (`loaded: false`, unchecked); + * - `missing` → section "missing" (override record whose directory is gone). + * + * Pure input → output; zero DOM/fetch. + */ +export function pluginNodes(response: PluginListResponse): readonly PluginNode[] { + const nodes: PluginNode[] = []; + for (const row of response.loaded) { + nodes.push({ + id: row.id, + name: row.name, + version: row.version, + tier: row.tier, + trust: row.trust, + loaded: true, + locked: row.locked, + dependsOn: row.dependsOn, + reason: null, + section: "loaded", + }); + } + for (const row of response.disabledWithReason) { + nodes.push({ + id: row.id, + name: row.name, + version: row.version, + tier: row.tier, + trust: row.trust, + loaded: false, + locked: false, + dependsOn: [], + reason: row.reason, + section: "discovered", + }); + } + for (const row of response.discovered) { + nodes.push({ + id: row.id, + name: row.name, + version: row.version, + tier: row.tier, + trust: row.trust, + loaded: false, + locked: false, + dependsOn: [], + reason: null, + section: "discovered", + }); + } + for (const row of response.missing) { + nodes.push({ + id: row.id, + name: row.id, + version: "", + tier: "standard", + trust: null, + loaded: false, + locked: false, + dependsOn: [], + reason: row.reason ?? "directory is gone", + section: "missing", + }); + } + return nodes; +} + +/** + * Cluster nodes for one manager table: tier groups in display order (Core → + * Providers → Standard), each tier's rows sorted by id. Tiers with no rows + * are omitted. Used by the loaded table's tier grouping (plan 07 §7.2). + */ +export function groupByTier( + nodes: readonly PluginNode[], +): ReadonlyArray<{ readonly tier: PluginNode["tier"]; readonly rows: readonly PluginNode[] }> { + const out: { tier: PluginNode["tier"]; rows: PluginNode[] }[] = []; + for (const tier of TIER_ORDER) { + const rows = nodes.filter((n) => n.tier === tier).sort((a, b) => a.id.localeCompare(b.id)); + if (rows.length > 0) out.push({ tier, rows }); + } + // Any tier outside TIER_ORDER (defensive — the contract pins three values). + const known = new Set(TIER_ORDER); + const rest = nodes.filter((n) => !known.has(n.tier)).sort((a, b) => a.id.localeCompare(b.id)); + if (rest.length > 0) out.push({ tier: rest[0]?.tier ?? "standard", rows: rest }); + return out; +} + +/** Split nodes into the manager's two tables + the missing strip. */ +export function splitSections(nodes: readonly PluginNode[]): { + readonly loaded: readonly PluginNode[]; + readonly discovered: readonly PluginNode[]; + readonly missing: readonly PluginNode[]; +} { + const bySection = (section: PluginSection): PluginNode[] => + nodes.filter((n) => n.section === section); + return { + loaded: bySection("loaded"), + discovered: bySection("discovered"), + missing: bySection("missing"), + }; +} diff --git a/src/features/plugins/logic/workspace-row.test.ts b/src/features/plugins/logic/workspace-row.test.ts new file mode 100644 index 0000000..4c7d29e --- /dev/null +++ b/src/features/plugins/logic/workspace-row.test.ts @@ -0,0 +1,67 @@ +import type { WorkspacePluginRow } from "@dispatch/transport-contract"; +import { describe, expect, it } from "vitest"; +import { rowBlockReason, rowChecked, rowSelectable, stateLabel } from "./workspace-row"; + +function row(overrides: Partial = {}): WorkspacePluginRow { + return { + pluginId: "mcp", + tier: "standard", + state: "undefined", + loaded: true, + locked: false, + ...overrides, + }; +} + +describe("rowChecked", () => { + it("is true only for an explicit enabled record", () => { + expect(rowChecked(row({ state: "enabled" }))).toBe(true); + expect(rowChecked(row({ state: "disabled" }))).toBe(false); + expect(rowChecked(row({ state: "undefined" }))).toBe(false); + }); +}); + +describe("rowSelectable", () => { + it("is true for a loaded, unlocked row (all three states)", () => { + for (const state of ["enabled", "disabled", "undefined"] as const) { + expect(rowSelectable(row({ state }))).toBe(true); + } + }); + + it("is false for an UNLOADED plugin (§7 strict layering — greyed out)", () => { + expect(rowSelectable(row({ loaded: false, state: "disabled" }))).toBe(false); + }); + + it("is false for a LOCKED (core) row — the server refuses the PUT (409)", () => { + expect(rowSelectable(row({ tier: "core", locked: true, state: "enabled" }))).toBe(false); + }); +}); + +describe("rowBlockReason", () => { + it("points at the global manager for unloaded plugins", () => { + const reason = rowBlockReason(row({ loaded: false })); + expect(reason).toContain("plugin manager"); + }); + + it("explains the core lock", () => { + const reason = rowBlockReason(row({ tier: "core", locked: true })); + expect(reason).toContain("Core plugins are enabled by default"); + }); + + it("prefers the unloaded reason when both apply", () => { + const reason = rowBlockReason(row({ loaded: false, tier: "core", locked: true })); + expect(reason).toContain("plugin manager"); + }); + + it("is null for an interactive row", () => { + expect(rowBlockReason(row())).toBeNull(); + }); +}); + +describe("stateLabel", () => { + it("renders the three wire states in a uniform grammar", () => { + expect(stateLabel("enabled")).toBe("enabled"); + expect(stateLabel("disabled")).toBe("disabled"); + expect(stateLabel("undefined")).toBe("not set"); + }); +}); diff --git a/src/features/plugins/logic/workspace-row.ts b/src/features/plugins/logic/workspace-row.ts new file mode 100644 index 0000000..c85b759 --- /dev/null +++ b/src/features/plugins/logic/workspace-row.ts @@ -0,0 +1,54 @@ +import type { WorkspacePluginRow, WorkspacePluginStateValue } from "@dispatch/transport-contract"; + +/** + * Pure helpers for the per-workspace plugin modal (plan 06 §6.6 wire rows → + * checkbox semantics). Zero DOM. + * + * State model (backend-canonical): `"enabled"` / `"disabled"` — an explicit + * record — or `"undefined"` for NO record (the blank slate; behaves disabled + * for Providers/Standard). Core rows arrive materialized `enabled` (Q-A). + */ + +/** Whether the modal checkbox is checked for a row (explicit `enabled` only). */ +export function rowChecked(row: WorkspacePluginRow): boolean { + return row.state === "enabled"; +} + +/** + * Whether the row's checkbox is selectable. Two unselectable cases (§7 + * strict layering + plan 06 §6.6): + * - the plugin is NOT loaded globally → greyed out and unselectable (only the + * global manager loads code); + * - the server reports the row locked (tier "core") → the server refuses the + * PUT (`core-locked` 409), so the control is disabled with a tooltip. + */ +export function rowSelectable(row: WorkspacePluginRow): boolean { + return row.loaded && !row.locked; +} + +/** + * Why the row is unselectable (tooltip text), or null when interactive. + * Unloaded beats locked in the copy (the user's actionable next step is the + * global manager). + */ +export function rowBlockReason(row: WorkspacePluginRow): string | null { + if (!row.loaded) { + return "Not loaded globally — load it in the plugin manager first (only the manager loads code)."; + } + if (row.locked) { + return "Core plugins are enabled by default in every workspace and managed globally."; + } + return null; +} + +/** The effective-state label shown next to a row (uniform three-state grammar). */ +export function stateLabel(state: WorkspacePluginStateValue): string { + switch (state) { + case "enabled": + return "enabled"; + case "disabled": + return "disabled"; + case "undefined": + return "not set"; + } +} diff --git a/src/features/plugins/store.svelte.ts b/src/features/plugins/store.svelte.ts new file mode 100644 index 0000000..580293e --- /dev/null +++ b/src/features/plugins/store.svelte.ts @@ -0,0 +1,198 @@ +import type { + PluginListResponse, + SetWorkspacePluginStateRequest, + WorkspacePluginRow, +} from "@dispatch/transport-contract"; +import type { PluginOpResult, PluginScanResult, PluginsHttp } from "./adapter/http"; +import type { PluginNode } from "./logic/types"; +import { pluginNodes } from "./logic/view-model"; + +/** + * Plugins store — a thin reactive wrapper over the injected `PluginsHttp` + * edge (the workspaces-store pattern). Owns: + * - the manager's plugin list (`GET /plugins`) normalized to `PluginNode`s; + * - ONE open workspace modal's view (`GET /workspaces/:id/plugins` — read-through + * materialization makes this first read the workspace's "open"); + * - pending/error state for the mutations. + * + * Multi-client coherence (§11): the composition root calls + * `onExtensionsChanged()` when the server broadcasts `extensions-changed` — + * the store refetches the list (and the open workspace view), so every + * connected client converges. No ambient state: one instance per composition + * root, passed explicitly; no module-level mutables. + */ +export interface PluginsStore { + /** The last `GET /plugins` response (null until the first fetch lands). */ + readonly list: PluginListResponse | null; + /** The normalized nodes across ALL sections (loaded + discovered + missing). */ + readonly nodes: readonly PluginNode[]; + readonly loading: boolean; + /** The last mutation/scan/load error message, or null. */ + readonly error: string | null; + /** The manual scan's result (null until a scan ran), for the summary line. */ + readonly lastScan: PluginScanResult | null; + + /** Refetch `GET /plugins`. */ + refresh(): Promise; + /** `POST /plugins/scan` (manual; the server never auto-loads new plugins). */ + scan(): Promise; + /** `POST /plugins/:id/load`. */ + load(id: string): Promise; + /** `POST /plugins/:id/unload` with `{ force }` (the §8 force semantics). */ + unload(id: string, force: boolean): Promise; + /** `POST /plugins/:id/reload`. */ + reload(id: string): Promise; + + /** + * The open workspace modal's rows (null when nothing loaded yet). Cleared + * IMMEDIATELY on open so a slow/failing fetch never shows the previous + * workspace's rows (the refreshCwd/refreshComputer pattern). + */ + readonly workspaceView: WorkspacePluginsView | null; + /** Load/reload the workspace modal's view (`GET /workspaces/:id/plugins`). */ + openWorkspace(workspaceId: string): Promise; + /** + * Write one plugin's state in one workspace (`PUT .../plugins/:pluginId`). + * On success the open view's row is updated in place from the response; on + * failure the error is surfaced AND the view refetched (server-authoritative + * reconciliation — the checkbox never lies). + */ + setWorkspaceState( + workspaceId: string, + pluginId: string, + state: SetWorkspacePluginStateRequest["state"], + ): Promise; + + /** + * Multi-client coherence hook (§11): the composition root calls + * `onExtensionsChanged()` when the server broadcasts `extensions-changed` — + * the store refetches the list (and the open workspace view), so every + * connected client converges. Fire-and-forget. + */ + onExtensionsChanged(): void; +} + +/** One open workspace modal's view (rows + materialized provider count). */ +export interface WorkspacePluginsView { + readonly workspaceId: string; + readonly rows: readonly WorkspacePluginRow[]; + /** Loaded provider-tier plugins effectively enabled here (§6.5). */ + readonly enabledProviderCount: number; +} + +export function createPluginsStore(http: PluginsHttp): PluginsStore { + let list = $state(null); + let loading = $state(false); + let error = $state(null); + let lastScan = $state(null); + let workspaceView = $state(null); + + // Normalized nodes — derived so the graph logic re-runs only when the list + // changes, not on every read. + const nodes = $derived(list === null ? [] : pluginNodes(list)); + + return { + get list(): PluginListResponse | null { + return list; + }, + get nodes(): readonly PluginNode[] { + return nodes; + }, + get loading(): boolean { + return loading; + }, + get error(): string | null { + return error; + }, + get lastScan(): PluginScanResult | null { + return lastScan; + }, + get workspaceView(): WorkspacePluginsView | null { + return workspaceView; + }, + + async refresh(): Promise { + loading = true; + const data = await http.list(); + if (data !== null) list = data; + loading = false; + }, + + async scan(): Promise { + error = null; + const result = await http.scan(); + lastScan = result; + if (!result.ok) error = result.error; + // A scan broadcasts `extensions-changed` server-side; refresh anyway so + // the manager is coherent even without a WS connection. + await this.refresh(); + return result; + }, + + async load(id): Promise { + error = null; + const result = await http.load(id); + if (!result.ok) error = result.error; + await this.refresh(); + return result; + }, + + async unload(id, force): Promise { + error = null; + const result = await http.unload(id, force); + if (!result.ok) error = result.error; + await this.refresh(); + return result; + }, + + async reload(id): Promise { + error = null; + const result = await http.reload(id); + if (!result.ok) error = result.error; + await this.refresh(); + return result; + }, + + async openWorkspace(workspaceId): Promise { + // Clear immediately so a slow/failing fetch never shows the PREVIOUS + // workspace's rows in the new modal. + workspaceView = null; + const data = await http.workspacePlugins(workspaceId); + if (data === null) { + error = `Failed to load plugins for workspace "${workspaceId}"`; + return; + } + workspaceView = { + workspaceId: data.workspaceId, + rows: data.plugins, + enabledProviderCount: data.enabledProviderCount, + }; + }, + + async setWorkspaceState(workspaceId, pluginId, state): Promise { + const result = await http.setWorkspaceState(workspaceId, pluginId, state); + if (result.ok) { + // Server-authoritative in-place row update (the response carries the + // resulting effective state — "undefined" after "unset"). + if (workspaceView?.workspaceId === workspaceId) { + const rows = workspaceView.rows.map( + (row): WorkspacePluginRow => + row.pluginId === pluginId ? { ...row, state: result.response.state } : row, + ); + workspaceView = { ...workspaceView, rows }; + } + return; + } + error = result.error; + // Refusal (409 core-locked / unloaded) or failure — reconcile from the + // server so the checkbox never lies. + await this.openWorkspace(workspaceId); + }, + + onExtensionsChanged(): void { + void this.refresh(); + const open = workspaceView?.workspaceId; + if (open !== undefined) void this.openWorkspace(open); + }, + }; +} diff --git a/src/features/plugins/store.test.ts b/src/features/plugins/store.test.ts new file mode 100644 index 0000000..be301ee --- /dev/null +++ b/src/features/plugins/store.test.ts @@ -0,0 +1,183 @@ +import type { PluginListResponse, WorkspacePluginsResponse } from "@dispatch/transport-contract"; +import { describe, expect, it, vi } from "vitest"; +import type { + PluginOpResult, + PluginScanResult, + PluginsHttp, + WorkspaceStateResult, +} from "./adapter/http"; +import { createPluginsStore } from "./store.svelte"; + +const listResponse: PluginListResponse = { + loaded: [ + { + id: "kernel", + name: "Kernel", + version: "1.0.0", + tier: "core", + trust: "bundled", + locked: true, + dependsOn: [], + }, + ], + disabledWithReason: [], + discovered: [ + { + id: "fresh", + name: "Fresh", + version: "1.0.0", + tier: "standard", + trust: "external", + dir: "/plugins/fresh", + }, + ], + missing: [], +}; + +const wsResponse: WorkspacePluginsResponse = { + workspaceId: "ws", + plugins: [ + { pluginId: "kernel", tier: "core", state: "enabled", loaded: true, locked: true }, + { pluginId: "mcp", tier: "standard", state: "undefined", loaded: true, locked: false }, + ], + enabledProviderCount: 1, +}; + +/** A fake PluginsHttp with a controllable list + workspace response. */ +function fakeHttp(opts: { + list?: PluginListResponse | null; + workspacePlugins?: WorkspacePluginsResponse | null; +}) { + // `=== undefined` (not `??`) so an explicit `null` override defeats the default. + const list = () => (opts.list === undefined ? listResponse : opts.list); + const ws = () => (opts.workspacePlugins === undefined ? wsResponse : opts.workspacePlugins); + const opOk = (action: "load" | "unload" | "reload") => ({ ok: true, action, id: "x" }) as const; + return { + list: vi.fn(async (): Promise => list()), + scan: vi.fn( + async (): Promise => ({ + ok: true, + response: { root: "/plugins", newPlugins: [], invalid: [], incompatible: [] }, + }), + ), + load: vi.fn(async (): Promise => ({ ok: true, response: opOk("load") })), + unload: vi.fn(async (): Promise => ({ ok: true, response: opOk("unload") })), + reload: vi.fn(async (): Promise => ({ ok: true, response: opOk("reload") })), + workspacePlugins: vi.fn(async (): Promise => ws()), + setWorkspaceState: vi.fn( + async (): Promise => ({ + ok: true, + response: { workspaceId: "ws", pluginId: "mcp", state: "enabled" }, + }), + ), + providersStatus: vi.fn(async () => ({ workspaceId: "ws", enabledProviderCount: 1 })), + } satisfies PluginsHttp; +} + +describe("createPluginsStore — manager list", () => { + it("fetches + normalizes the list into nodes", async () => { + const store = createPluginsStore(fakeHttp({})); + expect(store.list).toBeNull(); + expect(store.nodes).toEqual([]); + await store.refresh(); + expect(store.list).toEqual(listResponse); + expect(store.nodes.map((n) => n.id)).toEqual(["kernel", "fresh"]); + }); + + it("mutation results are surfaced and the list is refetched", async () => { + const http = fakeHttp({}); + const store = createPluginsStore(http); + await store.refresh(); + const result = await store.unload("kernel", false); + expect(result.ok).toBe(true); + expect(http.unload).toHaveBeenCalledWith("kernel", false); + expect(http.list).toHaveBeenCalledTimes(2); + }); + + it("scan records the result + refreshes", async () => { + const http = fakeHttp({}); + const store = createPluginsStore(http); + await store.scan(); + expect(store.lastScan?.ok).toBe(true); + expect(http.scan).toHaveBeenCalledOnce(); + expect(http.list).toHaveBeenCalledTimes(1); // refresh after scan (initial list fetch absent) + }); + + it("a mutation error lands in store.error", async () => { + const http = fakeHttp({}); + http.unload = vi.fn(async (): Promise => ({ ok: false, error: "blocked" })); + const store = createPluginsStore(http); + await store.unload("kernel", false); + expect(store.error).toBe("blocked"); + }); +}); + +describe("createPluginsStore — workspace view", () => { + it("openWorkspace loads the rows + enabled provider count", async () => { + const store = createPluginsStore(fakeHttp({})); + await store.openWorkspace("ws"); + expect(store.workspaceView).toEqual({ + workspaceId: "ws", + rows: wsResponse.plugins, + enabledProviderCount: 1, + }); + }); + + it("setWorkspaceState updates the row in place from the response", async () => { + const store = createPluginsStore(fakeHttp({})); + await store.openWorkspace("ws"); + await store.setWorkspaceState("ws", "mcp", "enabled"); + const row = store.workspaceView?.rows.find((r) => r.pluginId === "mcp"); + expect(row?.state).toBe("enabled"); + }); + + it("a refused setWorkspaceState refetches (server-authoritative reconcile)", async () => { + const http = fakeHttp({}); + http.setWorkspaceState = vi.fn( + async (): Promise => ({ + ok: false, + error: "core is locked", + reason: "core-locked", + }), + ); + const store = createPluginsStore(http); + await store.openWorkspace("ws"); + await store.setWorkspaceState("ws", "kernel", "disabled"); + expect(store.error).toBe("core is locked"); + expect(http.workspacePlugins).toHaveBeenCalledTimes(2); + }); + + it("a failed openWorkspace clears the view (no stale rows from another workspace)", async () => { + const http = fakeHttp({ workspacePlugins: null }); + const store = createPluginsStore(http); + await store.openWorkspace("other"); + expect(store.workspaceView).toBeNull(); + expect(store.error).toContain("other"); + }); +}); + +describe("createPluginsStore — multi-client coherence (§11)", () => { + it("onExtensionsChanged refetches the list + the open workspace view", async () => { + const http = fakeHttp({}); + const store = createPluginsStore(http); + await store.openWorkspace("ws"); + expect(http.list).toHaveBeenCalledTimes(0); + expect(http.workspacePlugins).toHaveBeenCalledTimes(1); + + store.onExtensionsChanged(); + await vi.waitFor(() => { + expect(http.list).toHaveBeenCalledTimes(1); + expect(http.workspacePlugins).toHaveBeenCalledTimes(2); + }); + }); + + it("onExtensionsChanged does not refetch a workspace view when none is open", async () => { + const http = fakeHttp({}); + const store = createPluginsStore(http); + store.onExtensionsChanged(); + await vi.waitFor(() => { + expect(http.list).toHaveBeenCalledTimes(1); + expect(http.workspacePlugins).toHaveBeenCalledTimes(0); + }); + }); +}); diff --git a/src/features/plugins/ui/PluginManager.svelte b/src/features/plugins/ui/PluginManager.svelte new file mode 100644 index 0000000..cd3d542 --- /dev/null +++ b/src/features/plugins/ui/PluginManager.svelte @@ -0,0 +1,217 @@ + + + + +{#if pendingUnload !== null} + 0 + ? [ + `Unloading ${pendingUnload.node.id} also unchecks: ${pendingUnload.cascade.join(", ")}.`, + ] + : []), + ...(pendingUnload.notice !== null ? [pendingUnload.notice] : []), + ]} + confirmLabel={pendingUnload.cascade.length > 0 ? "Unload with force" : "Force unload"} + onCancel={cancelUnload} + onConfirm={confirmUnload} + /> +{/if} diff --git a/src/features/plugins/ui/PluginManager.test.ts b/src/features/plugins/ui/PluginManager.test.ts new file mode 100644 index 0000000..c81938c --- /dev/null +++ b/src/features/plugins/ui/PluginManager.test.ts @@ -0,0 +1,284 @@ +import type { PluginListResponse } from "@dispatch/transport-contract"; +import { render, screen } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import type { PluginOpResult, PluginsHttp } from "../adapter/http"; +import { createPluginsStore } from "../store.svelte"; +import PluginManager from "./PluginManager.svelte"; + +const listResponse: PluginListResponse = { + loaded: [ + { + id: "kernel", + name: "Kernel", + version: "1.0.0", + tier: "core", + trust: "bundled", + locked: true, + dependsOn: [], + }, + { + id: "opencode", + name: "OpenCode", + version: "2.0.0", + tier: "providers", + trust: "bundled", + locked: false, + dependsOn: [], + }, + { + id: "transport-http", + name: "Transport HTTP", + version: "1.1.0", + tier: "standard", + trust: "bundled", + locked: false, + dependsOn: ["heartbeat"], + }, + { + id: "heartbeat", + name: "Heartbeat", + version: "1.1.0", + tier: "standard", + trust: "bundled", + locked: false, + dependsOn: [], + }, + ], + disabledWithReason: [ + { + id: "broken", + name: "Broken", + version: "0.1.0", + tier: "standard", + trust: "local", + reason: "activation threw", + }, + ], + discovered: [ + { + id: "fresh", + name: "Fresh", + version: "3.0.0", + tier: "standard", + trust: "external", + dir: "/plugins/fresh", + }, + ], + missing: [{ id: "gone", reason: "directory is gone" }], +}; + +/** A fake PluginsHttp (the OUTERMOST edge — the store itself is real). */ +function fakeHttp(overrides: Partial = {}): PluginsHttp { + return { + list: vi.fn(async () => listResponse), + scan: vi.fn(async () => ({ + ok: true, + response: { root: "/plugins", newPlugins: [], invalid: [], incompatible: [] }, + })), + load: vi.fn(async () => ({ ok: true, response: { ok: true, action: "load", id: "x" } })), + unload: vi.fn(async () => ({ ok: true, response: { ok: true, action: "unload", id: "x" } })), + reload: vi.fn(async () => ({ ok: true, response: { ok: true, action: "reload", id: "x" } })), + workspacePlugins: vi.fn(async () => null), + setWorkspaceState: vi.fn(async () => ({ + ok: true, + response: { workspaceId: "ws", pluginId: "x", state: "enabled" }, + })), + providersStatus: vi.fn(async () => ({ workspaceId: "ws", enabledProviderCount: 1 })), + ...overrides, + } as unknown as PluginsHttp; +} + +async function renderManager(http: PluginsHttp = fakeHttp()) { + const store = createPluginsStore(http); + await store.refresh(); + render(PluginManager, { props: { plugins: store, onClose: vi.fn() } }); + return { store, http }; +} + +describe("PluginManager — tables + locked core", () => { + it("renders the loaded table with tier groups and the discovered table", async () => { + await renderManager(); + expect(screen.getByText("Loaded")).toBeInTheDocument(); + expect(screen.getByText("Discovered (not loaded)")).toBeInTheDocument(); + // Tier subheadings (Core / Provider / Standard) — each also appears as a + // row badge, so assert at least one occurrence of each. + expect(screen.getAllByText("Core").length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText("Provider").length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText("Standard").length).toBeGreaterThanOrEqual(1); + expect(screen.getByText("Missing (directory gone)")).toBeInTheDocument(); + expect(screen.getByText("gone")).toBeInTheDocument(); + }); + + it("core rows show a lock instead of a checkbox", async () => { + await renderManager(); + // The kernel row has no checkbox (the lock glyph instead). + expect(screen.queryByRole("checkbox", { name: /Unload kernel/i })).not.toBeInTheDocument(); + // Provider + standard rows have checkboxes. + expect(screen.getByRole("checkbox", { name: /Unload opencode/i })).toBeChecked(); + expect(screen.getByRole("checkbox", { name: /Unload heartbeat/i })).toBeChecked(); + }); + + it("disabled-with-reason rows show their reason", async () => { + await renderManager(); + expect(screen.getByText("activation threw")).toBeInTheDocument(); + }); +}); + +describe("PluginManager — cascade-uncheck warning (§8)", () => { + it("unchecking a plugin with loaded dependents warns first (no request yet)", async () => { + const user = userEvent.setup(); + const { http } = await renderManager(); + // transport-http depends on heartbeat → unchecking heartbeat cascades. + await user.click(screen.getByRole("checkbox", { name: /Unload heartbeat/i })); + expect(http.unload).not.toHaveBeenCalled(); + expect(screen.getByRole("dialog", { name: /Unload heartbeat/i })).toBeInTheDocument(); + expect(screen.getByText(/also unchecks: transport-http/)).toBeInTheDocument(); + }); + + it("accepting the warning issues the forced unload (the cascade is server-side)", async () => { + const user = userEvent.setup(); + const { http } = await renderManager(); + await user.click(screen.getByRole("checkbox", { name: /Unload heartbeat/i })); + await user.click(screen.getByRole("button", { name: "Unload with force" })); + expect(http.unload).toHaveBeenCalledWith("heartbeat", true); + expect(screen.queryByRole("dialog", { name: /Unload heartbeat/i })).not.toBeInTheDocument(); + }); + + it("cancelling the warning issues nothing", async () => { + const user = userEvent.setup(); + const { http } = await renderManager(); + await user.click(screen.getByRole("checkbox", { name: /Unload heartbeat/i })); + await user.click(screen.getByRole("button", { name: "Cancel" })); + expect(http.unload).not.toHaveBeenCalled(); + }); + + it("unchecking WITHOUT dependents unloads directly (no warning)", async () => { + const user = userEvent.setup(); + const { http } = await renderManager(); + // transport-http has no loaded dependents (it DEPENDS on heartbeat, not + // the reverse) — and it is not a provider, so the last-provider lock + // (which correctly disables `opencode`, the fixture's only provider) + // does not apply. + await user.click(screen.getByRole("checkbox", { name: /Unload transport-http/i })); + expect(http.unload).toHaveBeenCalledWith("transport-http", false); + expect(screen.queryByRole("dialog", { name: /Unload/i })).not.toBeInTheDocument(); + }); + + it("a refused unload offers force with the global-stop copy (§8 in-use force)", async () => { + const user = userEvent.setup(); + const http = fakeHttp({ + unload: vi.fn( + async (): Promise => ({ + ok: false, + error: "the engine refused — the plugin is in use", + reason: "engine-refused", + }), + ), + }); + await renderManager(http); + await user.click(screen.getByRole("checkbox", { name: /Unload transport-http/i })); + // The refusal is surfaced both in the manager's error line and the + // confirm dialog's notice (the dialog appears after the async refusal). + expect( + await screen.findAllByText(/the engine refused/).then((els) => els.length), + ).toBeGreaterThanOrEqual(1); + expect(await screen.findByText(/stops ALL agents globally/i)).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Force unload" })); + expect(http.unload).toHaveBeenLastCalledWith("transport-http", true); + }); +}); + +describe("PluginManager — last-provider lock (§8)", () => { + it("the last loaded provider's checkbox is disabled with a tooltip", async () => { + const single: PluginListResponse = { + loaded: [ + { + id: "opencode", + name: "OpenCode", + version: "2.0.0", + tier: "providers", + trust: "bundled", + locked: false, + dependsOn: [], + }, + { + id: "kernel", + name: "Kernel", + version: "1.0.0", + tier: "core", + trust: "bundled", + locked: true, + dependsOn: [], + }, + ], + disabledWithReason: [], + discovered: [], + missing: [], + }; + const store = createPluginsStore(fakeHttp({ list: async () => single })); + await store.refresh(); + render(PluginManager, { props: { plugins: store, onClose: vi.fn() } }); + const checkbox = screen.getByRole("checkbox", { name: /Unload opencode/i }); + expect(checkbox).toBeDisabled(); + // The explanatory copy is wired via the title attribute (the tooltip). + expect(checkbox.getAttribute("title")).toMatch(/last provider/i); + }); +}); + +describe("PluginManager — load with auto-checked prereqs (§8)", () => { + it("checking a discovered plugin loads it and reflects the stateful refetch", async () => { + const user = userEvent.setup(); + // Stateful fake: after `load("fresh")`, the list reports fresh as loaded + // (the store refetches from the server after the op — the checkbox must + // reflect the NEW list, not a local guess). + let freshLoaded = false; + const stateful: PluginsHttp = { + ...fakeHttp(), + list: vi.fn(async () => + freshLoaded + ? { + ...listResponse, + discovered: [], + loaded: [ + ...listResponse.loaded, + { + id: "fresh", + name: "Fresh", + version: "3.0.0", + tier: "standard", + trust: "external", + locked: false, + dependsOn: [], + }, + ], + } + : listResponse, + ), + load: vi.fn(async (id: string) => { + if (id === "fresh") freshLoaded = true; + return { ok: true, response: { ok: true, action: "load", id } }; + }), + } as unknown as PluginsHttp; + const store = createPluginsStore(stateful); + await store.refresh(); + render(PluginManager, { props: { plugins: store, onClose: vi.fn() } }); + await user.click(screen.getByRole("checkbox", { name: /Load fresh/i })); + expect(stateful.load).toHaveBeenCalledWith("fresh"); + expect(screen.getByRole("checkbox", { name: /Unload fresh/i })).toBeChecked(); + }); + + it("the Scan button triggers a manual scan", async () => { + const user = userEvent.setup(); + const { http } = await renderManager(); + await user.click(screen.getByRole("button", { name: /Scan/ })); + expect(http.scan).toHaveBeenCalledOnce(); + }); + + it("the Reload affordance calls reload for a loaded row", async () => { + const user = userEvent.setup(); + const { http } = await renderManager(); + await user.click(screen.getByRole("button", { name: /Reload transport-http/i })); + expect(http.reload).toHaveBeenCalledWith("transport-http"); + }); +}); diff --git a/src/features/plugins/ui/PluginRow.svelte b/src/features/plugins/ui/PluginRow.svelte new file mode 100644 index 0000000..457bd29 --- /dev/null +++ b/src/features/plugins/ui/PluginRow.svelte @@ -0,0 +1,116 @@ + + + + onHover(node.id)} + onmouseout={() => onUnhover(node.id)} + onfocus={() => onHover(node.id)} + onblur={() => onUnhover(node.id)} +> + + {#if hasCheckbox} + {#if node.locked} + 🔒 + {:else} + onToggle(node, e.currentTarget.checked)} + /> + {/if} + {/if} + + +
+ {node.name} + {#if node.reason !== null} + {node.reason} + {/if} + {#if node.dependsOn.length > 0} + depends on: {node.dependsOn.join(", ")} + {/if} +
+ + + {tierLabel(node.tier)} + {#if node.trust !== null} + {node.trust} + {/if} + + {node.version} + + {#if node.loaded && onReload !== undefined} + + {/if} + + diff --git a/src/features/plugins/ui/PluginTable.svelte b/src/features/plugins/ui/PluginTable.svelte new file mode 100644 index 0000000..717f839 --- /dev/null +++ b/src/features/plugins/ui/PluginTable.svelte @@ -0,0 +1,85 @@ + + +
+

{title}

+ {#if rows.length === 0} +

Nothing here.

+ {:else} + + + + + + + + + + + {#each groups as group (group.tier)} + + + + + {#each group.rows as node (node.id)} + + {/each} + + {/each} +
+ Load/unload + PluginClassVersion + Actions +
+ {tierLabel(group.tier)} +
+ {/if} +
diff --git a/src/features/plugins/ui/UnloadConfirmModal.svelte b/src/features/plugins/ui/UnloadConfirmModal.svelte new file mode 100644 index 0000000..6ae95cc --- /dev/null +++ b/src/features/plugins/ui/UnloadConfirmModal.svelte @@ -0,0 +1,40 @@ + + + diff --git a/src/features/plugins/ui/WorkspacePluginModal.svelte b/src/features/plugins/ui/WorkspacePluginModal.svelte new file mode 100644 index 0000000..bce885b --- /dev/null +++ b/src/features/plugins/ui/WorkspacePluginModal.svelte @@ -0,0 +1,110 @@ + + + diff --git a/src/features/plugins/ui/WorkspacePluginModal.test.ts b/src/features/plugins/ui/WorkspacePluginModal.test.ts new file mode 100644 index 0000000..32e88d7 --- /dev/null +++ b/src/features/plugins/ui/WorkspacePluginModal.test.ts @@ -0,0 +1,124 @@ +import type { WorkspacePluginsResponse } from "@dispatch/transport-contract"; +import { render, screen } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import type { PluginsHttp } from "../adapter/http"; +import { createPluginsStore } from "../store.svelte"; +import WorkspacePluginModal from "./WorkspacePluginModal.svelte"; + +const wsResponse: WorkspacePluginsResponse = { + workspaceId: "ws", + plugins: [ + // Core: enabled + locked (the server materializes core to enabled and + // refuses its PUT with a 409 core-locked — plan 06 §6.6). + { pluginId: "kernel", tier: "core", state: "enabled", loaded: true, locked: true }, + // Loaded standard: interactive. + { pluginId: "mcp", tier: "standard", state: "undefined", loaded: true, locked: false }, + // UNLOADED: greyed out + unselectable (§7 strict layering). + { pluginId: "notloaded", tier: "standard", state: "disabled", loaded: false, locked: false }, + ], + enabledProviderCount: 1, +}; + +function fakeHttp(overrides: Partial = {}): PluginsHttp { + return { + list: vi.fn(async () => null), + scan: vi.fn(), + load: vi.fn(), + unload: vi.fn(), + reload: vi.fn(), + workspacePlugins: vi.fn(async () => wsResponse), + setWorkspaceState: vi.fn(async () => ({ + ok: true, + response: { workspaceId: "ws", pluginId: "mcp", state: "enabled" }, + })), + providersStatus: vi.fn(async () => ({ workspaceId: "ws", enabledProviderCount: 1 })), + ...overrides, + } as unknown as PluginsHttp; +} + +async function renderModal(http: PluginsHttp = fakeHttp()) { + const store = createPluginsStore(http); + await store.openWorkspace("ws"); + render(WorkspacePluginModal, { + props: { workspaceId: "ws", workspaceTitle: "My Workspace", plugins: store, onClose: vi.fn() }, + }); + return { store, http }; +} + +describe("WorkspacePluginModal", () => { + it("renders the workspace's plugin rows", async () => { + await renderModal(); + expect(screen.getByText("Plugins — My Workspace")).toBeInTheDocument(); + expect(screen.getByText("kernel")).toBeInTheDocument(); + expect(screen.getByText("mcp")).toBeInTheDocument(); + }); + + it("checkboxes reflect the workspace state (enabled=checked, undefined=unchecked)", async () => { + await renderModal(); + expect(screen.getByRole("checkbox", { name: /kernel/i })).toBeChecked(); + expect(screen.getByRole("checkbox", { name: /Enable mcp/i })).not.toBeChecked(); + }); + + it("unloaded plugins are greyed out AND unselectable (§7)", async () => { + await renderModal(); + const checkbox = screen.getByRole("checkbox", { name: /notloaded/i }); + expect(checkbox).toBeDisabled(); + // The row is visually greyed (the opacity marker class). + expect(checkbox.closest("tr")?.className).toContain("opacity-50"); + // The tooltip points at the global manager. + expect(checkbox.getAttribute("title")).toMatch(/plugin manager/i); + }); + + it("core rows render locked (checked but unselectable — the server refuses the PUT)", async () => { + await renderModal(); + const checkbox = screen.getByRole("checkbox", { name: /kernel/i }); + expect(checkbox).toBeDisabled(); + expect(checkbox.getAttribute("title")).toMatch(/Core plugins are enabled by default/i); + }); + + it("checking a selectable row writes enabled via the store", async () => { + const user = userEvent.setup(); + const { http } = await renderModal(); + await user.click(screen.getByRole("checkbox", { name: /Enable mcp/i })); + expect(http.setWorkspaceState).toHaveBeenCalledWith("ws", "mcp", "enabled"); + }); + + it("unchecking a checked row writes disabled", async () => { + const user = userEvent.setup(); + const enabled: WorkspacePluginsResponse = { + workspaceId: "ws", + plugins: [ + { pluginId: "mcp", tier: "standard", state: "enabled", loaded: true, locked: false }, + ], + enabledProviderCount: 1, + }; + const fake = fakeHttp({ workspacePlugins: async () => enabled }); + const store = createPluginsStore(fake); + await store.openWorkspace("ws"); + render(WorkspacePluginModal, { + props: { + workspaceId: "ws", + workspaceTitle: "My Workspace", + plugins: store, + onClose: vi.fn(), + }, + }); + await user.click(screen.getByRole("checkbox", { name: /Disable mcp/i })); + expect(fake.setWorkspaceState).toHaveBeenCalledWith("ws", "mcp", "disabled"); + }); + + it("shows the template hint for the default workspace", async () => { + const store = createPluginsStore(fakeHttp()); + await store.openWorkspace("default"); + render(WorkspacePluginModal, { + props: { + workspaceId: "default", + workspaceTitle: "Default", + plugins: store, + onClose: vi.fn(), + }, + }); + expect(screen.getByText(/other workspaces inherit these states/i)).toBeInTheDocument(); + }); +}); -- cgit v1.2.3