|
|
@@ -1,53 +1,25 @@
|
|
|
-import { PluginContextProvider, type Plugin } from "@opencode-ai/plugin/tui"
|
|
|
-import {
|
|
|
- batch,
|
|
|
- createComponent,
|
|
|
- createContext,
|
|
|
- createEffect,
|
|
|
- createMemo,
|
|
|
- For,
|
|
|
- mergeProps,
|
|
|
- on,
|
|
|
- onCleanup,
|
|
|
- onMount,
|
|
|
- useContext,
|
|
|
- type JSX,
|
|
|
- type ParentProps,
|
|
|
-} from "solid-js"
|
|
|
+import type { Plugin } from "@opencode-ai/plugin/tui"
|
|
|
+import { batch, createContext, createEffect, on, onCleanup, onMount, useContext, type ParentProps } from "solid-js"
|
|
|
import path from "path"
|
|
|
import { stat } from "fs/promises"
|
|
|
import { fileURLToPath, pathToFileURL } from "url"
|
|
|
-import type { Context, Dialog, Page, Slot, SlotMap, SlotName, Toast } from "@opencode-ai/plugin/tui/context"
|
|
|
+import type { Page, Slot, SlotName } from "@opencode-ai/plugin/tui/context"
|
|
|
import { createStore, produce, reconcile as reconcileStore } from "solid-js/store"
|
|
|
-import { useRenderer } from "@opentui/solid"
|
|
|
+import { isDeepEqual } from "remeda"
|
|
|
import "#runtime-plugin-support"
|
|
|
import { useConfig } from "../config"
|
|
|
-import { useClient } from "../context/client"
|
|
|
-import { useData } from "../context/data"
|
|
|
-import { Keymap } from "../context/keymap"
|
|
|
-import { useRoute } from "../context/route"
|
|
|
-import { useTuiApp, useTuiLifecycle, useTuiPaths } from "../context/runtime"
|
|
|
-import { useLocation } from "../context/location"
|
|
|
-import { useThemes } from "../context/theme"
|
|
|
-import { DialogAlert } from "../ui/dialog-alert"
|
|
|
-import { DialogConfirm } from "../ui/dialog-confirm"
|
|
|
-import { DialogPrompt } from "../ui/dialog-prompt"
|
|
|
-import { DialogSelect } from "../ui/dialog-select"
|
|
|
-import { useDialog } from "../ui/dialog"
|
|
|
-import { useToast } from "../ui/toast"
|
|
|
-import { useAttention } from "../context/attention"
|
|
|
-import { useStorage } from "../context/storage"
|
|
|
-import { useSessionTabs } from "../context/session-tabs"
|
|
|
-import { abbreviateHome } from "../util/path-format"
|
|
|
+import { useTuiLifecycle } from "../context/runtime"
|
|
|
+import { errorMessage } from "../util/error"
|
|
|
import { builtins } from "./builtins"
|
|
|
-import { discoverTuiPlugins } from "./discovery"
|
|
|
+import { createPluginContext, usePluginHost, type Dispose } from "./api"
|
|
|
+import { createSourceWatcher } from "./watch"
|
|
|
+import { discoverTuiPlugins, freshSpecifier, localSource, tuiPluginDirectory } from "./discovery"
|
|
|
|
|
|
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 }
|
|
|
@@ -63,15 +35,16 @@ type Value = {
|
|
|
readonly list: () => ReadonlyArray<State>
|
|
|
readonly registered: () => ReadonlyArray<RegisteredPlugin>
|
|
|
readonly route: (id: string, name: string) => Page["render"] | undefined
|
|
|
- readonly slot: <Name extends SlotName>(name: Name) => ReadonlyArray<Slot<Name>>
|
|
|
+ readonly slot: <Name extends SlotName>(name: Name) => ReadonlyArray<{ readonly id: string; readonly render: Slot<Name> }>
|
|
|
readonly activate: (id: string) => Promise<boolean>
|
|
|
readonly deactivate: (id: string) => Promise<boolean>
|
|
|
}
|
|
|
|
|
|
-type Dispose = () => Promise<void>
|
|
|
type Registration = {
|
|
|
plugin: Plugin.Definition
|
|
|
source: RegisteredPlugin["source"]
|
|
|
+ target?: string
|
|
|
+ version: string
|
|
|
options?: Readonly<Record<string, any>>
|
|
|
active: boolean
|
|
|
routes: Record<string, Page>
|
|
|
@@ -79,27 +52,15 @@ type Registration = {
|
|
|
cleanups: Dispose[]
|
|
|
}
|
|
|
|
|
|
+// One entry of the desired plugin generation produced by the resolve phase.
|
|
|
+type Desired = Pick<Registration, "plugin" | "source" | "target" | "version" | "options"> & { enabled: boolean }
|
|
|
+
|
|
|
const PluginContext = createContext<Value>()
|
|
|
|
|
|
export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>) {
|
|
|
- const renderer = useRenderer()
|
|
|
- const client = useClient()
|
|
|
- const data = useData()
|
|
|
- const route = useRoute()
|
|
|
+ const host = usePluginHost()
|
|
|
const config = useConfig()
|
|
|
- const keymap = Keymap.use()
|
|
|
- const shortcuts = Keymap.useShortcuts()
|
|
|
- const keymapState = Keymap.useState()
|
|
|
const lifecycle = useTuiLifecycle()
|
|
|
- const app = useTuiApp()
|
|
|
- const paths = useTuiPaths()
|
|
|
- const location = useLocation()
|
|
|
- const themes = useThemes()
|
|
|
- const dialog = useDialog()
|
|
|
- const toast = useToast()
|
|
|
- const attention = useAttention()
|
|
|
- const storage = useStorage()
|
|
|
- const sessionTabs = useSessionTabs()
|
|
|
const directory = config.path ? path.dirname(config.path) : process.cwd()
|
|
|
const [store, setStore] = createStore({
|
|
|
ready: false,
|
|
|
@@ -117,223 +78,26 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
|
|
|
setStore("registrations", id, "cleanups", [])
|
|
|
})
|
|
|
const owned: Dispose[] = []
|
|
|
- let context: Context
|
|
|
- const dialogApi: Dialog = {
|
|
|
- show(render, onClose) {
|
|
|
- dialog.replace(() => <PluginContextProvider value={context}>{render()}</PluginContextProvider>, onClose)
|
|
|
- },
|
|
|
- set(options) {
|
|
|
- dialog.setSize(options.size ?? "medium")
|
|
|
- dialog.setCentered(options.centered ?? false)
|
|
|
- },
|
|
|
- clear() {
|
|
|
- dialog.clear()
|
|
|
- },
|
|
|
- alert(options) {
|
|
|
- return new Promise<void>((resolve) => {
|
|
|
- let settled = false
|
|
|
- const done = () => {
|
|
|
- if (settled) return
|
|
|
- settled = true
|
|
|
- resolve()
|
|
|
- }
|
|
|
- dialogApi.show(() => <DialogAlert title={options.title} message={options.message} onConfirm={done} />, done)
|
|
|
- })
|
|
|
- },
|
|
|
- confirm(options) {
|
|
|
- return new Promise<boolean | undefined>((resolve) => {
|
|
|
- let settled = false
|
|
|
- const done = (result: boolean | undefined) => {
|
|
|
- if (settled) return
|
|
|
- settled = true
|
|
|
- resolve(result)
|
|
|
- }
|
|
|
- dialogApi.show(
|
|
|
- () => (
|
|
|
- <DialogConfirm
|
|
|
- title={options.title}
|
|
|
- message={options.message}
|
|
|
- label={options.label}
|
|
|
- onConfirm={() => done(true)}
|
|
|
- onCancel={() => done(false)}
|
|
|
- />
|
|
|
- ),
|
|
|
- () => done(undefined),
|
|
|
- )
|
|
|
- })
|
|
|
- },
|
|
|
- prompt(options) {
|
|
|
- return new Promise<string | undefined>((resolve) => {
|
|
|
- let settled = false
|
|
|
- const done = (result: string | undefined) => {
|
|
|
- if (settled) return
|
|
|
- settled = true
|
|
|
- resolve(result)
|
|
|
- }
|
|
|
- dialogApi.show(
|
|
|
- () => (
|
|
|
- <DialogPrompt
|
|
|
- title={options.title}
|
|
|
- description={options.description ? () => <text>{options.description}</text> : undefined}
|
|
|
- placeholder={options.placeholder}
|
|
|
- value={options.value}
|
|
|
- onConfirm={(value) => {
|
|
|
- done(value)
|
|
|
- dialogApi.clear()
|
|
|
- }}
|
|
|
- />
|
|
|
- ),
|
|
|
- () => done(undefined),
|
|
|
- )
|
|
|
- })
|
|
|
- },
|
|
|
- select(options) {
|
|
|
- return new Promise((resolve) => {
|
|
|
- let settled = false
|
|
|
- const done = (result: (typeof options.options)[number]["value"] | undefined) => {
|
|
|
- if (settled) return
|
|
|
- settled = true
|
|
|
- resolve(result)
|
|
|
- }
|
|
|
- dialogApi.show(
|
|
|
- () => (
|
|
|
- <DialogSelect
|
|
|
- title={options.title}
|
|
|
- placeholder={options.placeholder}
|
|
|
- options={options.options.map((option) => ({ ...option }))}
|
|
|
- current={options.current}
|
|
|
- onSelect={(option) => {
|
|
|
- done(option.value)
|
|
|
- dialogApi.clear()
|
|
|
- }}
|
|
|
- />
|
|
|
- ),
|
|
|
- () => done(undefined),
|
|
|
- )
|
|
|
- })
|
|
|
- },
|
|
|
- }
|
|
|
- const toastApi: Toast = {
|
|
|
- show(options) {
|
|
|
- toast.show({ ...options, variant: options.variant ?? "info" })
|
|
|
- },
|
|
|
- }
|
|
|
- context = {
|
|
|
- options: item.options ?? {},
|
|
|
- get location() {
|
|
|
- return location.current
|
|
|
- },
|
|
|
- app: { version: app.version, channel: app.channel },
|
|
|
- renderer,
|
|
|
- client: client.api,
|
|
|
- data,
|
|
|
- attention,
|
|
|
- get theme() {
|
|
|
- return themes.currentTokens()
|
|
|
- },
|
|
|
- keymap: {
|
|
|
- layer: Keymap.createLayer,
|
|
|
- dispatch: keymap.dispatch,
|
|
|
- shortcuts: shortcuts.list,
|
|
|
- commands: keymapState.commands,
|
|
|
- pending: keymapState.pending,
|
|
|
- active: keymapState.active,
|
|
|
- mode: keymap.mode,
|
|
|
- },
|
|
|
- storage: {
|
|
|
- store: (key, options) => storage.store(`plugin.${item.plugin.id}.${key}`, options),
|
|
|
- },
|
|
|
- ui: {
|
|
|
- dialog: dialogApi,
|
|
|
- toast: toastApi,
|
|
|
- format: {
|
|
|
- path: (value) => abbreviateHome(value, paths.home),
|
|
|
- },
|
|
|
- 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,
|
|
|
- render: (input) => <PluginContextProvider value={context}>{page.render(input)}</PluginContextProvider>,
|
|
|
- })
|
|
|
- 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
|
|
|
- },
|
|
|
- },
|
|
|
- tabs: {
|
|
|
- enabled: sessionTabs.enabled,
|
|
|
- list: () =>
|
|
|
- sessionTabs.tabs().map((tab) => ({
|
|
|
- ...tab,
|
|
|
- active: sessionTabs.current() === tab.sessionID,
|
|
|
- ...sessionTabs.status(tab.sessionID),
|
|
|
- })),
|
|
|
- open(sessionID) {
|
|
|
- if (!sessionTabs.enabled()) return false
|
|
|
- sessionTabs.select(sessionID)
|
|
|
- return true
|
|
|
- },
|
|
|
- focus(sessionID) {
|
|
|
- if (!sessionTabs.enabled()) return false
|
|
|
- if (!sessionTabs.tabs().some((tab) => tab.sessionID === sessionID)) return false
|
|
|
- sessionTabs.select(sessionID)
|
|
|
- return true
|
|
|
- },
|
|
|
- close(sessionID) {
|
|
|
- if (!sessionTabs.enabled()) return false
|
|
|
- const target = sessionID ?? sessionTabs.current()
|
|
|
- if (!target || !sessionTabs.tabs().some((tab) => tab.sessionID === target)) return false
|
|
|
- sessionTabs.close(target)
|
|
|
- return true
|
|
|
- },
|
|
|
- },
|
|
|
- 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, () => (input: SlotMap[typeof name]) => (
|
|
|
- <PluginContextProvider value={context}>{render(input)}</PluginContextProvider>
|
|
|
- ))
|
|
|
- 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 context = createPluginContext({
|
|
|
+ host,
|
|
|
+ id,
|
|
|
+ options: item.options,
|
|
|
+ owned,
|
|
|
+ registry: {
|
|
|
+ has: (kind, name) => Boolean(store.registrations[id]?.[kind][name]),
|
|
|
+ set: (kind: "routes" | "slots", name: string, value: Page | Slot) =>
|
|
|
+ setStore("registrations", id, kind, name, () => value),
|
|
|
+ remove: (kind, name) =>
|
|
|
+ setStore(
|
|
|
+ "registrations",
|
|
|
+ produce((registrations) => {
|
|
|
+ if (!registrations[id]) return
|
|
|
+ delete registrations[id][kind][name]
|
|
|
+ }),
|
|
|
+ ),
|
|
|
+ active: () => Boolean(store.registrations[id]?.active),
|
|
|
},
|
|
|
- }
|
|
|
+ })
|
|
|
const cleanup = await setup(item.plugin, context, owned).catch((error) => {
|
|
|
setStore("registrations", id, "routes", reconcileStore({}))
|
|
|
setStore("registrations", id, "slots", reconcileStore({}))
|
|
|
@@ -376,94 +140,219 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
|
|
|
return true
|
|
|
}
|
|
|
|
|
|
- const reconcile = async (configured = config.data.plugins ?? []) => {
|
|
|
- await Promise.all(
|
|
|
- Object.entries(store.registrations)
|
|
|
- .filter(([, registration]) => registration.active)
|
|
|
- .map(([id]) => deactivate(id)),
|
|
|
+ // Cleanup failures must not stop a swap or teardown, but they should not
|
|
|
+ // vanish either: the old generation may still own listeners or intervals.
|
|
|
+ const deactivateNoisily = (id: string) =>
|
|
|
+ deactivate(id).catch((error) =>
|
|
|
+ host.toast.show({ variant: "error", title: "Plugin", message: `${id}: cleanup failed: ${errorMessage(error)}` }),
|
|
|
)
|
|
|
- const entries = [...(await discoverTuiPlugins(paths.cwd)), ...configured]
|
|
|
- batch(() => {
|
|
|
- setStore("registrations", reconcileStore({}))
|
|
|
- setStore("states", [])
|
|
|
- })
|
|
|
|
|
|
- for (const plugin of builtins) {
|
|
|
- setStore("registrations", plugin.id, {
|
|
|
- plugin,
|
|
|
- source: "builtin",
|
|
|
- active: false,
|
|
|
- routes: {},
|
|
|
- slots: {},
|
|
|
- cleanups: [],
|
|
|
- })
|
|
|
- await activate(plugin.id)
|
|
|
- }
|
|
|
+ // Every lifecycle mutation — reconciles, manual dialog toggles, shutdown —
|
|
|
+ // is serialized through one chain so generations can never interleave.
|
|
|
+ let loading = Promise.resolve()
|
|
|
+ const enqueue = <T,>(task: () => Promise<T>) => {
|
|
|
+ const result = loading.catch(() => undefined).then(task)
|
|
|
+ loading = result.then(
|
|
|
+ () => undefined,
|
|
|
+ () => undefined,
|
|
|
+ )
|
|
|
+ return result
|
|
|
+ }
|
|
|
+
|
|
|
+ // Hot-reload local plugin sources: watch the discovery directory and any
|
|
|
+ // local entrypoints (see watch.ts for the mechanics), debounced into a
|
|
|
+ // serialized reconcile so bursts of events rebuild the generation once.
|
|
|
+ let pending: ReturnType<typeof setTimeout> | undefined
|
|
|
+ const watcher = createSourceWatcher(() => {
|
|
|
+ clearTimeout(pending)
|
|
|
+ pending = setTimeout(() => {
|
|
|
+ // Observe failures immediately: a plugin cleanup that throws would
|
|
|
+ // otherwise surface as an unhandled rejection until the next trigger.
|
|
|
+ void enqueue(reconcile).catch(() => undefined)
|
|
|
+ }, 100)
|
|
|
+ })
|
|
|
+ onCleanup(() => {
|
|
|
+ clearTimeout(pending)
|
|
|
+ watcher.dispose()
|
|
|
+ })
|
|
|
+
|
|
|
+ // Rebuild the plugin generation as resolve → compare → swap, mirroring the
|
|
|
+ // core plugin registry: fold the ordered entries into a desired end state
|
|
|
+ // (importing only new or changed sources, before anything running is
|
|
|
+ // touched), no-op when the generation is unchanged, and restart only the
|
|
|
+ // plugins that differ. Membership or order changes rebuild the whole
|
|
|
+ // generation to preserve slot-order semantics.
|
|
|
+ // Package resolution failures would otherwise retry a full npm install on
|
|
|
+ // every watch event; remember them until the configuration changes.
|
|
|
+ const npmFailures = new Map<string, string>()
|
|
|
+ const reconcile = async () => {
|
|
|
+ const entries = [...(await discoverTuiPlugins(host.paths.cwd)), ...(config.data.plugins ?? [])]
|
|
|
+ watcher.add(tuiPluginDirectory(host.paths.cwd))
|
|
|
|
|
|
+ // Resolve: fold entries into one desired generation. A source that fails
|
|
|
+ // to import keeps its running previous version and only reports failure.
|
|
|
+ const desired = new Map<string, Desired>()
|
|
|
+ for (const plugin of builtins) desired.set(plugin.id, { plugin, source: "builtin", version: "builtin", enabled: true })
|
|
|
+ const failures: State[] = []
|
|
|
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)
|
|
|
+ for (const item of desired.values()) if (matches(target.slice(1), item.plugin.id)) item.enabled = false
|
|
|
continue
|
|
|
}
|
|
|
|
|
|
- const selected = Object.keys(store.registrations).filter((id) => matches(target, id))
|
|
|
+ const selected = [...desired.values()].filter((item) => matches(target, item.plugin.id))
|
|
|
if (selected.length || target === "*" || target.endsWith(".*") || target.startsWith("opencode.")) {
|
|
|
- for (const id of selected) await activate(id)
|
|
|
+ for (const item of selected) item.enabled = true
|
|
|
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,
|
|
|
- ),
|
|
|
- )
|
|
|
+ // Watch even when the resolve below fails so fixing a broken plugin reloads it.
|
|
|
+ const local = localSource(target, directory)
|
|
|
+ if (local) watcher.add(fileURLToPath(local))
|
|
|
+ const previous = Object.values(store.registrations).find((registration) => registration.target === target)
|
|
|
+ const memo = local ? undefined : npmFailures.get(target)
|
|
|
+ const resolved = memo
|
|
|
+ ? { status: "failed" as const, error: memo }
|
|
|
+ : await resolvePlugin(target, local, options, previous, props.packages).catch((error) => ({
|
|
|
+ status: "failed" as const,
|
|
|
+ error: errorMessage(error),
|
|
|
+ }))
|
|
|
+ if (resolved.status === "unsupported") {
|
|
|
+ failures.push({ target, status: "unsupported" })
|
|
|
continue
|
|
|
}
|
|
|
-
|
|
|
- setStore("registrations", plugin.id, {
|
|
|
- plugin,
|
|
|
+ if (resolved.status === "failed") {
|
|
|
+ if (!local && !previous) npmFailures.set(target, resolved.error)
|
|
|
+ failures.push({
|
|
|
+ target,
|
|
|
+ status: "failed",
|
|
|
+ error: previous?.active ? `${resolved.error} (previous version still active)` : resolved.error,
|
|
|
+ })
|
|
|
+ if (previous)
|
|
|
+ desired.set(previous.plugin.id, {
|
|
|
+ plugin: previous.plugin,
|
|
|
+ source: previous.source,
|
|
|
+ target,
|
|
|
+ version: previous.version,
|
|
|
+ options: previous.options,
|
|
|
+ enabled: previous.active,
|
|
|
+ })
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ desired.set(resolved.plugin.id, {
|
|
|
+ plugin: resolved.plugin,
|
|
|
source: "external",
|
|
|
+ target,
|
|
|
+ version: resolved.version,
|
|
|
options,
|
|
|
- active: false,
|
|
|
- routes: {},
|
|
|
- slots: {},
|
|
|
- cleanups: [],
|
|
|
+ enabled: true,
|
|
|
})
|
|
|
- const error = await activate(plugin.id).then(
|
|
|
- () => undefined,
|
|
|
- (error) => (error instanceof Error ? error.message : String(error)),
|
|
|
+ }
|
|
|
+
|
|
|
+ // Compare: unchanged plugins are never touched, and a fully unchanged
|
|
|
+ // generation is a no-op, so spurious watch events cost nothing.
|
|
|
+ const currentIds = Object.keys(store.registrations)
|
|
|
+ const desiredIds = [...desired.keys()]
|
|
|
+ const structural = currentIds.length !== desiredIds.length || currentIds.some((id, index) => desiredIds[index] !== id)
|
|
|
+ if (structural) {
|
|
|
+ await Promise.all(
|
|
|
+ Object.entries(store.registrations)
|
|
|
+ .filter(([, registration]) => registration.active)
|
|
|
+ .map(([id]) => deactivateNoisily(id)),
|
|
|
)
|
|
|
- setStore("states", (items) => [
|
|
|
- ...items.filter((state) => state.target !== target && (!("id" in state) || state.id !== plugin.id)),
|
|
|
- error
|
|
|
- ? { target, status: "failed", error }
|
|
|
- : { target, id: plugin.id, status: "active" },
|
|
|
- ])
|
|
|
+ setStore("registrations", reconcileStore({}))
|
|
|
}
|
|
|
+ const changed = structural
|
|
|
+ ? desiredIds
|
|
|
+ : desiredIds.filter((id) => {
|
|
|
+ const registration = store.registrations[id]!
|
|
|
+ const item = desired.get(id)!
|
|
|
+ // enabled derives from config directives alone, so config wins over
|
|
|
+ // manual dialog toggles on every reconcile — the same semantics
|
|
|
+ // config saves had before hot reload existed, just more frequent.
|
|
|
+ return (
|
|
|
+ registration.version !== item.version ||
|
|
|
+ !sameOptions(registration.options, item.options) ||
|
|
|
+ registration.active !== item.enabled
|
|
|
+ )
|
|
|
+ })
|
|
|
+
|
|
|
+ // Swap: cleanup failures surface as a toast, never propagate, so one
|
|
|
+ // broken plugin cannot take the rest of the generation down.
|
|
|
+ const errors = new Map<string, string>()
|
|
|
+ for (const id of changed) {
|
|
|
+ const item = desired.get(id)!
|
|
|
+ const registration = store.registrations[id]
|
|
|
+ const replaced =
|
|
|
+ !registration || registration.version !== item.version || !sameOptions(registration.options, item.options)
|
|
|
+ // Snapshot the running version before it is overwritten: an import
|
|
|
+ // failure keeps last-good in the resolve phase, and a setup failure
|
|
|
+ // must not cost the previous version either.
|
|
|
+ const fallback: Desired | undefined =
|
|
|
+ replaced && registration
|
|
|
+ ? {
|
|
|
+ plugin: registration.plugin,
|
|
|
+ source: registration.source,
|
|
|
+ target: registration.target,
|
|
|
+ version: registration.version,
|
|
|
+ options: registration.options,
|
|
|
+ enabled: registration.active,
|
|
|
+ }
|
|
|
+ : undefined
|
|
|
+ if (replaced) {
|
|
|
+ if (registration) await deactivateNoisily(id)
|
|
|
+ // In-place replacement keeps the registration's key position, which
|
|
|
+ // slot ordering (mode "replace" takes the last one) depends on.
|
|
|
+ setStore("registrations", id, toRegistration(item))
|
|
|
+ }
|
|
|
+ if (!item.enabled) {
|
|
|
+ await deactivateNoisily(id)
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ const error = await activate(id).then(() => undefined, errorMessage)
|
|
|
+ if (!error) continue
|
|
|
+ errors.set(id, error)
|
|
|
+ if (!fallback) continue
|
|
|
+ setStore("registrations", id, toRegistration(fallback))
|
|
|
+ if (!fallback.enabled) continue
|
|
|
+ const restored = await activate(id).then(
|
|
|
+ () => true,
|
|
|
+ () => false,
|
|
|
+ )
|
|
|
+ if (restored) errors.set(id, `${error} (previous version still active)`)
|
|
|
+ }
|
|
|
+
|
|
|
+ const failedTargets = new Set(failures.map((failure) => failure.target))
|
|
|
+ const states: State[] = [
|
|
|
+ ...[...desired.values()].flatMap((item): State[] => {
|
|
|
+ if (item.target === undefined) return []
|
|
|
+ // A failed reload keeps this item running; the failure entry covers it.
|
|
|
+ if (failedTargets.has(item.target)) return []
|
|
|
+ const error = errors.get(item.plugin.id)
|
|
|
+ if (error) return [{ target: item.target, status: "failed", error }]
|
|
|
+ const status = store.registrations[item.plugin.id]?.active ? "active" : "inactive"
|
|
|
+ return [{ target: item.target, id: item.plugin.id, status }]
|
|
|
+ }),
|
|
|
+ ...failures,
|
|
|
+ ]
|
|
|
+ // Surface newly failing plugins; repeated reconciles stay silent.
|
|
|
+ for (const state of states)
|
|
|
+ if (
|
|
|
+ state.status === "failed" &&
|
|
|
+ !store.states.some((prev) => prev.status === "failed" && prev.target === state.target && prev.error === state.error)
|
|
|
+ )
|
|
|
+ host.toast.show({ variant: "error", title: "Plugin", message: `${state.target}: ${state.error}` })
|
|
|
+ setStore("states", reconcileStore(states))
|
|
|
}
|
|
|
- let loading = Promise.resolve()
|
|
|
+ const slotItems = new WeakMap<Slot, { readonly id: string; readonly render: Slot }>()
|
|
|
createEffect(
|
|
|
on(
|
|
|
() => JSON.stringify(config.data.plugins ?? []),
|
|
|
() => {
|
|
|
- const configured = config.data.plugins ?? []
|
|
|
- loading = loading.catch(() => undefined).then(() => reconcile(configured))
|
|
|
- void loading.then(
|
|
|
+ npmFailures.clear()
|
|
|
+ void enqueue(reconcile).then(
|
|
|
() => setStore("ready", true),
|
|
|
() => setStore("ready", true),
|
|
|
)
|
|
|
@@ -480,7 +369,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
|
|
|
Promise.all(
|
|
|
Object.entries(store.registrations)
|
|
|
.filter(([, registration]) => registration.active)
|
|
|
- .map(([id]) => deactivate(id)),
|
|
|
+ .map(([id]) => deactivate(id).catch(() => undefined)),
|
|
|
),
|
|
|
)
|
|
|
.then(() => setStore("registrations", reconcileStore({})))
|
|
|
@@ -502,11 +391,22 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
|
|
|
Object.entries(store.registrations).map(([id, plugin]) => ({ id, source: plugin.source, active: plugin.active })),
|
|
|
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,
|
|
|
+ Object.entries(store.registrations).flatMap(([id, registration]) => {
|
|
|
+ const render = registration.active ? registration.slots[name] : undefined
|
|
|
+ if (!render) return []
|
|
|
+ // <For> diffs rows by reference; a stable wrapper per render
|
|
|
+ // function keeps untouched plugins' slot rows (and their state)
|
|
|
+ // alive across other plugins' reloads.
|
|
|
+ const cached = slotItems.get(render)
|
|
|
+ if (cached) return [cached]
|
|
|
+ const item = { id, render }
|
|
|
+ slotItems.set(render, item)
|
|
|
+ return [item]
|
|
|
+ }),
|
|
|
+ // Manual dialog toggles join the same chain as reconciles so a
|
|
|
+ // toggle mid-reload cannot mix registrations across generations.
|
|
|
+ activate: (id) => enqueue(() => activate(id)),
|
|
|
+ deactivate: (id) => enqueue(() => deactivate(id)),
|
|
|
}}
|
|
|
>
|
|
|
{props.children}
|
|
|
@@ -533,17 +433,45 @@ 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
|
|
|
+async function resolvePlugin(
|
|
|
+ spec: string,
|
|
|
+ local: URL | undefined,
|
|
|
+ options: Readonly<Record<string, any>> | undefined,
|
|
|
+ previous: Registration | undefined,
|
|
|
+ packages: PackageResolver,
|
|
|
+) {
|
|
|
+ // Package entrypoints never change within a session, so a loaded previous
|
|
|
+ // version needs no re-resolution (which could otherwise hit npm).
|
|
|
+ if (!local && previous && sameOptions(previous.options, options))
|
|
|
+ return { status: "unchanged" as const, plugin: previous.plugin, version: previous.version }
|
|
|
const entrypoint = local ? await resolveLocal(local) : await packages.resolve(spec)
|
|
|
- if (!entrypoint) return
|
|
|
- const mod: { readonly default?: unknown } = await import(entrypoint)
|
|
|
+ if (!entrypoint) return { status: "unsupported" as const }
|
|
|
+ // The cache-busted specifier doubles as the version: unique per entrypoint
|
|
|
+ // and mtime, so equal versions mean an identical module.
|
|
|
+ const version = local ? freshSpecifier(entrypoint, (await stat(new URL(entrypoint))).mtimeMs) : entrypoint
|
|
|
+ if (previous && previous.version === version && sameOptions(previous.options, options))
|
|
|
+ return { status: "unchanged" as const, plugin: previous.plugin, version }
|
|
|
+ const mod: { readonly default?: unknown } = await import(version)
|
|
|
if (!isPlugin(mod.default)) throw new Error(`Invalid V2 TUI plugin module: ${spec}`)
|
|
|
- return mod.default
|
|
|
+ return { status: "loaded" as const, plugin: mod.default, version }
|
|
|
+}
|
|
|
+
|
|
|
+function toRegistration(item: Desired): Registration {
|
|
|
+ return {
|
|
|
+ plugin: item.plugin,
|
|
|
+ source: item.source,
|
|
|
+ target: item.target,
|
|
|
+ version: item.version,
|
|
|
+ options: item.options,
|
|
|
+ active: false,
|
|
|
+ routes: {},
|
|
|
+ slots: {},
|
|
|
+ cleanups: [],
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function sameOptions(a: Registration["options"], b: Registration["options"]) {
|
|
|
+ return isDeepEqual(a ?? null, b ?? null)
|
|
|
}
|
|
|
|
|
|
async function resolveLocal(url: URL) {
|
|
|
@@ -578,40 +506,3 @@ export function usePlugin() {
|
|
|
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 createComponent(render, { data: route.data.data })
|
|
|
- })
|
|
|
- return <>{content()}</>
|
|
|
-}
|
|
|
-
|
|
|
-export function PluginSlot<Name extends SlotName>(props: {
|
|
|
- readonly name: Name
|
|
|
- readonly input: SlotMap[Name]
|
|
|
- readonly mode: "all" | "replace"
|
|
|
-}) {
|
|
|
- const plugins = usePlugin()
|
|
|
- const renderers = createMemo(() => {
|
|
|
- const items = plugins.slot(props.name)
|
|
|
- if (props.mode === "replace") return items.slice(-1)
|
|
|
- return items
|
|
|
- })
|
|
|
- return (
|
|
|
- <For each={renderers()}>
|
|
|
- {(render) =>
|
|
|
- // Component semantics: the render body runs once and untracked, so
|
|
|
- // signals and intervals created inside are stable, while props stay
|
|
|
- // reactive through the merged getter. A bare render(props.input) call
|
|
|
- // would run inside the host's tracked scope and re-execute the whole
|
|
|
- // body (resetting plugin state) on every tracked read.
|
|
|
- createComponent(render, mergeProps(() => props.input) as SlotMap[Name])
|
|
|
- }
|
|
|
- </For>
|
|
|
- )
|
|
|
-}
|