mirror of
https://github.com/anomalyco/opentui.git
synced 2026-09-19 01:26:03 +08:00
264 lines
7.3 KiB
TypeScript
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>
|
|
)
|
|
}
|