parallel pre-load

actual-tui-plugins
Sebastian Herrlinger 2026-03-09 14:32:50 +01:00
parent 3341dba46e
commit 5c95616579
2 changed files with 79 additions and 55 deletions

View File

@ -193,14 +193,14 @@ export namespace TuiPlugin {
await deps
}
const loadOne = async (item: (typeof plugins)[number], retry = false) => {
const prep = async (item: (typeof plugins)[number], retry = false) => {
const spec = Config.pluginSpecifier(item)
log.info("loading tui plugin", { path: spec, retry })
const target = await resolvePluginTarget(spec).catch((error) => {
log.error("failed to resolve tui plugin", { path: spec, retry, error })
return
})
if (!target) return false
if (!target) return
const meta = await PluginMeta.touch(spec, target).catch((error) => {
log.warn("failed to track tui plugin", { path: spec, retry, error })
})
@ -217,62 +217,74 @@ export namespace TuiPlugin {
const root = pluginRoot(spec, target)
const install = makeInstallFn(getPluginMeta(config, item), root)
const mod = await import(target).catch((error) => {
log.error("failed to load tui plugin", { path: spec, retry, error })
return
})
if (!mod) return false
if (!mod) return
for (const [name, entry] of uniqueModuleEntries(mod)) {
if (!entry || typeof entry !== "object") {
log.warn("ignoring non-object tui plugin export", {
path: spec,
name,
type: entry === null ? "null" : typeof entry,
})
continue
}
const slotPlugin = getTuiSlotPlugin(entry)
if (slotPlugin) input.slots.register(slotPlugin)
const tuiPlugin = getTuiPlugin(entry)
if (!tuiPlugin) continue
await tuiPlugin(
{
...input,
api: {
command: input.api.command,
route: input.api.route,
ui: input.api.ui,
keybind: input.api.keybind,
theme: Object.create(input.api.theme, {
install: {
value: install,
configurable: true,
enumerable: true,
},
}),
},
},
Config.pluginOptions(item),
)
return {
item,
spec,
mod,
install,
}
return true
}
try {
for (const item of plugins) {
const ok = await loadOne(item)
if (ok) continue
const loaded = await Promise.all(plugins.map((item) => prep(item)))
const spec = Config.pluginSpecifier(item)
if (!spec.startsWith("file://")) continue
for (let i = 0; i < plugins.length; i++) {
let load = loaded[i]
if (!load) {
const item = plugins[i]
if (!item) continue
const spec = Config.pluginSpecifier(item)
if (!spec.startsWith("file://")) continue
await wait()
load = await prep(item, true)
}
if (!load) continue
await wait()
await loadOne(item, true)
// Keep plugin execution sequential for deterministic side effects:
// command registration order affects keybind/command precedence,
// route registration is last-wins when ids collide,
// and hook chains rely on stable plugin ordering.
for (const [name, value] of uniqueModuleEntries(load.mod)) {
if (!value || typeof value !== "object") {
log.warn("ignoring non-object tui plugin export", {
path: load.spec,
name,
type: value === null ? "null" : typeof value,
})
continue
}
const slotPlugin = getTuiSlotPlugin(value)
if (slotPlugin) input.slots.register(slotPlugin)
const tuiPlugin = getTuiPlugin(value)
if (!tuiPlugin) continue
await tuiPlugin(
{
...input,
api: {
command: input.api.command,
route: input.api.route,
ui: input.api.ui,
keybind: input.api.keybind,
theme: Object.create(input.api.theme, {
install: {
value: load.install,
configurable: true,
enumerable: true,
},
}),
},
},
Config.pluginOptions(load.item),
)
}
}
} finally {
await PluginMeta.persist().catch((error) => {

View File

@ -82,13 +82,13 @@ export namespace Plugin {
return value.server
}
for (const item of plugins) {
const prep = async (item: (typeof plugins)[number]) => {
const spec = Config.pluginSpecifier(item)
// ignore old codex plugin since it is supported first party now
if (spec.includes("opencode-openai-codex-auth") || spec.includes("opencode-copilot-auth")) continue
if (spec.includes("opencode-openai-codex-auth") || spec.includes("opencode-copilot-auth")) return
log.info("loading plugin", { path: spec })
const target = await resolvePlugin(spec)
if (!target) continue
if (!target) return
const mod = await import(target).catch((err) => {
const message = err instanceof Error ? err.message : String(err)
log.error("failed to load plugin", { path: spec, error: message })
@ -99,23 +99,35 @@ export namespace Plugin {
})
return
})
if (!mod) continue
if (!mod) return
return {
item,
spec,
mod,
}
}
const loaded = await Promise.all(plugins.map((item) => prep(item)))
for (const load of loaded) {
if (!load) continue
// Keep plugin execution sequential so hook registration and execution
// order remains deterministic across plugin runs.
// Prevent duplicate initialization when plugins export the same function
// as both a named export and default export (e.g., `export const X` and `export default X`).
// uniqueModuleEntries keeps only the first export for each shared value reference.
await (async () => {
for (const [, entry] of uniqueModuleEntries(mod)) {
for (const [, entry] of uniqueModuleEntries(load.mod)) {
const server = getServerPlugin(entry)
if (!server) throw new TypeError("Plugin export is not a function")
hooks.push(await server(input, Config.pluginOptions(item)))
hooks.push(await server(input, Config.pluginOptions(load.item)))
}
})().catch((err) => {
const message = err instanceof Error ? err.message : String(err)
log.error("failed to load plugin", { path: spec, error: message })
log.error("failed to load plugin", { path: load.spec, error: message })
Bus.publish(Session.Event.Error, {
error: new NamedError.Unknown({
message: `Failed to load plugin ${spec}: ${message}`,
message: `Failed to load plugin ${load.spec}: ${message}`,
}).toObject(),
})
})