Files
2026-03-12 12:03:20 +01:00

264 lines
7.3 KiB
TypeScript

import { existsSync } from "node:fs"
import { dirname, isAbsolute, join, resolve } from "node:path"
import process from "node:process"
import { fileURLToPath, pathToFileURL } from "node:url"
import "@opentui/react/runtime-plugin-support"
import {
Slot,
createReactSlotRegistry,
type ReactPlugin,
type SlotMode,
useKeyboard,
useRenderer,
} from "@opentui/react"
import { useEffect, useMemo, useState } from "react"
const STATUSBAR_LABEL = "host-status"
const SIDEBAR_SECTION = "external-plugins"
const DEFAULT_PLUGIN_ENTRY = ".plugin/index.tsx"
const EXTERNAL_PLUGIN_PATH_ENV = "OPENTUI_REACT_EXTERNAL_PLUGIN_PATH"
const moduleDir = dirname(fileURLToPath(import.meta.url))
type ExternalPluginSlots = {
statusbar: { label: string }
sidebar: { section: string }
}
type ExternalPluginContext = {
appName: string
version: string
}
type ExternalPluginModule = {
loadExternalPlugin(): ReactPlugin<ExternalPluginSlots, ExternalPluginContext>
}
function normalizePath(input: string): string {
if (input.startsWith("file://")) {
return fileURLToPath(input)
}
if (isAbsolute(input)) {
return input
}
return resolve(process.cwd(), input)
}
function resolveExternalPluginCandidates(): string[] {
const paths = new Set<string>()
const envPath = process.env[EXTERNAL_PLUGIN_PATH_ENV]
if (envPath && envPath.trim().length > 0) {
paths.add(normalizePath(envPath.trim()))
}
paths.add(resolve(process.cwd(), "packages", "react", "examples", DEFAULT_PLUGIN_ENTRY))
paths.add(resolve(dirname(process.execPath), "..", "..", DEFAULT_PLUGIN_ENTRY))
paths.add(resolve(moduleDir, DEFAULT_PLUGIN_ENTRY))
paths.add(join(dirname(process.execPath), DEFAULT_PLUGIN_ENTRY))
paths.add(resolve(dirname(process.execPath), "..", DEFAULT_PLUGIN_ENTRY))
paths.add(resolve(process.cwd(), DEFAULT_PLUGIN_ENTRY))
return [...paths]
}
function resolveExternalPluginPath(): string {
const candidates = resolveExternalPluginCandidates()
for (const candidate of candidates) {
if (existsSync(candidate)) {
return candidate
}
}
throw new Error(`Unable to locate external plugin. Checked: ${candidates.join(", ")}`)
}
async function loadExternalPluginFromDisk(
nonce: number,
): Promise<{ path: string; plugin: ReactPlugin<ExternalPluginSlots, ExternalPluginContext> }> {
const path = resolveExternalPluginPath()
const url = pathToFileURL(path)
url.searchParams.set("reload", `${nonce}`)
url.searchParams.set("ts", `${Date.now()}`)
const externalModule = (await import(url.href)) as Partial<ExternalPluginModule>
if (typeof externalModule.loadExternalPlugin !== "function") {
throw new Error("External plugin module does not export loadExternalPlugin()")
}
return {
path,
plugin: externalModule.loadExternalPlugin(),
}
}
const hostContext: ExternalPluginContext = {
appName: "react-external-plugin-demo",
version: "1.0.0",
}
function nextStatusbarMode(mode: SlotMode): SlotMode {
if (mode === "append") {
return "replace"
}
if (mode === "replace") {
return "single_winner"
}
return "append"
}
export default function ExternalPluginSlotsDemo() {
const renderer = useRenderer()
const registry = useMemo(
() => createReactSlotRegistry<ExternalPluginSlots, ExternalPluginContext>(renderer, hostContext),
[renderer],
)
const AppSlot = Slot<ExternalPluginSlots, ExternalPluginContext>
const [statusbarMode, setStatusbarMode] = useState<SlotMode>("append")
const [pluginEnabled, setPluginEnabled] = useState(true)
const [reloadNonce, setReloadNonce] = useState(0)
const [loadedPluginPath, setLoadedPluginPath] = useState("(not loaded yet)")
const [lastPluginId, setLastPluginId] = useState("(none)")
const [lastLoadError, setLastLoadError] = useState<string | null>(null)
useEffect(() => {
renderer.setBackgroundColor("#000000")
}, [renderer])
useEffect(() => {
return registry.onPluginError((event) => {
setLastLoadError(`${event.phase}: ${event.error.message}`)
})
}, [registry])
useEffect(() => {
let cleanedUp = false
let unregisterPlugin: (() => void) | null = null
if (!pluginEnabled) {
setLastPluginId("(disabled)")
setLastLoadError(null)
return () => {
cleanedUp = true
}
}
setLastLoadError(null)
void (async () => {
try {
const { path, plugin } = await loadExternalPluginFromDisk(reloadNonce)
if (cleanedUp) {
return
}
const unregister = registry.register(plugin)
unregisterPlugin = () => {
unregister()
}
setLoadedPluginPath(path)
setLastPluginId(plugin.id)
setLastLoadError(null)
} catch (error) {
const message = error instanceof Error ? `${error.name}: ${error.message}` : String(error)
setLastPluginId("(load failed)")
setLastLoadError(message)
}
})()
return () => {
cleanedUp = true
if (unregisterPlugin) {
unregisterPlugin()
unregisterPlugin = null
}
}
}, [registry, pluginEnabled, reloadNonce])
useKeyboard((key) => {
switch (key.name) {
case "m":
setStatusbarMode((current) => nextStatusbarMode(current))
return
case "p":
setPluginEnabled((current) => !current)
return
case "r":
setReloadNonce((current) => current + 1)
return
case "c":
if (key.ctrl) {
key.preventDefault()
renderer.destroy()
}
return
}
})
const info = [
"React External Plugin Slot Demo",
"",
`External plugin env override: ${EXTERNAL_PLUGIN_PATH_ENV}`,
`External plugin resolved path: ${loadedPluginPath}`,
`Last loaded plugin id: ${lastPluginId}`,
`Last plugin load error: ${lastLoadError ?? "(none)"}`,
"",
`Plugin enabled: ${pluginEnabled ? "ON" : "OFF"} (press p)`,
`Statusbar mode: ${statusbarMode.toUpperCase()} (press m to cycle)`,
"Press r to reload external plugin from disk and re-register.",
"",
`Statusbar slot label: ${STATUSBAR_LABEL}`,
`Sidebar slot section: ${SIDEBAR_SECTION}`,
"",
"The plugin renders external JSX components for both slots.",
].join("\n")
return (
<box width="100%" height="100%" flexDirection="column" padding={1} backgroundColor="#020617">
<box
height={5}
width="100%"
border
borderStyle="single"
borderColor="#334155"
alignItems="center"
flexDirection="row"
paddingLeft={1}
marginBottom={1}
>
<AppSlot registry={registry} name="statusbar" label={STATUSBAR_LABEL} mode={statusbarMode}>
<text fg="#94a3b8">Fallback statusbar content</text>
</AppSlot>
</box>
<box width="100%" flexGrow={1} flexDirection="row">
<box
width={44}
border
borderStyle="single"
borderColor="#334155"
flexDirection="column"
padding={1}
marginRight={1}
>
<AppSlot registry={registry} name="sidebar" section={SIDEBAR_SECTION} mode="replace">
<text fg="#94a3b8">No external sidebar plugin loaded</text>
</AppSlot>
</box>
<box flexGrow={1} border borderStyle="single" borderColor="#334155" flexDirection="column" padding={1}>
<text fg="#e2e8f0" content={info} />
</box>
</box>
</box>
)
}