fix(tui): watch newly created plugin directory
This commit is contained in:
parent
6a543791b6
commit
55b2bb8a3a
2 changed files with 63 additions and 5 deletions
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue