Compare commits

...

1 Commits

Author SHA1 Message Date
Kit Langton 55b2bb8a3a fix(tui): watch newly created plugin directory 2026-07-31 20:58:53 -04:00
2 changed files with 63 additions and 5 deletions
+36 -5
View File
@@ -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<string, ReturnType<typeof watch>>()
const watched = new Map<string, Set<string> | null>()
const missing = new Map<string, { dir: string; watcher: ReturnType<typeof watch> }>()
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)
}
@@ -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")