Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down Expand Up @@ -120,6 +122,10 @@ const RightPanel: Component<RightPanelProps> = (props) => {
parseRightPanelCustomization(readClientLayoutValue(RIGHT_PANEL_CUSTOMIZATION_STORAGE_KEY)),
)
const [draggedTabId, setDraggedTabId] = createSignal<string | null>(null)
const rightPanelPluginRuntime = loadRightPanelPluginManifests(RIGHT_PANEL_PLUGIN_MANIFESTS, { instanceId: props.instanceId })
onCleanup(() => {
rightPanelPluginRuntime.unload()
})

const [browserPath, setBrowserPath] = createSignal(".")
const [browserEntries, setBrowserEntries] = createSignal<FileNode[] | null>(null)
Expand Down Expand Up @@ -814,6 +820,7 @@ const RightPanel: Component<RightPanelProps> = (props) => {
},
],
},
...rightPanelPluginRuntime.modules,
])

const allRightPanelTabs = createMemo(() => collectRightPanelItems<RightPanelTabModule>(rightPanelModules(), "tabs"))
Expand Down
Original file line number Diff line number Diff line change
@@ -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")
})
})
Original file line number Diff line number Diff line change
@@ -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<string>()

for (const manifest of manifests) {
if (!manifest.id || seen.has(manifest.id)) {
errors.push({ pluginId: manifest.id || "<missing>", 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
},
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import type { RightPanelPluginManifest } from "./plugin-manifest"

export const RIGHT_PANEL_PLUGIN_MANIFESTS: readonly RightPanelPluginManifest[] = []
Loading