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[] = []