summaryrefslogtreecommitdiffhomepage
path: root/src/features/layout/logic
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-09-05 04:42:20 +0900
committerAdam Malczewski <[email protected]>2026-09-05 04:42:20 +0900
commit5e5f02c67d0c165dd93f8277a3815d51a540d996 (patch)
tree2b98579d25f3f16204feaf25ddac2746e4bbb934 /src/features/layout/logic
parent2f282f1a5c784dff97af7c936ccb43ca2b63f03f (diff)
downloaddispatch-web-5e5f02c67d0c165dd93f8277a3815d51a540d996.tar.gz
dispatch-web-5e5f02c67d0c165dd93f8277a3815d51a540d996.zip
feat(layout): plan 03 — frontend plugin ABI + handshake + load lifecycle
Diffstat (limited to 'src/features/layout/logic')
-rw-r--r--src/features/layout/logic/registry.test.ts68
-rw-r--r--src/features/layout/logic/registry.ts32
2 files changed, 100 insertions, 0 deletions
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);