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
|
// directories stay quiet. Symlinked files are additionally watched at their
|
||||||
// resolved target, since edits there emit nothing at the link's location.
|
// resolved target, since edits there emit nothing at the link's location.
|
||||||
// Directory targets are watched at their root only: edits to nested helper
|
// 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
|
// files do not change the entrypoint mtime and are not detected. A missing
|
||||||
// never torn down individually (a stale watch costs one fs handle and a
|
// target temporarily watches its nearest existing parent, filtered to the
|
||||||
// spurious onChange); all die with dispose(). Failed or vanished watches are
|
// first missing path segment, until the normal source watch can take over.
|
||||||
// forgotten so a later add() can re-arm once the path exists.
|
// 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) {
|
export function createSourceWatcher(onChange: () => void) {
|
||||||
const watchers = new Map<string, ReturnType<typeof watch>>()
|
const watchers = new Map<string, ReturnType<typeof watch>>()
|
||||||
const watched = new Map<string, Set<string> | null>()
|
const watched = new Map<string, Set<string> | null>()
|
||||||
|
const missing = new Map<string, { dir: string; watcher: ReturnType<typeof watch> }>()
|
||||||
let disposed = false
|
let disposed = false
|
||||||
const forget = (dir: string) => {
|
const forget = (dir: string) => {
|
||||||
watchers.get(dir)?.close()
|
watchers.get(dir)?.close()
|
||||||
watchers.delete(dir)
|
watchers.delete(dir)
|
||||||
watched.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) => {
|
const arm = (target: string) => {
|
||||||
stat(target)
|
stat(target)
|
||||||
.then((info) => {
|
.then((info) => {
|
||||||
if (disposed) return
|
if (disposed) return
|
||||||
|
forgetMissing(target)
|
||||||
const dir = info.isDirectory() ? target : path.dirname(target)
|
const dir = info.isDirectory() ? target : path.dirname(target)
|
||||||
// Directories accept every filename (null); files accept their basename.
|
// Directories accept every filename (null); files accept their basename.
|
||||||
const name = info.isDirectory() ? null : path.basename(target)
|
const name = info.isDirectory() ? null : path.basename(target)
|
||||||
|
|
@ -55,7 +78,7 @@ export function createSourceWatcher(onChange: () => void) {
|
||||||
watcher.on("error", () => forget(dir))
|
watcher.on("error", () => forget(dir))
|
||||||
watchers.set(dir, watcher)
|
watchers.set(dir, watcher)
|
||||||
})
|
})
|
||||||
.catch(() => undefined)
|
.catch(() => armMissing(target))
|
||||||
}
|
}
|
||||||
const add = (target: string) => {
|
const add = (target: string) => {
|
||||||
arm(target)
|
arm(target)
|
||||||
|
|
@ -70,6 +93,14 @@ export function createSourceWatcher(onChange: () => void) {
|
||||||
const dispose = () => {
|
const dispose = () => {
|
||||||
disposed = true
|
disposed = true
|
||||||
for (const watcher of watchers.values()) watcher.close()
|
for (const watcher of watchers.values()) watcher.close()
|
||||||
|
for (const item of missing.values()) item.watcher.close()
|
||||||
}
|
}
|
||||||
return { add, dispose }
|
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 {
|
return {
|
||||||
task,
|
task,
|
||||||
|
renderer: setup,
|
||||||
async [Symbol.asyncDispose]() {
|
async [Symbol.asyncDispose]() {
|
||||||
process.chdir(cwd)
|
process.chdir(cwd)
|
||||||
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
|
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
|
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 () => {
|
test("a plugin whose slot render throws does not take down the TUI", async () => {
|
||||||
await using tmp = await tmpdir()
|
await using tmp = await tmpdir()
|
||||||
const directory = path.join(tmp.path, ".opencode", "plugins", "tui")
|
const directory = path.join(tmp.path, ".opencode", "plugins", "tui")
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue