Skip to content

Commit 1d1da61

Browse files
committed
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.
1 parent b2d3041 commit 1d1da61

4 files changed

Lines changed: 137 additions & 0 deletions

File tree

packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ import {
7373
type RightPanelSectionModule,
7474
type RightPanelTabModule,
7575
} from "./registry"
76+
import { loadRightPanelPluginManifests } from "./plugin-manifest"
77+
import { RIGHT_PANEL_PLUGIN_MANIFESTS } from "./plugins"
7678
import { CORE_STATUS_SECTION_ITEMS } from "./tabs/status-sections"
7779

7880
const LazyGitChangesTab = lazy(() => import("./tabs/GitChangesTab"))
@@ -120,6 +122,10 @@ const RightPanel: Component<RightPanelProps> = (props) => {
120122
parseRightPanelCustomization(readClientLayoutValue(RIGHT_PANEL_CUSTOMIZATION_STORAGE_KEY)),
121123
)
122124
const [draggedTabId, setDraggedTabId] = createSignal<string | null>(null)
125+
const rightPanelPluginRuntime = loadRightPanelPluginManifests(RIGHT_PANEL_PLUGIN_MANIFESTS, { instanceId: props.instanceId })
126+
onCleanup(() => {
127+
rightPanelPluginRuntime.unload()
128+
})
123129

124130
const [browserPath, setBrowserPath] = createSignal(".")
125131
const [browserEntries, setBrowserEntries] = createSignal<FileNode[] | null>(null)
@@ -814,6 +820,7 @@ const RightPanel: Component<RightPanelProps> = (props) => {
814820
},
815821
],
816822
},
823+
...rightPanelPluginRuntime.modules,
817824
])
818825

819826
const allRightPanelTabs = createMemo(() => collectRightPanelItems<RightPanelTabModule>(rightPanelModules(), "tabs"))
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import assert from "node:assert/strict"
2+
import { describe, it } from "node:test"
3+
4+
import { loadRightPanelPluginManifests, type RightPanelPluginManifest } from "./plugin-manifest"
5+
6+
const manifest = (id: string, events: string[]): RightPanelPluginManifest => ({
7+
id,
8+
tabs: [{ id: `${id}-tab`, labelKey: id, order: 10, render: () => undefined as any }],
9+
lifecycle: {
10+
onLoad: (context) => {
11+
events.push(`${id}:load:${context.instanceId}`)
12+
return () => events.push(`${id}:cleanup`)
13+
},
14+
onUnload: () => events.push(`${id}:unload`),
15+
},
16+
})
17+
18+
describe("right panel plugin manifests", () => {
19+
it("loads modules and unloads lifecycle hooks in reverse order", () => {
20+
const events: string[] = []
21+
const runtime = loadRightPanelPluginManifests([manifest("first", events), manifest("second", events)], { instanceId: "abc" })
22+
23+
assert.deepEqual(runtime.modules.map((entry) => entry.id), ["first", "second"])
24+
assert.deepEqual(events, ["first:load:abc", "second:load:abc"])
25+
assert.deepEqual(runtime.unload(), [])
26+
assert.deepEqual(events, ["first:load:abc", "second:load:abc", "second:cleanup", "second:unload", "first:cleanup", "first:unload"])
27+
})
28+
29+
it("skips duplicate ids without blocking other plugins", () => {
30+
const events: string[] = []
31+
const runtime = loadRightPanelPluginManifests([manifest("plugin", events), manifest("plugin", events), manifest("other", events)], {
32+
instanceId: "abc",
33+
})
34+
35+
assert.deepEqual(runtime.modules.map((entry) => entry.id), ["plugin", "other"])
36+
assert.equal(runtime.errors.length, 1)
37+
assert.equal(runtime.errors[0]?.pluginId, "plugin")
38+
})
39+
40+
it("skips plugins that fail during load", () => {
41+
const runtime = loadRightPanelPluginManifests(
42+
[
43+
{ id: "bad", lifecycle: { onLoad: () => { throw new Error("boom") } } },
44+
{ id: "good", tabs: [{ id: "good-tab", labelKey: "good", order: 10, render: () => undefined as any }] },
45+
],
46+
{ instanceId: "abc" },
47+
)
48+
49+
assert.deepEqual(runtime.modules.map((entry) => entry.id), ["good"])
50+
assert.equal(runtime.errors.length, 1)
51+
assert.equal(runtime.errors[0]?.pluginId, "bad")
52+
})
53+
})
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import type { RightPanelModule, RightPanelSectionModule, RightPanelTabModule } from "./registry"
2+
3+
export type RightPanelPluginCleanup = () => void
4+
5+
export interface RightPanelPluginContext {
6+
instanceId: string
7+
}
8+
9+
export interface RightPanelPluginLifecycle {
10+
onLoad?: (context: RightPanelPluginContext) => void | RightPanelPluginCleanup
11+
onUnload?: (context: RightPanelPluginContext) => void
12+
}
13+
14+
export interface RightPanelPluginManifest {
15+
id: string
16+
tabs?: readonly RightPanelTabModule[]
17+
statusSections?: readonly RightPanelSectionModule[]
18+
lifecycle?: RightPanelPluginLifecycle
19+
}
20+
21+
export interface RightPanelPluginLoadError {
22+
pluginId: string
23+
phase: "load" | "unload"
24+
error: unknown
25+
}
26+
27+
export interface LoadedRightPanelPlugins {
28+
modules: RightPanelModule[]
29+
errors: RightPanelPluginLoadError[]
30+
unload: () => RightPanelPluginLoadError[]
31+
}
32+
33+
export function loadRightPanelPluginManifests(
34+
manifests: readonly RightPanelPluginManifest[],
35+
context: RightPanelPluginContext,
36+
): LoadedRightPanelPlugins {
37+
const modules: RightPanelModule[] = []
38+
const cleanupStack: { manifest: RightPanelPluginManifest; cleanup?: RightPanelPluginCleanup }[] = []
39+
const errors: RightPanelPluginLoadError[] = []
40+
const seen = new Set<string>()
41+
42+
for (const manifest of manifests) {
43+
if (!manifest.id || seen.has(manifest.id)) {
44+
errors.push({ pluginId: manifest.id || "<missing>", phase: "load", error: new Error("Duplicate or missing right panel plugin id") })
45+
continue
46+
}
47+
seen.add(manifest.id)
48+
49+
try {
50+
const cleanup = manifest.lifecycle?.onLoad?.(context)
51+
modules.push({ id: manifest.id, tabs: manifest.tabs, statusSections: manifest.statusSections })
52+
cleanupStack.push({ manifest, cleanup: typeof cleanup === "function" ? cleanup : undefined })
53+
} catch (error) {
54+
errors.push({ pluginId: manifest.id, phase: "load", error })
55+
}
56+
}
57+
58+
return {
59+
modules,
60+
errors,
61+
unload: () => {
62+
const unloadErrors: RightPanelPluginLoadError[] = []
63+
for (const { manifest, cleanup } of cleanupStack.slice().reverse()) {
64+
try {
65+
cleanup?.()
66+
manifest.lifecycle?.onUnload?.(context)
67+
} catch (error) {
68+
unloadErrors.push({ pluginId: manifest.id, phase: "unload", error })
69+
}
70+
}
71+
return unloadErrors
72+
},
73+
}
74+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import type { RightPanelPluginManifest } from "./plugin-manifest"
2+
3+
export const RIGHT_PANEL_PLUGIN_MANIFESTS: readonly RightPanelPluginManifest[] = []

0 commit comments

Comments
 (0)