diff --git a/packages/tui/src/plugin/watch.ts b/packages/tui/src/plugin/watch.ts index f58b866f5b..54c916f647 100644 --- a/packages/tui/src/plugin/watch.ts +++ b/packages/tui/src/plugin/watch.ts @@ -8,23 +8,46 @@ import { lstat, realpath, stat } from "fs/promises" // directories stay quiet. Symlinked files are additionally watched at their // resolved target, since edits there emit nothing at the link's location. // 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. +// files do not change the entrypoint mtime and are not detected. A missing +// target temporarily watches its nearest existing parent, filtered to the +// first missing path segment, until the normal source watch can take over. +// Established source watches die with dispose(). Failed or vanished watches +// are forgotten so a later add() can re-arm once the path exists. export function createSourceWatcher(onChange: () => void) { const watchers = new Map>() const watched = new Map | null>() + const missing = new Map }>() let disposed = false const forget = (dir: string) => { watchers.get(dir)?.close() watchers.delete(dir) watched.delete(dir) } + const forgetMissing = (target: string) => { + missing.get(target)?.watcher.close() + missing.delete(target) + } + const armMissing = (target: string) => { + if (disposed) return + const dir = nearestExistingParent(target) + if (!dir) return + if (missing.get(target)?.dir === dir) return + forgetMissing(target) + const name = path.relative(dir, target).split(path.sep)[0]! + const watcher = watch(dir, (_event, filename) => { + if (filename && filename.toString().split(path.sep)[0] !== name) return + forgetMissing(target) + arm(target) + onChange() + }) + watcher.on("error", () => forgetMissing(target)) + missing.set(target, { dir, watcher }) + } const arm = (target: string) => { stat(target) .then((info) => { if (disposed) return + forgetMissing(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) @@ -55,7 +78,7 @@ export function createSourceWatcher(onChange: () => void) { watcher.on("error", () => forget(dir)) watchers.set(dir, watcher) }) - .catch(() => undefined) + .catch(() => armMissing(target)) } const add = (target: string) => { arm(target) @@ -70,6 +93,14 @@ export function createSourceWatcher(onChange: () => void) { const dispose = () => { disposed = true for (const watcher of watchers.values()) watcher.close() + for (const item of missing.values()) item.watcher.close() } return { add, dispose } } + +function nearestExistingParent(target: string) { + const dir = path.dirname(target) + if (existsSync(dir)) return dir + if (dir === path.dirname(dir)) return + return nearestExistingParent(dir) +} diff --git a/packages/tui/test/plugin-hot-reload.test.tsx b/packages/tui/test/plugin-hot-reload.test.tsx index 4baa380121..e18394930f 100644 --- a/packages/tui/test/plugin-hot-reload.test.tsx +++ b/packages/tui/test/plugin-hot-reload.test.tsx @@ -53,6 +53,7 @@ async function bootApp(directory: string) { ) return { task, + renderer: setup, async [Symbol.asyncDispose]() { process.chdir(cwd) if (!setup.renderer.isDestroyed) setup.renderer.destroy() @@ -81,6 +82,32 @@ test("editing a discovered TUI plugin hot-reloads its fresh module", async () => await app.task }) +test("creating the TUI plugin directory after startup discovers its first plugin", async () => { + await using tmp = await tmpdir() + const directory = path.join(tmp.path, ".opencode", "plugins", "tui") + await mkdir(path.dirname(directory), { recursive: true }) + const marker = path.join(tmp.path, "marker.txt") + const placeholders = ["Fix a TODO in the codebase", "What is the tech stack of this project?", "Fix broken tests"] + + await using app = await bootApp(tmp.path) + const frame = await until( + async () => { + await app.renderer.renderOnce() + return app.renderer.captureCharFrame() + }, + (value) => placeholders.some((text) => value?.includes(text)), + ) + expect(placeholders.some((text) => frame?.includes(text))).toBe(true) + await mkdir(directory) + await writeFile(path.join(directory, "hot.ts"), lifecycleSource(marker, "test.hot", "v1")) + + const read = () => readFile(marker, "utf8") + expect(await until(read, (value) => value === "v1:setup\n")).toBe("v1:setup\n") + + process.emit("SIGHUP") + await app.task +}) + test("a plugin whose slot render throws does not take down the TUI", async () => { await using tmp = await tmpdir() const directory = path.join(tmp.path, ".opencode", "plugins", "tui")