diff options
Diffstat (limited to 'src/features/plugins/logic/view-model.ts')
| -rw-r--r-- | src/features/plugins/logic/view-model.ts | 160 |
1 files changed, 160 insertions, 0 deletions
diff --git a/src/features/plugins/logic/view-model.ts b/src/features/plugins/logic/view-model.ts new file mode 100644 index 0000000..5a66ffa --- /dev/null +++ b/src/features/plugins/logic/view-model.ts @@ -0,0 +1,160 @@ +import type { PluginListResponse } from "@dispatch/transport-contract"; +import type { FrontendPluginState } from "../../../core/plugin-abi"; +import { normalizeTier, 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, + // Normalize at the boundary: a missing/out-of-contract tier (backend + // bug, stale contract) folds into "standard" so every node's tier is + // canonical and the tier-keyed consumers below stay total. + tier: normalizeTier(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: normalizeTier(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: normalizeTier(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; +} + +/** + * 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). + * + * Duplicate-key-proof BY CONSTRUCTION: nodes are bucketed through a Map keyed + * by the NORMALIZED tier, so each of the three canonical tiers can yield at + * most one group — `PluginTable.svelte` renders a keyed each + * (`{#each groups as group (group.tier)}`), and a duplicate key throws + * `each_key_duplicate` in Svelte 5, aborting the whole manager render. The + * normalization also means an unknown/missing tier (contract violation — the + * backend rejects tierless externals at boot and directory scans fail + * validation) folds into the `standard` bucket instead of forming a separate + * trailing group; the old fallback (`rest[0]?.tier ?? "standard"`) produced a + * DUPLICATE `standard` key when canonical standard rows were present. + * groupByTier normalizes on the way in even though `pluginNodes` already + * does at the boundary — it is exported and must be safe for any caller. + */ +export function groupByTier( + nodes: readonly PluginNode[], +): ReadonlyArray<{ readonly tier: PluginNode["tier"]; readonly rows: readonly PluginNode[] }> { + const byTier = new Map<PluginNode["tier"], PluginNode[]>(); + for (const n of nodes) { + const tier = normalizeTier(n.tier); + const rows = byTier.get(tier); + if (rows) rows.push(n); + else byTier.set(tier, [n]); + } + const out: { tier: PluginNode["tier"]; rows: PluginNode[] }[] = []; + for (const tier of TIER_ORDER) { + const rows = byTier.get(tier); + if (!rows || rows.length === 0) continue; + out.push({ tier, rows: rows.sort((a, b) => a.id.localeCompare(b.id)) }); + } + 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"), + }; +} |
