fix(tui): deduplicate plugin targets (#49112)

This commit is contained in:
Kit Langton
2026-09-15 18:03:58 -07:00
committed by GitHub
parent 370b9d484a
commit 73bbeb7ce1
3 changed files with 58 additions and 19 deletions
+23 -18
View File
@@ -29,7 +29,7 @@ import { useData } from "../context/data"
import { errorMessage } from "../util/error"
import { createPluginContext, usePluginHost, type Dispose, type RegisteredSlot, type SlotRender } from "./api"
import { createSourceWatcher } from "./watch"
import { discoverPluginTargets, localSource } from "./discovery"
import { discoverPluginTargets, localSource, mergePluginTargets } from "./discovery"
import { createPluginSources } from "./source"
import { isMissingPath } from "../util/config-directories"
import { createMarkdownRenderer } from "./markdown"
@@ -285,23 +285,28 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir
await trace("watch", { reconciliation: id, directories: props.directories }, () =>
Promise.all(props.directories.map(watcher.wait)).then(() => undefined),
)
const entries = [
...(
await trace("discover", { reconciliation: id, directories: props.directories }, () =>
discoverPluginTargets(props.directories),
)
).map((entry) => ({
entry,
install: true,
optional: true,
})),
...serverTuiPlugins().map((plugin) => ({
entry: plugin.source.type === "package" ? plugin.source.target : path.dirname(plugin.source.path),
install: false,
optional: true,
})),
...(config.data.plugins ?? []).map((entry) => ({ entry, install: true, optional: false })),
]
// Discovery admits TUI-only plugins while server inventory carries combined plugins.
// Their overlap is intentional; explicit configuration remains the final authority.
const entries = mergePluginTargets(
[
...(
await trace("discover", { reconciliation: id, directories: props.directories }, () =>
discoverPluginTargets(props.directories),
)
).map((entry) => ({
entry,
install: true,
optional: true,
})),
...serverTuiPlugins().map((plugin) => ({
entry: plugin.source.type === "package" ? plugin.source.target : path.dirname(plugin.source.path),
install: false,
optional: true,
})),
...(config.data.plugins ?? []).map((entry) => ({ entry, install: true, optional: false })),
],
directory,
)
// Resolve: fold entries into one desired generation. A source that fails
// to import keeps its running previous version and only reports failure.
+17 -1
View File
@@ -1,8 +1,10 @@
import { readdir, stat } from "node:fs/promises"
import path from "node:path"
export { localSource } from "@opencode/plugin/source"
import { localSource } from "@opencode/plugin/source"
import { isMissingPath, localProjectDirectory, projectConfigDirectories } from "../util/config-directories"
export { localSource }
export async function localPluginDirectories(cwd: string, configDirectory: string) {
const projectDirectory = await localProjectDirectory(cwd)
const projectConfig = path.join(projectDirectory, ".opencode")
@@ -49,3 +51,17 @@ export async function discoverPluginTargets(directories: string[]) {
)
).flat()
}
export function mergePluginTargets<const Target extends { readonly entry: string | { readonly package: string } }>(
targets: readonly Target[],
directory: string,
) {
return [
...targets
.reduce((result, target) => {
const value = typeof target.entry === "string" ? target.entry : target.entry.package
return result.set(localSource(value, directory)?.href ?? value, target)
}, new Map<string, Target>())
.values(),
]
}
@@ -0,0 +1,18 @@
import { expect, test } from "bun:test"
import { mergePluginTargets } from "../src/plugin/discovery"
test("deduplicates equivalent local plugin targets while retaining the final source", () => {
const directory = "/project"
const discovered = { entry: "/project/.opencode/plugins/example", install: true, optional: true }
const server = { entry: "./.opencode/plugins/example", install: false, optional: true }
expect(mergePluginTargets([discovered, server], directory)).toEqual([server])
})
test("preserves the first target position when a later source overrides it", () => {
const first = { entry: "example", install: true, optional: true }
const other = { entry: "other", install: true, optional: true }
const configured = { entry: { package: "example", options: { enabled: true } }, install: true, optional: false }
expect(mergePluginTargets([first, other, configured], "/project")).toEqual([configured, other])
})