diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 29864cf7b3..42aa2a7d7e 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -378,7 +378,10 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { - + Promise @@ -57,11 +57,12 @@ type Desired = Pick() -export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>) { +export function PluginProvider(props: ParentProps<{ packages: PackageResolver; configDirectory: string }>) { const host = usePluginHost() const config = useConfig() const lifecycle = useTuiLifecycle() const directory = config.path ? path.dirname(config.path) : process.cwd() + const pluginDirectories = tuiPluginDirectories(host.paths.cwd, props.configDirectory) const [store, setStore] = createStore({ ready: false, states: [] as ReadonlyArray, @@ -186,8 +187,8 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }> // every watch event; remember them until the configuration changes. const npmFailures = new Map() const reconcile = async () => { - const entries = [...(await discoverTuiPlugins(host.paths.cwd)), ...(config.data.plugins ?? [])] - watcher.add(tuiPluginDirectory(host.paths.cwd)) + const entries = [...(await discoverTuiPlugins(pluginDirectories)), ...(config.data.plugins ?? [])] + pluginDirectories.forEach((directory) => watcher.add(directory, true)) // Resolve: fold entries into one desired generation. A source that fails // to import keeps its running previous version and only reports failure. diff --git a/packages/tui/src/plugin/discovery.ts b/packages/tui/src/plugin/discovery.ts index b1589c7d22..733089abb2 100644 --- a/packages/tui/src/plugin/discovery.ts +++ b/packages/tui/src/plugin/discovery.ts @@ -4,20 +4,33 @@ import { fileURLToPath, pathToFileURL } from "node:url" const extensions = new Set([".cjs", ".cts", ".js", ".jsx", ".mjs", ".mts", ".ts", ".tsx"]) -export function tuiPluginDirectory(cwd: string) { - return path.join(cwd, ".opencode", "plugins", "tui") +export function tuiPluginDirectories(cwd: string, configDirectory: string) { + const ancestors: string[] = [] + let current = path.resolve(cwd) + while (true) { + ancestors.push(path.join(current, ".opencode", "plugins", "tui")) + const parent = path.dirname(current) + if (parent === current) break + current = parent + } + return [...new Set([path.join(configDirectory, "plugins", "tui"), ...ancestors.reverse()])] } -export async function discoverTuiPlugins(cwd: string) { - const directory = tuiPluginDirectory(cwd) - const entries = await readdir(directory, { withFileTypes: true }).catch((error: unknown) => { - if (error && typeof error === "object" && Reflect.get(error, "code") === "ENOENT") return [] - return Promise.reject(error) - }) - return entries - .filter((entry) => (entry.isFile() || entry.isSymbolicLink()) && extensions.has(path.extname(entry.name))) - .map((entry) => path.join(directory, entry.name)) - .sort() +export async function discoverTuiPlugins(directories: string[]) { + return ( + await Promise.all( + directories.map(async (directory) => { + const entries = await readdir(directory, { withFileTypes: true }).catch((error: unknown) => { + if (error && typeof error === "object" && Reflect.get(error, "code") === "ENOENT") return [] + return Promise.reject(error) + }) + return entries + .filter((entry) => (entry.isFile() || entry.isSymbolicLink()) && extensions.has(path.extname(entry.name))) + .map((entry) => path.join(directory, entry.name)) + .sort() + }), + ) + ).flat() } export function localSource(spec: string, directory: string) { diff --git a/packages/tui/src/plugin/watch.ts b/packages/tui/src/plugin/watch.ts index f58b866f5b..9e20face63 100644 --- a/packages/tui/src/plugin/watch.ts +++ b/packages/tui/src/plugin/watch.ts @@ -10,21 +10,24 @@ import { lstat, realpath, stat } from "fs/promises" // Directory targets are watched at their root only: edits to nested helper // files do not change the entrypoint mtime and are not detected. Watches are // never torn down individually (a stale watch costs one fs handle and a -// spurious onChange); all die with dispose(). Failed or vanished watches are -// forgotten so a later add() can re-arm once the path exists. +// spurious onChange); all die with dispose(). Missing or temporarily +// unwatchable targets are polled until they can be armed without relying on a +// racy chain of ancestor watches. export function createSourceWatcher(onChange: () => void) { const watchers = new Map>() const watched = new Map | null>() + const missing = new Set() let disposed = false const forget = (dir: string) => { watchers.get(dir)?.close() watchers.delete(dir) watched.delete(dir) } - const arm = (target: string) => { + const arm = (target: string, retry: boolean) => { stat(target) .then((info) => { if (disposed) return + const appeared = missing.delete(target) const dir = info.isDirectory() ? target : path.dirname(target) // Directories accept every filename (null); files accept their basename. const name = info.isDirectory() ? null : path.basename(target) @@ -32,9 +35,9 @@ export function createSourceWatcher(onChange: () => void) { if (existing !== undefined) { if (name === null) watched.set(dir, null) else existing?.add(name) + if (appeared) onChange() return } - watched.set(dir, name === null ? null : new Set([name])) const watcher = watch(dir, (_event, filename) => { // A replaced directory keeps this watcher on the dead inode (Linux // emits rename, not error); forget it so a later add() re-arms on @@ -49,27 +52,36 @@ export function createSourceWatcher(onChange: () => void) { if (filename && accept && !accept.has(filename.toString())) return onChange() }) - // A watched directory can disappear out from under us; without a - // listener the error event would crash the process. Forget the path - // so a later add can re-arm once it exists again. - watcher.on("error", () => forget(dir)) + watched.set(dir, name === null ? null : new Set([name])) + // Reconcile after watcher errors so every source is re-added and any + // temporarily unavailable target moves into the polling set. + watcher.on("error", () => { + forget(dir) + onChange() + }) watchers.set(dir, watcher) + if (appeared) onChange() + }) + .catch(() => { + if (retry) missing.add(target) }) - .catch(() => undefined) } - const add = (target: string) => { - arm(target) + const add = (target: string, retry = false) => { + arm(target, retry) // A symlinked source receives edits at its resolved target. lstat(target) .then((info) => { if (!info.isSymbolicLink()) return - return realpath(target).then(arm) + return realpath(target).then((target) => arm(target, retry)) }) .catch(() => undefined) } const dispose = () => { disposed = true + clearInterval(poll) for (const watcher of watchers.values()) watcher.close() } + const poll = setInterval(() => missing.forEach((target) => arm(target, true)), 500) + poll.unref() return { add, dispose } } diff --git a/packages/tui/test/plugin-discovery.test.ts b/packages/tui/test/plugin-discovery.test.ts index 3c3c85fcf9..4a518ecae2 100644 --- a/packages/tui/test/plugin-discovery.test.ts +++ b/packages/tui/test/plugin-discovery.test.ts @@ -1,7 +1,7 @@ import { mkdir, writeFile } from "node:fs/promises" import path from "node:path" import { expect, test } from "bun:test" -import { discoverTuiPlugins } from "../src/plugin/discovery" +import { discoverTuiPlugins, tuiPluginDirectories } from "../src/plugin/discovery" import { tmpdir } from "./fixture/fixture" test("discovers project TUI plugin files in stable order", async () => { @@ -15,7 +15,7 @@ test("discovers project TUI plugin files in stable order", async () => { writeFile(path.join(directory, "nested", "ignored.ts"), "export default {}"), ]) - expect(await discoverTuiPlugins(tmp.path)).toEqual([ + expect(await discoverTuiPlugins(tuiPluginDirectories(tmp.path, path.join(tmp.path, "config")))).toEqual([ path.join(directory, "first.js"), path.join(directory, "second.tsx"), ]) @@ -23,5 +23,25 @@ test("discovers project TUI plugin files in stable order", async () => { test("returns no project TUI plugins when the directory is absent", async () => { await using tmp = await tmpdir() - expect(await discoverTuiPlugins(tmp.path)).toEqual([]) + expect(await discoverTuiPlugins(tuiPluginDirectories(tmp.path, path.join(tmp.path, "config")))).toEqual([]) +}) + +test("discovers global and ancestor plugin roots in precedence order", async () => { + await using tmp = await tmpdir() + const cwd = path.join(tmp.path, "repo", "packages", "app") + const config = path.join(tmp.path, "config") + const directories = [ + path.join(config, "plugins", "tui"), + path.join(tmp.path, "repo", ".opencode", "plugins", "tui"), + path.join(tmp.path, "repo", "packages", ".opencode", "plugins", "tui"), + ] + await Promise.all(directories.map((directory) => mkdir(directory, { recursive: true }))) + await Promise.all( + directories.map((directory, index) => writeFile(path.join(directory, `${index}.ts`), "export default {}")), + ) + + expect(await discoverTuiPlugins(tuiPluginDirectories(cwd, config))).toEqual( + directories.map((directory, index) => path.join(directory, `${index}.ts`)), + ) + expect(tuiPluginDirectories(cwd, config)).toContain(path.join(cwd, ".opencode", "plugins", "tui")) }) diff --git a/packages/tui/test/plugin-hot-reload.test.tsx b/packages/tui/test/plugin-hot-reload.test.tsx index 4baa380121..14b4068f98 100644 --- a/packages/tui/test/plugin-hot-reload.test.tsx +++ b/packages/tui/test/plugin-hot-reload.test.tsx @@ -1,7 +1,6 @@ import { expect, mock, test } from "bun:test" import { createTestRenderer } from "@opentui/core/testing" import { Effect, FileSystem } from "effect" -import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { Global } from "@opencode-ai/util/global" import { mkdir, readFile, symlink, writeFile } from "node:fs/promises" import path from "node:path" @@ -49,7 +48,10 @@ async function bootApp(directory: string) { packages: { resolve: async () => undefined }, args: {}, log: () => {}, - }).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))), + }).pipe( + Effect.provide(Global.layerWith({ config: path.join(directory, ".global") })), + Effect.provide(FileSystem.layerNoop({})), + ), ) return { task, @@ -62,6 +64,28 @@ async function bootApp(directory: string) { } } +test("discovers an ancestor TUI plugin directory created after startup", async () => { + await using tmp = await tmpdir() + const cwd = path.join(tmp.path, "repo", "packages", "app") + await mkdir(cwd, { recursive: true }) + const marker = path.join(tmp.path, "marker.txt") + + await using app = await bootApp(cwd) + const directory = path.join(tmp.path, "repo", ".opencode", "plugins", "tui") + await mkdir(directory, { recursive: true }) + await writeFile(path.join(directory, "hot.ts"), lifecycleSource(marker, "test.hot", "v1")) + + expect( + await until( + () => readFile(marker, "utf8"), + (value) => value === "v1:setup\n", + ), + ).toBe("v1:setup\n") + + process.emit("SIGHUP") + await app.task +}) + test("editing a discovered TUI plugin hot-reloads its fresh module", async () => { await using tmp = await tmpdir() const directory = path.join(tmp.path, ".opencode", "plugins", "tui")