summaryrefslogtreecommitdiffhomepage
path: root/src/features/plugins/logic/view-model.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/plugins/logic/view-model.ts')
-rw-r--r--src/features/plugins/logic/view-model.ts160
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"),
+ };
+}