feat(tui): add v2 plugin runtime
This commit is contained in:
parent
5c5579e90c
commit
4a93972a78
63 changed files with 1722 additions and 1701 deletions
|
|
@ -1,356 +0,0 @@
|
|||
import type { TuiDialogSelectOption, TuiPluginApi, TuiSlotProps } from "@opencode-ai/plugin/tui"
|
||||
import type { Config } from "../config"
|
||||
import type { useEvent } from "../context/event"
|
||||
import type { useRoute } from "../context/route"
|
||||
import type { useClient } from "../context/client"
|
||||
import type { useData } from "../context/data"
|
||||
import type { useProject } from "../context/project"
|
||||
import type { useTheme } from "../context/theme"
|
||||
import { Dialog as DialogUI, type useDialog } from "../ui/dialog"
|
||||
import type { useOpencodeKeymap } from "../keymap"
|
||||
import { DialogAlert } from "../ui/dialog-alert"
|
||||
import { DialogConfirm } from "../ui/dialog-confirm"
|
||||
import { DialogPrompt } from "../ui/dialog-prompt"
|
||||
import { DialogSelect, type DialogSelectOption as SelectOption } from "../ui/dialog-select"
|
||||
import { Prompt } from "../component/prompt"
|
||||
import type { useToast } from "../ui/toast"
|
||||
import * as Keymap from "../keymap"
|
||||
import { createCommandShim } from "./command-shim"
|
||||
import type { PluginRoutes } from "./api"
|
||||
export type { RouteMap } from "./api"
|
||||
export { createPluginRoutes, createTuiApi } from "./api"
|
||||
|
||||
type Input = {
|
||||
version: string
|
||||
tuiConfig: Config.Resolved
|
||||
dialog: ReturnType<typeof useDialog>
|
||||
keymap: ReturnType<typeof useOpencodeKeymap>
|
||||
route: ReturnType<typeof useRoute>
|
||||
routes: PluginRoutes
|
||||
event: ReturnType<typeof useEvent>
|
||||
client: ReturnType<typeof useClient>
|
||||
project: ReturnType<typeof useProject>
|
||||
data: ReturnType<typeof useData>
|
||||
theme: ReturnType<typeof useTheme>
|
||||
toast: ReturnType<typeof useToast>
|
||||
renderer: TuiPluginApi["renderer"]
|
||||
attention: TuiPluginApi["attention"]
|
||||
Slot: TuiPluginApi["ui"]["Slot"]
|
||||
}
|
||||
|
||||
function routeNavigate(route: ReturnType<typeof useRoute>, name: string, params?: Record<string, unknown>) {
|
||||
if (name === "home") {
|
||||
route.navigate({ type: "home" })
|
||||
return
|
||||
}
|
||||
|
||||
if (name === "session") {
|
||||
const sessionID = params?.sessionID
|
||||
if (typeof sessionID !== "string") return
|
||||
route.navigate({ type: "session", sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
route.navigate({ type: "plugin", id: name, data: params })
|
||||
}
|
||||
|
||||
function routeCurrent(route: ReturnType<typeof useRoute>): TuiPluginApi["route"]["current"] {
|
||||
if (route.data.type === "home") return { name: "home" }
|
||||
if (route.data.type === "session") {
|
||||
return {
|
||||
name: "session",
|
||||
params: {
|
||||
sessionID: route.data.sessionID,
|
||||
prompt: route.data.prompt,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: route.data.id,
|
||||
params: route.data.data,
|
||||
}
|
||||
}
|
||||
|
||||
function mapOption<Value>(item: TuiDialogSelectOption<Value>): SelectOption<Value> {
|
||||
return {
|
||||
...item,
|
||||
onSelect: () => item.onSelect?.(),
|
||||
}
|
||||
}
|
||||
|
||||
function pickOption<Value>(item: SelectOption<Value>): TuiDialogSelectOption<Value> {
|
||||
return {
|
||||
title: item.title,
|
||||
value: item.value,
|
||||
description: item.description,
|
||||
footer: item.footer,
|
||||
category: item.category,
|
||||
disabled: item.disabled,
|
||||
}
|
||||
}
|
||||
|
||||
function mapOptionCb<Value>(cb?: (item: TuiDialogSelectOption<Value>) => void) {
|
||||
if (!cb) return
|
||||
return (item: SelectOption<Value>) => cb(pickOption(item))
|
||||
}
|
||||
|
||||
function stateApi(project: ReturnType<typeof useProject>, data: ReturnType<typeof useData>): TuiPluginApi["state"] {
|
||||
return {
|
||||
get ready() {
|
||||
return true
|
||||
},
|
||||
get config() {
|
||||
return {}
|
||||
},
|
||||
get provider() {
|
||||
return []
|
||||
},
|
||||
get path() {
|
||||
return project.instance.path()
|
||||
},
|
||||
get vcs() {
|
||||
return undefined
|
||||
},
|
||||
session: {
|
||||
count() {
|
||||
return data.session.list().length
|
||||
},
|
||||
get(_sessionID) {
|
||||
return undefined
|
||||
},
|
||||
diff(_sessionID) {
|
||||
return []
|
||||
},
|
||||
messages(_sessionID) {
|
||||
return []
|
||||
},
|
||||
status(sessionID) {
|
||||
return data.session.status(sessionID) === "running" ? { type: "busy" } : { type: "idle" }
|
||||
},
|
||||
permission(_sessionID) {
|
||||
return []
|
||||
},
|
||||
question(_sessionID) {
|
||||
return []
|
||||
},
|
||||
},
|
||||
part(_messageID) {
|
||||
return []
|
||||
},
|
||||
lsp() {
|
||||
return []
|
||||
},
|
||||
mcp() {
|
||||
return (data.location.mcp.server.list() ?? [])
|
||||
.toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
.flatMap((item) =>
|
||||
item.status.status === "pending"
|
||||
? []
|
||||
: [
|
||||
{
|
||||
name: item.name,
|
||||
status: item.status.status,
|
||||
error: item.status.status === "failed" ? item.status.error : undefined,
|
||||
},
|
||||
],
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function appApi(version: string): TuiPluginApi["app"] {
|
||||
return {
|
||||
get version() {
|
||||
return version
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const unsupportedClient = new Proxy(
|
||||
{},
|
||||
{
|
||||
get() {
|
||||
throw new Error("The legacy plugin client is not supported in V2")
|
||||
},
|
||||
},
|
||||
) as TuiPluginApi["client"]
|
||||
|
||||
export function createTuiApiAdapters(input: Input): Omit<TuiPluginApi, "lifecycle"> {
|
||||
return {
|
||||
app: appApi(input.version),
|
||||
attention: input.attention,
|
||||
// Keep deprecated `api.command` working for v1 plugins; remove in v2.
|
||||
command: createCommandShim(input.keymap, input.dialog, input.tuiConfig.keybinds),
|
||||
keys: {
|
||||
formatSequence(parts) {
|
||||
return Keymap.formatKeySequence(parts, input.tuiConfig)
|
||||
},
|
||||
formatBindings(bindings) {
|
||||
return Keymap.formatKeyBindings(bindings, input.tuiConfig)
|
||||
},
|
||||
},
|
||||
keymap: input.keymap,
|
||||
mode: {
|
||||
current() {
|
||||
return Keymap.getOpencodeModeStack(input.keymap).current()
|
||||
},
|
||||
push(mode) {
|
||||
return Keymap.getOpencodeModeStack(input.keymap).push(mode)
|
||||
},
|
||||
},
|
||||
route: {
|
||||
register(list) {
|
||||
return input.routes.register(list)
|
||||
},
|
||||
navigate(name, params) {
|
||||
routeNavigate(input.route, name, params)
|
||||
},
|
||||
get current() {
|
||||
return routeCurrent(input.route)
|
||||
},
|
||||
},
|
||||
ui: {
|
||||
Dialog(props) {
|
||||
return (
|
||||
<DialogUI size={props.size} onClose={props.onClose}>
|
||||
{props.children}
|
||||
</DialogUI>
|
||||
)
|
||||
},
|
||||
DialogAlert(props) {
|
||||
return <DialogAlert {...props} />
|
||||
},
|
||||
DialogConfirm(props) {
|
||||
return <DialogConfirm {...props} />
|
||||
},
|
||||
DialogPrompt(props) {
|
||||
return <DialogPrompt {...props} description={props.description} />
|
||||
},
|
||||
DialogSelect(props) {
|
||||
return (
|
||||
<DialogSelect
|
||||
title={props.title}
|
||||
placeholder={props.placeholder}
|
||||
options={props.options.map(mapOption)}
|
||||
flat={props.flat}
|
||||
onMove={mapOptionCb(props.onMove)}
|
||||
onFilter={props.onFilter}
|
||||
onSelect={mapOptionCb(props.onSelect)}
|
||||
skipFilter={props.skipFilter}
|
||||
current={props.current}
|
||||
/>
|
||||
)
|
||||
},
|
||||
Slot<Name extends string>(props: TuiSlotProps<Name>) {
|
||||
return <input.Slot {...props} />
|
||||
},
|
||||
Prompt(props) {
|
||||
return (
|
||||
<Prompt
|
||||
sessionID={props.sessionID}
|
||||
visible={props.visible}
|
||||
disabled={props.disabled}
|
||||
onSubmit={props.onSubmit}
|
||||
ref={props.ref}
|
||||
hint={props.hint}
|
||||
right={props.right}
|
||||
showPlaceholder={props.showPlaceholder}
|
||||
placeholders={props.placeholders}
|
||||
/>
|
||||
)
|
||||
},
|
||||
toast(inputToast) {
|
||||
input.toast.show({
|
||||
title: inputToast.title,
|
||||
message: inputToast.message,
|
||||
variant: inputToast.variant ?? "info",
|
||||
duration: inputToast.duration,
|
||||
})
|
||||
},
|
||||
dialog: {
|
||||
replace(render, onClose) {
|
||||
input.dialog.replace(render, onClose)
|
||||
},
|
||||
clear() {
|
||||
input.dialog.clear()
|
||||
},
|
||||
setSize(size) {
|
||||
input.dialog.setSize(size)
|
||||
},
|
||||
get size() {
|
||||
return input.dialog.size
|
||||
},
|
||||
get depth() {
|
||||
return input.dialog.stack.length
|
||||
},
|
||||
get open() {
|
||||
return input.dialog.stack.length > 0
|
||||
},
|
||||
},
|
||||
},
|
||||
get tuiConfig() {
|
||||
return input.tuiConfig
|
||||
},
|
||||
kv: {
|
||||
get(_key, fallback) {
|
||||
if (fallback === undefined) throw new Error("Persistent TUI KV storage is not supported")
|
||||
return fallback
|
||||
},
|
||||
set() {},
|
||||
ready: true,
|
||||
},
|
||||
state: stateApi(input.project, input.data),
|
||||
client: unsupportedClient,
|
||||
event: input.event,
|
||||
renderer: input.renderer,
|
||||
slots: {
|
||||
register() {
|
||||
throw new Error("slots.register is only available in plugin context")
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
list() {
|
||||
return []
|
||||
},
|
||||
async activate() {
|
||||
return false
|
||||
},
|
||||
async deactivate() {
|
||||
return false
|
||||
},
|
||||
async add() {
|
||||
return false
|
||||
},
|
||||
async install() {
|
||||
return {
|
||||
ok: false,
|
||||
message: "plugins.install is only available in plugin context",
|
||||
}
|
||||
},
|
||||
},
|
||||
theme: {
|
||||
get current() {
|
||||
return input.theme.theme
|
||||
},
|
||||
get selected() {
|
||||
return input.theme.selected
|
||||
},
|
||||
has(name) {
|
||||
return input.theme.has(name)
|
||||
},
|
||||
set(name) {
|
||||
return input.theme.set(name)
|
||||
},
|
||||
async install(_jsonPath) {
|
||||
throw new Error("theme.install is only available in plugin context")
|
||||
},
|
||||
mode() {
|
||||
return input.theme.mode()
|
||||
},
|
||||
get ready() {
|
||||
return input.theme.ready
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import type { TuiPluginApi, TuiRouteDefinition } from "@opencode-ai/plugin/tui"
|
||||
import type { TuiRouteDefinition } from "@opencode-ai/plugin/tui"
|
||||
import { createSignal } from "solid-js"
|
||||
|
||||
type RouteEntry = {
|
||||
|
|
@ -38,15 +38,3 @@ export function createPluginRoutes() {
|
|||
}
|
||||
|
||||
export type PluginRoutes = ReturnType<typeof createPluginRoutes>
|
||||
|
||||
export function createTuiApi(input: Omit<TuiPluginApi, "lifecycle">): TuiPluginApi {
|
||||
return {
|
||||
...input,
|
||||
lifecycle: {
|
||||
signal: new AbortController().signal,
|
||||
onDispose() {
|
||||
return () => {}
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
9
packages/tui/src/plugin/builtins.ts
Normal file
9
packages/tui/src/plugin/builtins.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import HomeFooter from "../feature-plugins/home/footer"
|
||||
import HomeTips from "../feature-plugins/home/tips"
|
||||
import SidebarContext from "../feature-plugins/sidebar/context"
|
||||
import SidebarFooter from "../feature-plugins/sidebar/footer"
|
||||
import SidebarLsp from "../feature-plugins/sidebar/lsp"
|
||||
import SidebarMcp from "../feature-plugins/sidebar/mcp"
|
||||
import Scrap from "../feature-plugins/system/scrap"
|
||||
|
||||
export const builtins = [HomeFooter, HomeTips, SidebarContext, SidebarMcp, SidebarLsp, SidebarFooter, Scrap]
|
||||
|
|
@ -56,8 +56,7 @@ function toCommand(item: TuiCommand, dialog: LegacyDialog) {
|
|||
suggested: item.suggested,
|
||||
hidden: item.hidden,
|
||||
enabled: item.enabled,
|
||||
slashName: item.slash?.name,
|
||||
slashAliases: item.slash?.aliases,
|
||||
slash: item.slash,
|
||||
run() {
|
||||
return item.onSelect?.(dialog)
|
||||
},
|
||||
|
|
|
|||
383
packages/tui/src/plugin/context.tsx
Normal file
383
packages/tui/src/plugin/context.tsx
Normal file
|
|
@ -0,0 +1,383 @@
|
|||
import type { Plugin } from "@opencode-ai/plugin/v2/tui"
|
||||
import {
|
||||
batch,
|
||||
createContext,
|
||||
createMemo,
|
||||
For,
|
||||
onCleanup,
|
||||
onMount,
|
||||
useContext,
|
||||
type JSX,
|
||||
type ParentProps,
|
||||
} from "solid-js"
|
||||
import path from "path"
|
||||
import { stat } from "fs/promises"
|
||||
import { fileURLToPath, pathToFileURL } from "url"
|
||||
import type { Context, Page, Slot } from "@opencode-ai/plugin/v2/tui/context"
|
||||
import { createStore, produce, reconcile as reconcileStore } from "solid-js/store"
|
||||
import { useConfig } from "../config"
|
||||
import { useClient } from "../context/client"
|
||||
import { useData } from "../context/data"
|
||||
import { useRoute } from "../context/route"
|
||||
import { useTuiLifecycle } from "../context/runtime"
|
||||
import { builtins } from "./builtins"
|
||||
|
||||
export interface PackageResolver {
|
||||
readonly resolve: (spec: string) => Promise<string | undefined>
|
||||
}
|
||||
|
||||
type State =
|
||||
| { readonly target: string; readonly status: "loading" }
|
||||
| { readonly target: string; readonly id: string; readonly status: "active" | "inactive" }
|
||||
| { readonly target: string; readonly status: "unsupported" }
|
||||
| { readonly target: string; readonly status: "failed"; readonly error: string }
|
||||
|
||||
type Value = {
|
||||
readonly ready: () => boolean
|
||||
readonly list: () => ReadonlyArray<State>
|
||||
readonly route: (id: string, name: string) => Page["render"] | undefined
|
||||
readonly slot: (name: string) => ReadonlyArray<Slot>
|
||||
readonly activate: (id: string) => Promise<boolean>
|
||||
readonly deactivate: (id: string) => Promise<boolean>
|
||||
}
|
||||
|
||||
type Dispose = () => Promise<void>
|
||||
type Registration = {
|
||||
target: string
|
||||
plugin: Plugin.Definition
|
||||
options?: Readonly<Record<string, any>>
|
||||
active: boolean
|
||||
routes: Record<string, Page>
|
||||
slots: Record<string, Slot>
|
||||
cleanups: Dispose[]
|
||||
}
|
||||
|
||||
const PluginContext = createContext<Value>()
|
||||
|
||||
export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>) {
|
||||
const client = useClient()
|
||||
const data = useData()
|
||||
const route = useRoute()
|
||||
const config = useConfig()
|
||||
const lifecycle = useTuiLifecycle()
|
||||
const directory = config.path ? path.dirname(config.path) : process.cwd()
|
||||
const [store, setStore] = createStore({
|
||||
ready: false,
|
||||
states: [] as ReadonlyArray<State>,
|
||||
registrations: {} as Record<string, Registration>,
|
||||
})
|
||||
|
||||
const activate = async (id: string) => {
|
||||
const item = store.registrations[id]
|
||||
if (!item) return false
|
||||
await deactivate(id)
|
||||
batch(() => {
|
||||
setStore("registrations", id, "routes", reconcileStore({}))
|
||||
setStore("registrations", id, "slots", reconcileStore({}))
|
||||
setStore("registrations", id, "cleanups", [])
|
||||
})
|
||||
const owned: Dispose[] = []
|
||||
const context: Context = {
|
||||
options: item.options ?? {},
|
||||
client: client.api,
|
||||
data,
|
||||
ui: {
|
||||
router: {
|
||||
register(page) {
|
||||
if (store.registrations[item.plugin.id]?.routes[page.name])
|
||||
throw new Error(`Route already registered: ${page.name}`)
|
||||
setStore("registrations", item.plugin.id, "routes", page.name, page)
|
||||
let registered = true
|
||||
const unregister = () => {
|
||||
if (!registered) return
|
||||
registered = false
|
||||
if (!store.registrations[item.plugin.id]?.active) return
|
||||
setStore(
|
||||
"registrations",
|
||||
produce((registrations) => {
|
||||
if (!registrations[item.plugin.id]) return
|
||||
delete registrations[item.plugin.id].routes[page.name]
|
||||
}),
|
||||
)
|
||||
}
|
||||
owned.push(async () => unregister())
|
||||
return unregister
|
||||
},
|
||||
navigate(destination) {
|
||||
if (destination.type === "plugin") {
|
||||
route.navigate({ ...destination, id: "id" in destination ? destination.id : item.plugin.id })
|
||||
return
|
||||
}
|
||||
route.navigate(destination)
|
||||
},
|
||||
current() {
|
||||
return route.data
|
||||
},
|
||||
},
|
||||
slot(name, render) {
|
||||
if (store.registrations[item.plugin.id]?.slots[name]) throw new Error(`Slot already registered: ${name}`)
|
||||
setStore("registrations", item.plugin.id, "slots", name, () => render)
|
||||
let registered = true
|
||||
const unregister = () => {
|
||||
if (!registered) return
|
||||
registered = false
|
||||
if (!store.registrations[item.plugin.id]?.active) return
|
||||
setStore(
|
||||
"registrations",
|
||||
produce((registrations) => {
|
||||
if (!registrations[item.plugin.id]) return
|
||||
delete registrations[item.plugin.id].slots[name]
|
||||
}),
|
||||
)
|
||||
}
|
||||
owned.push(async () => unregister())
|
||||
return unregister
|
||||
},
|
||||
},
|
||||
}
|
||||
const cleanup = await setup(item.plugin, context, owned).catch((error) => {
|
||||
setStore("registrations", id, "routes", reconcileStore({}))
|
||||
setStore("registrations", id, "slots", reconcileStore({}))
|
||||
throw error
|
||||
})
|
||||
if (cleanup) owned.push(async () => cleanup())
|
||||
batch(() => {
|
||||
setStore("registrations", id, "cleanups", owned)
|
||||
setStore("registrations", id, "active", true)
|
||||
setStore("states", (items) =>
|
||||
items.map((state) =>
|
||||
"id" in state && state.id === id ? { target: state.target, id, status: "active" } : state,
|
||||
),
|
||||
)
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
const deactivate = async (id: string) => {
|
||||
const item = store.registrations[id]
|
||||
if (!item?.active) return false
|
||||
const cleanups = [...item.cleanups]
|
||||
batch(() => {
|
||||
setStore("registrations", id, "active", false)
|
||||
setStore("registrations", id, "cleanups", [])
|
||||
})
|
||||
await disposeAll(cleanups).finally(() =>
|
||||
batch(() => {
|
||||
if (store.registrations[id]) {
|
||||
setStore("registrations", id, "routes", reconcileStore({}))
|
||||
setStore("registrations", id, "slots", reconcileStore({}))
|
||||
}
|
||||
setStore("states", (items) =>
|
||||
items.map((state) =>
|
||||
"id" in state && state.id === id ? { target: state.target, id, status: "inactive" } : state,
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
const reconcile = async () => {
|
||||
await Promise.all(
|
||||
Object.entries(store.registrations)
|
||||
.filter(([, registration]) => registration.active)
|
||||
.map(([id]) => deactivate(id)),
|
||||
)
|
||||
const entries = config.data.plugins ?? []
|
||||
batch(() => {
|
||||
setStore("registrations", reconcileStore({}))
|
||||
setStore("states", [])
|
||||
})
|
||||
|
||||
for (const plugin of builtins) {
|
||||
setStore("registrations", plugin.id, {
|
||||
target: plugin.id,
|
||||
plugin,
|
||||
active: false,
|
||||
routes: {},
|
||||
slots: {},
|
||||
cleanups: [],
|
||||
})
|
||||
await activate(plugin.id)
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const target = typeof entry === "string" ? entry : entry.package
|
||||
if (target.startsWith("-")) {
|
||||
for (const id of Object.keys(store.registrations).filter((id) => matches(target.slice(1), id)))
|
||||
await deactivate(id)
|
||||
continue
|
||||
}
|
||||
|
||||
const selected = Object.keys(store.registrations).filter((id) => matches(target, id))
|
||||
if (selected.length || target === "*" || target.endsWith(".*") || target.startsWith("opencode.")) {
|
||||
for (const id of selected) await activate(id)
|
||||
continue
|
||||
}
|
||||
|
||||
const options = typeof entry === "string" ? undefined : entry.options
|
||||
setStore("states", (items) => [...items, { target, status: "loading" }])
|
||||
const plugin = await loadPlugin(target, directory, props.packages).catch((error) => {
|
||||
setStore("states", (items) =>
|
||||
items.map((state) =>
|
||||
state.target === target
|
||||
? { target, status: "failed", error: error instanceof Error ? error.message : String(error) }
|
||||
: state,
|
||||
),
|
||||
)
|
||||
return undefined
|
||||
})
|
||||
if (!plugin) {
|
||||
setStore("states", (items) =>
|
||||
items.map((state) =>
|
||||
state.target === target && state.status !== "failed" ? { target, status: "unsupported" } : state,
|
||||
),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
const item = { target, plugin, options }
|
||||
setStore("registrations", item.plugin.id, {
|
||||
...item,
|
||||
active: false,
|
||||
routes: {},
|
||||
slots: {},
|
||||
cleanups: [],
|
||||
})
|
||||
const error = await activate(item.plugin.id).then(
|
||||
() => undefined,
|
||||
(error) => (error instanceof Error ? error.message : String(error)),
|
||||
)
|
||||
setStore("states", (items) => [
|
||||
...items.filter((state) => state.target !== item.target && (!("id" in state) || state.id !== item.plugin.id)),
|
||||
error
|
||||
? { target: item.target, status: "failed", error }
|
||||
: { target: item.target, id: item.plugin.id, status: "active" },
|
||||
])
|
||||
}
|
||||
}
|
||||
onMount(() => {
|
||||
const loading = reconcile()
|
||||
let disposing: Promise<void> | undefined
|
||||
const dispose = () => {
|
||||
if (disposing) return disposing
|
||||
disposing = loading
|
||||
.catch(() => undefined)
|
||||
.then(() =>
|
||||
Promise.all(
|
||||
Object.entries(store.registrations)
|
||||
.filter(([, registration]) => registration.active)
|
||||
.map(([id]) => deactivate(id)),
|
||||
),
|
||||
)
|
||||
.then(() => setStore("registrations", reconcileStore({})))
|
||||
return disposing
|
||||
}
|
||||
const unregister = lifecycle.add(dispose)
|
||||
onCleanup(() => {
|
||||
unregister()
|
||||
void dispose()
|
||||
})
|
||||
void loading.finally(() => setStore("ready", true))
|
||||
})
|
||||
|
||||
return (
|
||||
<PluginContext.Provider
|
||||
value={{
|
||||
ready: () => store.ready,
|
||||
list: () => store.states,
|
||||
route: (id, name) => store.registrations[id]?.routes[name]?.render,
|
||||
slot: (name) =>
|
||||
Object.values(store.registrations).flatMap((registration) =>
|
||||
registration.active && registration.slots[name] ? [registration.slots[name]] : [],
|
||||
),
|
||||
activate,
|
||||
deactivate,
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</PluginContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
async function disposeAll(cleanups: Dispose[]) {
|
||||
const failures: unknown[] = []
|
||||
for (const cleanup of cleanups.splice(0).reverse()) await cleanup().catch((error) => failures.push(error))
|
||||
if (failures.length) throw failures[0]
|
||||
}
|
||||
|
||||
async function setup(plugin: Plugin.Definition, context: Plugin.Context, owned: Dispose[]) {
|
||||
try {
|
||||
return await plugin.setup(context)
|
||||
} catch (error) {
|
||||
await disposeAll(owned).catch(() => undefined)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function matches(selector: string, id: string) {
|
||||
return selector === "*" || selector === id || (selector.endsWith(".*") && id.startsWith(selector.slice(0, -1)))
|
||||
}
|
||||
|
||||
async function loadPlugin(spec: string, directory: string, packages: PackageResolver) {
|
||||
const local = spec.startsWith("file://")
|
||||
? new URL(spec)
|
||||
: spec.startsWith("./") || spec.startsWith("../") || path.isAbsolute(spec)
|
||||
? pathToFileURL(path.resolve(directory, spec))
|
||||
: undefined
|
||||
const entrypoint = local ? await resolveLocal(local) : await packages.resolve(spec)
|
||||
if (!entrypoint) return
|
||||
const mod: { readonly default?: unknown } = await import(entrypoint)
|
||||
if (!isPlugin(mod.default)) throw new Error(`Invalid V2 TUI plugin module: ${spec}`)
|
||||
return mod.default
|
||||
}
|
||||
|
||||
async function resolveLocal(url: URL) {
|
||||
const info = await stat(url)
|
||||
if (info.isFile()) return url.href
|
||||
if (!info.isDirectory()) return
|
||||
return resolve(pathToFileURL(path.join(fileURLToPath(url), "tui")).href)
|
||||
}
|
||||
|
||||
function resolve(specifier: string) {
|
||||
try {
|
||||
return import.meta.resolve(specifier)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function isPlugin(value: unknown): value is Plugin.Definition {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"id" in value &&
|
||||
typeof value.id === "string" &&
|
||||
value.id.length > 0 &&
|
||||
"setup" in value &&
|
||||
typeof value.setup === "function"
|
||||
)
|
||||
}
|
||||
|
||||
export function usePlugin() {
|
||||
const value = useContext(PluginContext)
|
||||
if (!value) throw new Error("PluginProvider is missing")
|
||||
return value
|
||||
}
|
||||
|
||||
export function PluginRoute(props: { readonly fallback: (id: string, name: string) => JSX.Element }) {
|
||||
const plugins = usePlugin()
|
||||
const route = useRoute()
|
||||
const content = createMemo(() => {
|
||||
if (route.data.type !== "plugin") return
|
||||
const render = plugins.route(route.data.id, route.data.name)
|
||||
if (!render) return props.fallback(route.data.id, route.data.name)
|
||||
return render({ data: route.data.data })
|
||||
})
|
||||
return <>{content()}</>
|
||||
}
|
||||
|
||||
export function PluginSlot(props: { readonly name: string; readonly input?: Record<string, any> }) {
|
||||
const plugins = usePlugin()
|
||||
return <For each={plugins.slot(props.name)}>{(render) => render(props.input ?? {})}</For>
|
||||
}
|
||||
|
|
@ -23,7 +23,7 @@ function isHostSlotPlugin(value: unknown): value is HostSlotPlugin<Record<string
|
|||
}
|
||||
|
||||
export function createSlots() {
|
||||
const empty: SlotView = () => null
|
||||
const empty: SlotView = (props) => props.children ?? null
|
||||
const [view, setView] = createSignal<SlotView>(empty)
|
||||
const Slot: SlotView = (props) => view()(props)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue