From 1d1da617e11cb88fd16575f056c36e2610ccdd4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Mon, 20 Jul 2026 22:53:14 +0200 Subject: [PATCH] feat(ui): load right panel plugin manifests Add a typed manifest loader for right-panel plugins so bundled modules can contribute tabs and Status sections through the registry introduced by the stacked customization PR. The loader runs deterministic onLoad/onUnload lifecycle hooks, skips duplicate or failed manifests without blocking other plugins, and keeps the plugin list explicit for now to avoid arbitrary code loading or marketplace behavior in this step. Validated with focused manifest and registry tests, UI typecheck, whitespace check, UI build, and a final gatekeeper pass. --- .../instance/shell/right-panel/RightPanel.tsx | 7 ++ .../shell/right-panel/plugin-manifest.test.ts | 53 +++++++++++++ .../shell/right-panel/plugin-manifest.ts | 74 +++++++++++++++++++ .../instance/shell/right-panel/plugins.ts | 3 + 4 files changed, 137 insertions(+) create mode 100644 packages/ui/src/components/instance/shell/right-panel/plugin-manifest.test.ts create mode 100644 packages/ui/src/components/instance/shell/right-panel/plugin-manifest.ts create mode 100644 packages/ui/src/components/instance/shell/right-panel/plugins.ts diff --git a/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx b/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx index 205ea2c1..220b4682 100644 --- a/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx @@ -73,6 +73,8 @@ import { type RightPanelSectionModule, type RightPanelTabModule, } from "./registry" +import { loadRightPanelPluginManifests } from "./plugin-manifest" +import { RIGHT_PANEL_PLUGIN_MANIFESTS } from "./plugins" import { CORE_STATUS_SECTION_ITEMS } from "./tabs/status-sections" const LazyGitChangesTab = lazy(() => import("./tabs/GitChangesTab")) @@ -120,6 +122,10 @@ const RightPanel: Component = (props) => { parseRightPanelCustomization(readClientLayoutValue(RIGHT_PANEL_CUSTOMIZATION_STORAGE_KEY)), ) const [draggedTabId, setDraggedTabId] = createSignal(null) + const rightPanelPluginRuntime = loadRightPanelPluginManifests(RIGHT_PANEL_PLUGIN_MANIFESTS, { instanceId: props.instanceId }) + onCleanup(() => { + rightPanelPluginRuntime.unload() + }) const [browserPath, setBrowserPath] = createSignal(".") const [browserEntries, setBrowserEntries] = createSignal(null) @@ -814,6 +820,7 @@ const RightPanel: Component = (props) => { }, ], }, + ...rightPanelPluginRuntime.modules, ]) const allRightPanelTabs = createMemo(() => collectRightPanelItems(rightPanelModules(), "tabs")) diff --git a/packages/ui/src/components/instance/shell/right-panel/plugin-manifest.test.ts b/packages/ui/src/components/instance/shell/right-panel/plugin-manifest.test.ts new file mode 100644 index 00000000..f31d508c --- /dev/null +++ b/packages/ui/src/components/instance/shell/right-panel/plugin-manifest.test.ts @@ -0,0 +1,53 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import { loadRightPanelPluginManifests, type RightPanelPluginManifest } from "./plugin-manifest" + +const manifest = (id: string, events: string[]): RightPanelPluginManifest => ({ + id, + tabs: [{ id: `${id}-tab`, labelKey: id, order: 10, render: () => undefined as any }], + lifecycle: { + onLoad: (context) => { + events.push(`${id}:load:${context.instanceId}`) + return () => events.push(`${id}:cleanup`) + }, + onUnload: () => events.push(`${id}:unload`), + }, +}) + +describe("right panel plugin manifests", () => { + it("loads modules and unloads lifecycle hooks in reverse order", () => { + const events: string[] = [] + const runtime = loadRightPanelPluginManifests([manifest("first", events), manifest("second", events)], { instanceId: "abc" }) + + assert.deepEqual(runtime.modules.map((entry) => entry.id), ["first", "second"]) + assert.deepEqual(events, ["first:load:abc", "second:load:abc"]) + assert.deepEqual(runtime.unload(), []) + assert.deepEqual(events, ["first:load:abc", "second:load:abc", "second:cleanup", "second:unload", "first:cleanup", "first:unload"]) + }) + + it("skips duplicate ids without blocking other plugins", () => { + const events: string[] = [] + const runtime = loadRightPanelPluginManifests([manifest("plugin", events), manifest("plugin", events), manifest("other", events)], { + instanceId: "abc", + }) + + assert.deepEqual(runtime.modules.map((entry) => entry.id), ["plugin", "other"]) + assert.equal(runtime.errors.length, 1) + assert.equal(runtime.errors[0]?.pluginId, "plugin") + }) + + it("skips plugins that fail during load", () => { + const runtime = loadRightPanelPluginManifests( + [ + { id: "bad", lifecycle: { onLoad: () => { throw new Error("boom") } } }, + { id: "good", tabs: [{ id: "good-tab", labelKey: "good", order: 10, render: () => undefined as any }] }, + ], + { instanceId: "abc" }, + ) + + assert.deepEqual(runtime.modules.map((entry) => entry.id), ["good"]) + assert.equal(runtime.errors.length, 1) + assert.equal(runtime.errors[0]?.pluginId, "bad") + }) +}) diff --git a/packages/ui/src/components/instance/shell/right-panel/plugin-manifest.ts b/packages/ui/src/components/instance/shell/right-panel/plugin-manifest.ts new file mode 100644 index 00000000..b6d7b293 --- /dev/null +++ b/packages/ui/src/components/instance/shell/right-panel/plugin-manifest.ts @@ -0,0 +1,74 @@ +import type { RightPanelModule, RightPanelSectionModule, RightPanelTabModule } from "./registry" + +export type RightPanelPluginCleanup = () => void + +export interface RightPanelPluginContext { + instanceId: string +} + +export interface RightPanelPluginLifecycle { + onLoad?: (context: RightPanelPluginContext) => void | RightPanelPluginCleanup + onUnload?: (context: RightPanelPluginContext) => void +} + +export interface RightPanelPluginManifest { + id: string + tabs?: readonly RightPanelTabModule[] + statusSections?: readonly RightPanelSectionModule[] + lifecycle?: RightPanelPluginLifecycle +} + +export interface RightPanelPluginLoadError { + pluginId: string + phase: "load" | "unload" + error: unknown +} + +export interface LoadedRightPanelPlugins { + modules: RightPanelModule[] + errors: RightPanelPluginLoadError[] + unload: () => RightPanelPluginLoadError[] +} + +export function loadRightPanelPluginManifests( + manifests: readonly RightPanelPluginManifest[], + context: RightPanelPluginContext, +): LoadedRightPanelPlugins { + const modules: RightPanelModule[] = [] + const cleanupStack: { manifest: RightPanelPluginManifest; cleanup?: RightPanelPluginCleanup }[] = [] + const errors: RightPanelPluginLoadError[] = [] + const seen = new Set() + + for (const manifest of manifests) { + if (!manifest.id || seen.has(manifest.id)) { + errors.push({ pluginId: manifest.id || "", phase: "load", error: new Error("Duplicate or missing right panel plugin id") }) + continue + } + seen.add(manifest.id) + + try { + const cleanup = manifest.lifecycle?.onLoad?.(context) + modules.push({ id: manifest.id, tabs: manifest.tabs, statusSections: manifest.statusSections }) + cleanupStack.push({ manifest, cleanup: typeof cleanup === "function" ? cleanup : undefined }) + } catch (error) { + errors.push({ pluginId: manifest.id, phase: "load", error }) + } + } + + return { + modules, + errors, + unload: () => { + const unloadErrors: RightPanelPluginLoadError[] = [] + for (const { manifest, cleanup } of cleanupStack.slice().reverse()) { + try { + cleanup?.() + manifest.lifecycle?.onUnload?.(context) + } catch (error) { + unloadErrors.push({ pluginId: manifest.id, phase: "unload", error }) + } + } + return unloadErrors + }, + } +} diff --git a/packages/ui/src/components/instance/shell/right-panel/plugins.ts b/packages/ui/src/components/instance/shell/right-panel/plugins.ts new file mode 100644 index 00000000..bda52c73 --- /dev/null +++ b/packages/ui/src/components/instance/shell/right-panel/plugins.ts @@ -0,0 +1,3 @@ +import type { RightPanelPluginManifest } from "./plugin-manifest" + +export const RIGHT_PANEL_PLUGIN_MANIFESTS: readonly RightPanelPluginManifest[] = []