diff options
| author | Adam Malczewski <[email protected]> | 2026-09-03 18:05:29 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-09-03 18:05:29 +0900 |
| commit | 3a4922a05930b0dc22740bf5621910803e8f275e (patch) | |
| tree | d5f831c3d3838cfff346e378f5370a9c58bc2ed3 /src/features/plugins/logic | |
| parent | 33c7245f18346553f660434bc3ac589cff3e80c2 (diff) | |
| download | dispatch-web-3a4922a05930b0dc22740bf5621910803e8f275e.tar.gz dispatch-web-3a4922a05930b0dc22740bf5621910803e8f275e.zip | |
feat(plugin-system): plan 07 frontend plugin manager ui
Diffstat (limited to 'src/features/plugins/logic')
| -rw-r--r-- | src/features/plugins/logic/cascade.test.ts | 66 | ||||
| -rw-r--r-- | src/features/plugins/logic/cascade.ts | 42 | ||||
| -rw-r--r-- | src/features/plugins/logic/highlight.test.ts | 60 | ||||
| -rw-r--r-- | src/features/plugins/logic/highlight.ts | 37 | ||||
| -rw-r--r-- | src/features/plugins/logic/last-provider.test.ts | 70 | ||||
| -rw-r--r-- | src/features/plugins/logic/last-provider.ts | 31 | ||||
| -rw-r--r-- | src/features/plugins/logic/prereqs.test.ts | 58 | ||||
| -rw-r--r-- | src/features/plugins/logic/prereqs.ts | 45 | ||||
| -rw-r--r-- | src/features/plugins/logic/test-fixtures.ts | 30 | ||||
| -rw-r--r-- | src/features/plugins/logic/types.ts | 44 | ||||
| -rw-r--r-- | src/features/plugins/logic/view-model.test.ts | 129 | ||||
| -rw-r--r-- | src/features/plugins/logic/view-model.ts | 111 | ||||
| -rw-r--r-- | src/features/plugins/logic/workspace-row.test.ts | 67 | ||||
| -rw-r--r-- | src/features/plugins/logic/workspace-row.ts | 54 |
14 files changed, 844 insertions, 0 deletions
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<string, string[]>(); + 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<string>([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<string>; + /** The loaded/unloaded rows that DIRECTLY depend on the hovered row + * (highlighted when hovering a dependency). */ + readonly dependents: ReadonlySet<string>; +} + +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<string>([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> = {}): 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> = {}): 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<PluginNode["tier"]>(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> = {}): 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"; + } +} |
