diff options
| author | Adam Malczewski <[email protected]> | 2026-09-05 04:42:20 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-09-05 04:42:20 +0900 |
| commit | 5e5f02c67d0c165dd93f8277a3815d51a540d996 (patch) | |
| tree | 2b98579d25f3f16204feaf25ddac2746e4bbb934 /src/features | |
| parent | 2f282f1a5c784dff97af7c936ccb43ca2b63f03f (diff) | |
| download | dispatch-web-5e5f02c67d0c165dd93f8277a3815d51a540d996.tar.gz dispatch-web-5e5f02c67d0c165dd93f8277a3815d51a540d996.zip | |
feat(layout): plan 03 — frontend plugin ABI + handshake + load lifecycle
Diffstat (limited to 'src/features')
| -rw-r--r-- | src/features/layout/index.ts | 2 | ||||
| -rw-r--r-- | src/features/layout/logic/registry.test.ts | 68 | ||||
| -rw-r--r-- | src/features/layout/logic/registry.ts | 32 | ||||
| -rw-r--r-- | src/features/plugins/index.ts | 7 | ||||
| -rw-r--r-- | src/features/plugins/logic/types.ts | 7 | ||||
| -rw-r--r-- | src/features/plugins/logic/view-model.test.ts | 71 | ||||
| -rw-r--r-- | src/features/plugins/logic/view-model.ts | 29 | ||||
| -rw-r--r-- | src/features/plugins/store.svelte.ts | 20 | ||||
| -rw-r--r-- | src/features/plugins/ui/PluginManager.svelte | 10 | ||||
| -rw-r--r-- | src/features/plugins/ui/PluginRow.svelte | 4 |
10 files changed, 244 insertions, 6 deletions
diff --git a/src/features/layout/index.ts b/src/features/layout/index.ts index f5696ac..6a17589 100644 --- a/src/features/layout/index.ts +++ b/src/features/layout/index.ts @@ -25,8 +25,10 @@ export { listPanelViews, listViews, type RegisteredView, + registerPluginView, registerView, unregisterView, + unregisterViewsByPlugin, type ViewComponent, type ViewRegistration, viewRegistry, diff --git a/src/features/layout/logic/registry.test.ts b/src/features/layout/logic/registry.test.ts index 443e50d..e5e947c 100644 --- a/src/features/layout/logic/registry.test.ts +++ b/src/features/layout/logic/registry.test.ts @@ -4,8 +4,10 @@ import { getView, listPanelViews, listViews, + registerPluginView, registerView, unregisterView, + unregisterViewsByPlugin, type ViewRegistration, viewRegistry, } from "./registry"; @@ -78,3 +80,69 @@ describe("view registry", () => { expect(ids.indexOf("z-first")).toBeLessThan(ids.indexOf("a-second")); }); }); + +// ── Plugin-view registration (plan 03) — forced attribution + the sweep ───── + +describe("registerPluginView / unregisterViewsByPlugin (plan 03)", () => { + it("forces the importing plugin's pluginId attribution", () => { + const dispose = registerPluginView( + { id: "abi-view", label: "ABI View", component: fakeComponent }, + "my-plugin", + ); + expect(getView("abi-view")?.pluginId).toBe("my-plugin"); + dispose(); + expect(getView("abi-view")).toBeUndefined(); + }); + + it("returns a working per-view disposer (the plugin deactivate contract)", () => { + const dispose = registerPluginView( + { id: "abi-view-2", label: "ABI View", component: fakeComponent }, + "my-plugin", + ); + dispose(); + dispose(); // idempotent + expect(getView("abi-view-2")).toBeUndefined(); + }); + + it("the sweep removes exactly that plugin's views (registry attribution)", () => { + registerPluginView({ id: "p1-a", label: "A", component: fakeComponent }, "plugin-1"); + registerPluginView({ id: "p1-b", label: "B", component: fakeComponent }, "plugin-1"); + registerPluginView({ id: "p2-a", label: "C", component: fakeComponent }, "plugin-2"); + registerView(registration({ id: "builtin" })); // no pluginId — never swept + + unregisterViewsByPlugin("plugin-1"); + expect(getView("p1-a")).toBeUndefined(); + expect(getView("p1-b")).toBeUndefined(); + expect(getView("p2-a")).not.toBeUndefined(); + expect(getView("builtin")).not.toBeUndefined(); + + unregisterViewsByPlugin("plugin-2"); + expect(getView("p2-a")).toBeUndefined(); + expect(getView("builtin")).not.toBeUndefined(); + }); + + it("the sweep is idempotent and a no-op for an unknown plugin", () => { + unregisterViewsByPlugin("nobody"); + registerPluginView({ id: "p3-a", label: "A", component: fakeComponent }, "plugin-3"); + unregisterViewsByPlugin("plugin-3"); + expect(() => unregisterViewsByPlugin("plugin-3")).not.toThrow(); + }); + + it("a duplicate id from a plugin throws LOUDLY (the driver marks it failed)", () => { + registerView(registration({ id: "taken" })); + expect(() => + registerPluginView({ id: "taken", label: "Taken", component: fakeComponent }, "my-plugin"), + ).toThrow(/duplicate view id/); + }); + + it("sweep removals are visible to registry subscribers (reactive)", () => { + const seen: string[][] = []; + const unsubscribe = viewRegistry.subscribe((views) => { + seen.push(views.map((v) => v.id)); + }); + registerPluginView({ id: "reactive", label: "R", component: fakeComponent }, "p"); + unregisterViewsByPlugin("p"); + unsubscribe(); + expect(seen.at(-1)).not.toContain("reactive"); + }); +}); diff --git a/src/features/layout/logic/registry.ts b/src/features/layout/logic/registry.ts index b072d2f..3a86cb9 100644 --- a/src/features/layout/logic/registry.ts +++ b/src/features/layout/logic/registry.ts @@ -82,6 +82,38 @@ export function unregisterView(id: string): void { if (byId.delete(id)) publish(); } +/** + * Register a PLUGIN view (plan 03) — the ABI-facing registration: the + * `pluginId` attribution is FORCED to the importing plugin (plugins cannot + * attribute views to another plugin or to no plugin), which is what makes + * `unregisterViewsByPlugin`'s unload sweep exact. Returns the per-view + * disposer (the plugin's deactivate contract). A duplicate id throws + * LOUDLY — the driver catches it and marks the plugin's FE half failed. + */ +export function registerPluginView( + registration: Omit<ViewRegistration, "pluginId">, + pluginId: string, +): () => void { + registerView({ ...registration, pluginId }); + return () => unregisterView(registration.id); +} + +/** + * Remove every view attributed to one plugin (the plan-03 unload sweep). + * Idempotent; the reactive registry re-renders open dropdowns, and tiles + * holding removed views fall back to `FallbackTile` (never a crash). + */ +export function unregisterViewsByPlugin(pluginId: string): void { + let changed = false; + for (const [id, view] of byId) { + if (view.pluginId === pluginId) { + byId.delete(id); + changed = true; + } + } + if (changed) publish(); +} + /** Look a view up by id (undefined → the renderer shows its fallback tile). */ export function getView(id: string): RegisteredView | undefined { return byId.get(id); diff --git a/src/features/plugins/index.ts b/src/features/plugins/index.ts index 0f2abbc..7a9d1a0 100644 --- a/src/features/plugins/index.ts +++ b/src/features/plugins/index.ts @@ -10,9 +10,12 @@ 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 type { + PluginNode, + PluginSection, +} from "./logic/types"; export { TIER_ORDER, tierLabel } from "./logic/types"; -export { groupByTier, pluginNodes, splitSections } from "./logic/view-model"; +export { groupByTier, pluginNodes, splitSections, withFrontendStates } 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"; diff --git a/src/features/plugins/logic/types.ts b/src/features/plugins/logic/types.ts index 702483b..d5fa4bb 100644 --- a/src/features/plugins/logic/types.ts +++ b/src/features/plugins/logic/types.ts @@ -25,6 +25,13 @@ export interface PluginNode { readonly dependsOn: readonly string[]; /** Why the plugin is not loaded (disabled-with-reason) or gone (missing). */ readonly reason: string | null; + /** + * The frontend-plugin (plan 03) issue line for the manager row, when the + * plugin's FE half is incompatible ("incompatible frontend plugin") or + * failed (`frontend failed: <error>`). Absent = the FE half is fine (or + * the plugin declares no frontend section). + */ + readonly frontendIssue?: string; readonly section: PluginSection; } diff --git a/src/features/plugins/logic/view-model.test.ts b/src/features/plugins/logic/view-model.test.ts index 680ba94..661cade 100644 --- a/src/features/plugins/logic/view-model.test.ts +++ b/src/features/plugins/logic/view-model.test.ts @@ -1,7 +1,7 @@ import type { PluginListResponse, PluginTier } from "@dispatch/transport-contract"; import { describe, expect, it } from "vitest"; import { emptyListResponse, node } from "./test-fixtures"; -import { groupByTier, pluginNodes, splitSections } from "./view-model"; +import { groupByTier, pluginNodes, splitSections, withFrontendStates } from "./view-model"; function listResponse(overrides: Partial<PluginListResponse> = {}): PluginListResponse { return { @@ -225,3 +225,72 @@ describe("splitSections", () => { expect(sections.missing.map((n) => n.id)).toEqual(["gone"]); }); }); + +// ── withFrontendStates (plan 03) — the manager rows' FE-half issues ───────── + +describe("withFrontendStates", () => { + function listWith(...ids: readonly string[]): PluginListResponse { + return { + loaded: ids.map((id) => ({ + id, + name: id, + version: "1.0.0", + tier: "standard" as const, + trust: "local" as const, + locked: false, + dependsOn: [], + })), + disabledWithReason: [], + discovered: [], + missing: [], + }; + } + + it("renders the plan-mandated grammar for an incompatible FE half", () => { + const nodes = pluginNodes(listWith("bad")); + const states = new Map([["bad", { kind: "incompatible", apiVersion: "^2" } as const]]); + const out = withFrontendStates(nodes, states); + expect(out[0]?.frontendIssue).toBe("incompatible frontend plugin"); + }); + + it("renders the activation error for a failed FE half", () => { + const nodes = pluginNodes(listWith("broken")); + const states = new Map([["broken", { kind: "failed", error: "boom" } as const]]); + const out = withFrontendStates(nodes, states); + expect(out[0]?.frontendIssue).toBe("frontend failed: boom"); + }); + + it("a loaded FE half adds no issue line; absent states add none", () => { + const nodes = pluginNodes(listWith("ok", "no-fe")); + const states = new Map([["ok", { kind: "loaded" } as const]]); + const out = withFrontendStates(nodes, states); + expect(out.find((n) => n.id === "ok")?.frontendIssue).toBeUndefined(); + expect(out.find((n) => n.id === "no-fe")?.frontendIssue).toBeUndefined(); + }); + + it("never annotates non-loaded sections (discovered / missing rows)", () => { + const response: PluginListResponse = { + loaded: [], + disabledWithReason: [], + discovered: [ + { + id: "dis", + name: "dis", + version: "1.0.0", + tier: "standard", + trust: "local", + dir: "/plugins/dis", + }, + ], + missing: [], + }; + const states = new Map([["dis", { kind: "failed", error: "boom" } as const]]); + const out = withFrontendStates(pluginNodes(response), states); + expect(out[0]?.frontendIssue).toBeUndefined(); + }); + + it("an empty state map is the identity (no allocation on the hot path)", () => { + const nodes = pluginNodes(listWith("a")); + expect(withFrontendStates(nodes, new Map())).toBe(nodes); + }); +}); diff --git a/src/features/plugins/logic/view-model.ts b/src/features/plugins/logic/view-model.ts index 20d7d68..5a66ffa 100644 --- a/src/features/plugins/logic/view-model.ts +++ b/src/features/plugins/logic/view-model.ts @@ -1,4 +1,5 @@ import type { PluginListResponse } from "@dispatch/transport-contract"; +import type { FrontendPluginState } from "../../../core/plugin-abi"; import { normalizeTier, type PluginNode, type PluginSection, TIER_ORDER } from "./types"; /** @@ -79,6 +80,34 @@ export function pluginNodes(response: PluginListResponse): readonly PluginNode[] } /** + * Merge the frontend-plugin (plan 03) per-plugin FE states into the node + * list — the manager rows' `frontendIssue` line. Pure input → output: + * - `incompatible` → the plan-mandated "incompatible frontend plugin" + * grammar (the backend apiVersion-mismatch wording, FE half); + * - `failed` → the activation error (the FE half failed; the backend half + * stays loaded). + * Loaded rows only — the FE states never apply to discovered/missing rows. + */ +export function withFrontendStates( + nodes: readonly PluginNode[], + states: ReadonlyMap<string, FrontendPluginState>, +): readonly PluginNode[] { + if (states.size === 0) return nodes; + return nodes.map((node) => { + if (node.section !== "loaded") return node; + const state = states.get(node.id); + if (state === undefined) return node; + const issue = + state.kind === "incompatible" + ? "incompatible frontend plugin" + : state.kind === "failed" + ? `frontend failed: ${state.error}` + : null; + return issue === null ? node : { ...node, frontendIssue: issue }; + }); +} + +/** * 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). diff --git a/src/features/plugins/store.svelte.ts b/src/features/plugins/store.svelte.ts index 580293e..fe48940 100644 --- a/src/features/plugins/store.svelte.ts +++ b/src/features/plugins/store.svelte.ts @@ -3,6 +3,7 @@ import type { SetWorkspacePluginStateRequest, WorkspacePluginRow, } from "@dispatch/transport-contract"; +import type { FrontendPluginState } from "../../core/plugin-abi"; import type { PluginOpResult, PluginScanResult, PluginsHttp } from "./adapter/http"; import type { PluginNode } from "./logic/types"; import { pluginNodes } from "./logic/view-model"; @@ -70,6 +71,16 @@ export interface PluginsStore { * connected client converges. Fire-and-forget. */ onExtensionsChanged(): void; + + /** + * The frontend-plugin (plan 03) per-plugin FE states — reported by the + * plugin host after every driver sync. Rendered by the manager rows: + * `incompatible` → "incompatible frontend plugin" (the backend-mismatch + * grammar), `failed` → the activation error. Absent = no FE section. + */ + readonly frontendStates: ReadonlyMap<string, FrontendPluginState>; + /** The plugin host's post-sync report (the only writer). */ + setFrontendStates(states: ReadonlyMap<string, FrontendPluginState>): void; } /** One open workspace modal's view (rows + materialized provider count). */ @@ -86,6 +97,7 @@ export function createPluginsStore(http: PluginsHttp): PluginsStore { let error = $state<string | null>(null); let lastScan = $state<PluginScanResult | null>(null); let workspaceView = $state<WorkspacePluginsView | null>(null); + let frontendStates = $state<ReadonlyMap<string, FrontendPluginState>>(new Map()); // Normalized nodes — derived so the graph logic re-runs only when the list // changes, not on every read. @@ -194,5 +206,13 @@ export function createPluginsStore(http: PluginsHttp): PluginsStore { const open = workspaceView?.workspaceId; if (open !== undefined) void this.openWorkspace(open); }, + + get frontendStates(): ReadonlyMap<string, FrontendPluginState> { + return frontendStates; + }, + + setFrontendStates(states: ReadonlyMap<string, FrontendPluginState>): void { + frontendStates = states; + }, }; } diff --git a/src/features/plugins/ui/PluginManager.svelte b/src/features/plugins/ui/PluginManager.svelte index cd3d542..fd2a8a7 100644 --- a/src/features/plugins/ui/PluginManager.svelte +++ b/src/features/plugins/ui/PluginManager.svelte @@ -6,7 +6,7 @@ import { autoCheckBackendPrereqs } from "../logic/prereqs"; import { dependencyHighlightSets } from "../logic/highlight"; import { unloadBlockReason } from "../logic/last-provider"; - import { splitSections } from "../logic/view-model"; + import { splitSections, withFrontendStates } from "../logic/view-model"; import PluginTable from "./PluginTable.svelte"; import UnloadConfirmModal from "./UnloadConfirmModal.svelte"; @@ -17,9 +17,13 @@ }); // ── Derived sections + hover highlighting (§8) ──────────────────────────── - const sections = $derived(splitSections(plugins.nodes)); + // The FE-half states (plan 03) merge into the nodes here — one reactive + // derivation over both inputs (the normalized nodes + the plugin host's + // post-sync state map). + const nodes = $derived(withFrontendStates(plugins.nodes, plugins.frontendStates)); + const sections = $derived(splitSections(nodes)); let hoveredId = $state<string | null>(null); - const highlight = $derived(dependencyHighlightSets(hoveredId, plugins.nodes)); + const highlight = $derived(dependencyHighlightSets(hoveredId, nodes)); function highlightFor(id: string): "dep" | "dependent" | null { if (highlight.deps.has(id)) return "dep"; diff --git a/src/features/plugins/ui/PluginRow.svelte b/src/features/plugins/ui/PluginRow.svelte index 457bd29..73e0b18 100644 --- a/src/features/plugins/ui/PluginRow.svelte +++ b/src/features/plugins/ui/PluginRow.svelte @@ -83,6 +83,10 @@ {#if node.reason !== null} <span class="text-xs text-error">{node.reason}</span> {/if} + {#if node.frontendIssue !== undefined} + <!-- plan 03: the FE half's state — "incompatible frontend plugin" or the activation error --> + <span class="text-xs text-error">{node.frontendIssue}</span> + {/if} {#if node.dependsOn.length > 0} <span class="text-xs opacity-60">depends on: {node.dependsOn.join(", ")}</span> {/if} |
