Compare commits

...
11 changed files with 690 additions and 210 deletions
+12 -6
View File
@@ -434,7 +434,7 @@ export function Titlebar(props: {
}}
>
<Show when={!mobile() && (!props.verticalTabs || windows())}>
<ChannelIndicator debugTools={props.debugTools} height={windows() ? minHeight() : undefined} />
<ChannelIndicator debugTools={props.debugTools} />
</Show>
<Show when={windows() || linux()}>
<WindowsAppMenu command={command} platform={platform} />
@@ -753,18 +753,23 @@ function TitlebarUpdateIconButton(props: { state: TitlebarUpdatePillState }) {
)
}
function ChannelIndicator(props: { debugTools?: { visible: boolean; toggle: () => void }; height?: string }) {
function ChannelIndicator(props: { debugTools?: { visible: boolean; toggle: () => void } }) {
const platform = usePlatform()
const windows = () => platform.platform === "desktop" && platform.os === "windows"
const classes = () => ({
"px-2 rounded-sm": windows(),
"inline-flex h-4 shrink-0 items-center leading-4 px-1.5 rounded-full": !windows(),
})
const style = () => ({
height: props.height,
"font-size": platform.platform === "desktop" && platform.os === "macos" ? "9px" : "10px",
"font-size": windows() ? undefined : platform.platform === "desktop" && platform.os === "macos" ? "9px" : "10px",
})
const channel = import.meta.env.VITE_OPENCODE_CHANNEL
if (channel === "dev" && props.debugTools) {
return (
<button
type="button"
class="inline-flex h-4 shrink-0 items-center bg-icon-interactive-base text-[#FFF] leading-4 font-medium px-1.5 rounded-full uppercase font-mono cursor-pointer [app-region:no-drag]"
class="bg-icon-interactive-base text-[#FFF] font-medium uppercase font-mono cursor-pointer [app-region:no-drag]"
classList={classes()}
style={style()}
onClick={props.debugTools.toggle}
aria-label="Toggle debug tools"
@@ -780,7 +785,8 @@ function ChannelIndicator(props: { debugTools?: { visible: boolean; toggle: () =
<Show when={label}>
{(value) => (
<div
class="inline-flex h-4 shrink-0 items-center bg-icon-interactive-base text-[#FFF] leading-4 font-medium px-1.5 rounded-full uppercase font-mono"
class="bg-icon-interactive-base text-[#FFF] font-medium uppercase font-mono"
classList={classes()}
style={style()}
>
{value()}
+46 -120
View File
@@ -4,7 +4,7 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import path from "path"
import { isDeepStrictEqual } from "node:util"
import { type ParseError, parse } from "jsonc-parser"
import { Context, Effect, Layer, Option, PubSub, Ref, Schema, Semaphore, Stream } from "effect"
import { Context, Effect, FiberMap, Layer, Option, PubSub, Ref, Schema, Semaphore, Stream } from "effect"
import {
AgentsDirectory,
ClaudeDirectory,
@@ -23,6 +23,8 @@ import { Location } from "./location.js"
import { AbsolutePath } from "./schema.js"
import { ConfigVariable } from "./config/variable.js"
import { ConfigNormalize } from "./config/normalize.js"
import { ConfigDiscovery } from "./config/discovery.js"
import { ConfigWatch } from "./config/watch.js"
import { WellKnown } from "./wellknown.js"
export function latest<K extends keyof Info>(entries: readonly Entry[], key: K): Info[K] | undefined {
@@ -83,15 +85,12 @@ export const layer = (options?: Options) =>
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const location = yield* Location.Service
const watcher = yield* Watcher.Service
const bus = yield* Bus.Service
const credentials = yield* Credential.Service
const wellknown = yield* WellKnown.Service
const names = ["opencode.json", "opencode.jsonc"]
const reloadLock = Semaphore.makeUnsafe(1)
const fileTargets = new Set<AbsolutePath>()
const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
const decodeInfo = Schema.decodeUnknownOption(Info, decodeOptions)
const parseInfo = Effect.fn("Config.parseInfo")(function* (text: string, source: string) {
@@ -177,90 +176,23 @@ export const layer = (options?: Options) =>
const loadDirectory = Effect.fnUntraced(function* (directory: AbsolutePath) {
return [
...(yield* Effect.forEach(names, (file) => loadFile(path.join(directory, file))).pipe(
...(yield* Effect.forEach(ConfigDiscovery.names, (file) => loadFile(path.join(directory, file))).pipe(
Effect.map((configs) => configs.filter((config): config is Document => config !== undefined)),
)),
new Directory({ type: "directory", path: directory }),
]
})
const discover = Effect.fn("Config.discover")(function* () {
const globalDirectory = AbsolutePath.make(global.config)
const globalAgentsDirectory = AbsolutePath.make(path.join(global.home, ".agents"))
const globalClaudeDirectory = AbsolutePath.make(path.join(global.home, ".claude"))
// Global roots and the walk are compared by canonical path: the same
// directory reached under two spellings (a symlinked checkout, macOS
// /var vs /private/var, OPENCODE_CONFIG_DIR inside the project) must
// classify identically or it enters discovery twice.
const globalRoots = yield* Effect.forEach(
[globalDirectory, globalClaudeDirectory, globalAgentsDirectory],
(item) => fs.resolve(item),
)
const locationIsGlobal = (yield* fs.resolve(location.directory)) === globalRoots[0]
const discovered =
locationIsGlobal || options?.project === false
? []
: yield* fs
.up({
targets: [".opencode", ".claude", ".agents", ...names.toReversed()],
start: location.directory,
})
.pipe(
Effect.flatMap((items) =>
Effect.forEach(items, (item) =>
fs.resolve(item).pipe(Effect.map((resolved) => ({ item, resolved }))),
),
),
Effect.orDie,
)
const globalEnabled = options?.global !== false
// A walked path that resolves into a global root is global config
// however the walk reached it (home above the project, or a location
// beneath the global config dir), so global: false excludes it
// uniformly — classified once here, not per consumer below. With
// global enabled, the roots themselves and the global config files are
// already loaded below, so the walk must not add them a second time.
const globalFiles = yield* Effect.forEach(names, (name) => fs.resolve(path.join(globalDirectory, name)))
const visible = discovered
.filter(({ resolved }) =>
globalEnabled
? !globalRoots.includes(resolved) && !globalFiles.includes(resolved)
: !globalRoots.some((root) => resolved === root || resolved.startsWith(root + path.sep)),
)
.map(({ item }) => item)
// We load certain files from a few other folders in the ecosystem
const claude = [
...new Set([
...(globalEnabled && (yield* fs.isDir(globalClaudeDirectory)) ? [globalClaudeDirectory] : []),
...visible.filter((item) => path.basename(item) === ".claude").toReversed(),
]),
].map((directory) => new ClaudeDirectory({ type: "claude", path: AbsolutePath.make(directory) }))
const agents = [
...new Set([
...(globalEnabled && (yield* fs.isDir(globalAgentsDirectory)) ? [globalAgentsDirectory] : []),
...visible.filter((item) => path.basename(item) === ".agents").toReversed(),
]),
].map((directory) => new AgentsDirectory({ type: "agents", path: AbsolutePath.make(directory) }))
const projectDirectories = visible
.filter((item) => path.basename(item) === ".opencode")
.toReversed()
.map((directory) => AbsolutePath.make(directory))
const directPaths = visible
.filter((item) => ![".agents", ".claude", ".opencode"].includes(path.basename(item)))
.toReversed()
fileTargets.clear()
directPaths.forEach((filepath) => fileTargets.add(AbsolutePath.make(filepath)))
const direct = yield* Effect.forEach(directPaths, (filepath) => loadFile(filepath)).pipe(
const load = Effect.fn("Config.load")(function* (sources: ConfigDiscovery.Sources) {
const claude = yield* Effect.filter(sources.claude, (path) => fs.isDir(path))
const agents = yield* Effect.filter(sources.agents, (path) => fs.isDir(path))
const direct = yield* Effect.forEach(sources.direct, (filepath) => loadFile(filepath)).pipe(
Effect.orDie,
Effect.map((entries) => entries.filter((entry): entry is Document => entry !== undefined)),
)
const file = options?.file
if (file) fileTargets.add(AbsolutePath.make(path.resolve(file)))
const explicit = file
? yield* loadFile(path.resolve(file)).pipe(
const explicit = sources.explicit
? yield* loadFile(sources.explicit).pipe(
Effect.map((config) => (config ? [config] : [])),
Effect.orDie,
)
@@ -281,15 +213,18 @@ export const layer = (options?: Options) =>
// Global entries sit below explicit and direct files; project
// directories rank above them.
const globalSupplementary = globalEnabled ? yield* loadDirectory(globalDirectory).pipe(Effect.orDie) : []
const projectSupplementary = yield* Effect.forEach(projectDirectories, loadDirectory).pipe(
const globalSupplementary = sources.global ? yield* loadDirectory(sources.global).pipe(Effect.orDie) : []
const projectSupplementary = yield* Effect.forEach(
sources.project.filter((root) => root.present),
(root) => loadDirectory(root.path),
).pipe(
Effect.orDie,
Effect.map((entries) => entries.flat()),
)
return [
...(yield* loadWellknown().pipe(Effect.orDie)),
...claude,
...agents,
...claude.map((path) => new ClaudeDirectory({ type: "claude", path })),
...agents.map((path) => new AgentsDirectory({ type: "agents", path })),
...globalSupplementary,
...explicit,
...direct,
@@ -298,44 +233,35 @@ export const layer = (options?: Options) =>
]
})
const initial = yield* discover()
let configs = initial
const initial = yield* ConfigDiscovery.discover(options)
let configs = yield* load(initial)
const updates = yield* PubSub.unbounded<Watcher.Update>()
// Vendored trees inside config roots (a plugin's node_modules, a nested
// .git) produce event blizzards that can never change discovery output.
const ignore = ["node_modules", ".git", "**/{node_modules,.git}/**"]
// Watch-once: roots leave discovery only by deletion, so a stale watch is
// inert, bounded, and dies with this layer — and keeping a deleted root's
// watch alive is exactly what makes its recreation observable.
const watched = new Set<string>()
const reconcile = Effect.fn("Config.reconcileWatches")(function* (entries: readonly Entry[]) {
const directories = entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : []))
const files = [
...entries.flatMap((entry) => (entry.type === "document" && entry.path ? [entry.path] : [])),
...fileTargets,
]
const targets = [
...directories.map((path) => ({ path, type: "directory" as const, ignore })),
...files
.filter((file) => !directories.some((directory) => FSUtil.contains(directory, file)))
.map((path) => ({ path, type: "file" as const })),
]
for (const target of targets) {
const key = JSON.stringify(target)
if (watched.has(key)) continue
watched.add(key)
const stream = yield* watcher.subscribe(target)
yield* stream.pipe(
Stream.runForEach((update) => PubSub.publish(updates, update)),
Effect.forkScoped({ startImmediately: true }),
)
const reloads = yield* PubSub.sliding<void>(1)
// Readiness rescans recover writes made before a watch attached.
const requestReload = PubSub.publish(reloads, undefined).pipe(Effect.asVoid)
const watched = yield* FiberMap.make<string>()
const reconcile = Effect.fn("Config.reconcileWatches")(function* (sources: ConfigDiscovery.Sources) {
const plan = ConfigWatch.plan(sources)
for (const key of Array.from(watched, ([key]) => key)) {
if (!plan.has(key)) yield* FiberMap.remove(watched, key)
}
for (const [key, target] of plan) {
yield* watcher
.subscribe(target, requestReload)
.pipe(
Effect.flatMap(
Stream.runForEach((update) => PubSub.publish(updates, update).pipe(Effect.andThen(requestReload))),
),
FiberMap.run(watched, key, { onlyIfMissing: true, startImmediately: true }),
)
}
})
const reload = Effect.fn("Config.reload")(
function* () {
const next = yield* discover()
yield* reconcile(next)
const sources = yield* ConfigDiscovery.discover(options)
const next = yield* load(sources)
yield* reconcile(sources)
if (isDeepStrictEqual(configs, next)) return
configs = next
yield* bus.publish(Event.Updated, {})
@@ -343,12 +269,12 @@ export const layer = (options?: Options) =>
(effect) => reloadLock.withPermit(effect),
)
yield* Stream.fromPubSub(updates).pipe(
// Subscribe eagerly so synchronous watch readiness isn't dropped.
const pendingReloads = yield* PubSub.subscribe(reloads)
yield* Stream.fromSubscription(pendingReloads).pipe(
Stream.debounce("100 millis"),
Stream.runForEach((update) =>
reload().pipe(
Effect.catchCause((cause) => Effect.logError("failed to reload config", { path: update.path, cause })),
),
Stream.runForEach(() =>
reload().pipe(Effect.catchCause((cause) => Effect.logError("failed to reload config", { cause }))),
),
Effect.forkScoped({ startImmediately: true }),
)
@@ -389,7 +315,7 @@ export const layer = (options?: Options) =>
Effect.forever,
Effect.forkScoped({ startImmediately: true }),
)
yield* reconcile(initial)
yield* reloadLock.withPermit(reconcile(initial))
return Service.of({
entries: Effect.fnUntraced(function* () {
+84
View File
@@ -0,0 +1,84 @@
export * as ConfigDiscovery from "./discovery.js"
import path from "path"
import { Effect } from "effect"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Location } from "../location.js"
import { AbsolutePath } from "../schema.js"
import type { Options } from "../config.js"
export const names = ["opencode.json", "opencode.jsonc"]
/** Eligible sources in priority order, including paths that may appear later. */
export interface Sources {
readonly global?: AbsolutePath
readonly explicit?: AbsolutePath
readonly direct: readonly AbsolutePath[]
readonly project: readonly { readonly path: AbsolutePath; readonly present: boolean }[]
readonly claude: readonly AbsolutePath[]
readonly agents: readonly AbsolutePath[]
}
export const discover = Effect.fn("ConfigDiscovery.discover")(function* (options?: Options) {
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const location = yield* Location.Service
const globalDirectory = AbsolutePath.make(global.config)
const globalAgentsDirectory = AbsolutePath.make(path.join(global.home, ".agents"))
const globalClaudeDirectory = AbsolutePath.make(path.join(global.home, ".claude"))
const globalRoots = yield* Effect.forEach([globalDirectory, globalClaudeDirectory, globalAgentsDirectory], (item) =>
fs.resolve(item),
)
const directories =
(yield* fs.resolve(location.directory)) === globalRoots[0] || options?.project === false
? []
: yield* fs.up({ targets: ["."], start: location.directory }).pipe(Effect.orDie)
const discovered = yield* Effect.forEach(directories, (directory) =>
Effect.gen(function* () {
// Resolve the parent too: missing children must honor symlinked global roots.
const parent = yield* fs.resolve(directory)
return yield* Effect.forEach([".claude", ".agents", ".opencode", ...names.toReversed()], (name) =>
fs
.resolve(path.join(parent, name))
.pipe(Effect.map((resolved) => ({ item: AbsolutePath.make(path.join(directory, name)), resolved }))),
)
}),
).pipe(
Effect.map((items) => items.flat()),
Effect.orDie,
)
const globalEnabled = options?.global !== false
const globalFiles = yield* Effect.forEach(names, (name) => fs.resolve(path.join(globalDirectory, name)))
// Global sources must not re-enter through the project walk.
const visible = discovered
.filter(({ resolved }) =>
globalEnabled
? !globalRoots.includes(resolved) && !globalFiles.includes(resolved)
: !globalRoots.some((root) => resolved === root || resolved.startsWith(root + path.sep)),
)
.map(({ item }) => item)
return {
global: globalEnabled ? globalDirectory : undefined,
explicit: options?.file ? AbsolutePath.make(path.resolve(options.file)) : undefined,
direct: visible.filter((item) => ![".agents", ".claude", ".opencode"].includes(path.basename(item))).toReversed(),
project: yield* Effect.forEach(
visible.filter((item) => path.basename(item) === ".opencode").toReversed(),
(directory) => fs.isDir(directory).pipe(Effect.map((present) => ({ path: directory, present }))),
),
claude: [
...new Set([
...(globalEnabled ? [globalClaudeDirectory] : []),
...visible.filter((item) => path.basename(item) === ".claude").toReversed(),
]),
],
agents: [
...new Set([
...(globalEnabled ? [globalAgentsDirectory] : []),
...visible.filter((item) => path.basename(item) === ".agents").toReversed(),
]),
],
} satisfies Sources
})
+1 -1
View File
@@ -33,7 +33,7 @@ export const Plugin = define({
const changes = yield* PubSub.sliding<string>(1)
const lock = Semaphore.makeUnsafe(1)
const watch = Effect.fn("ConfigSkillPlugin.watch")(function* (directory: string, type: Watcher.WatchInput["type"]) {
const watch = Effect.fn("ConfigSkillPlugin.watch")(function* (directory: string, type: "file" | "directory") {
const target = path.resolve(directory)
const updates = yield* watcher.subscribe({ path: target, type })
yield* FiberMap.run(
+39
View File
@@ -0,0 +1,39 @@
export * as ConfigWatch from "./watch.js"
import path from "path"
import { FSUtil } from "@opencode-ai/util/fs-util"
import type { Watcher } from "../filesystem/watcher.js"
import type { ConfigDiscovery } from "./discovery.js"
export function plan(sources: ConfigDiscovery.Sources) {
const directories = [
...(sources.global ? [sources.global] : []),
...sources.project.filter((root) => root.present).map((root) => root.path),
]
const files = [
...sources.direct,
...sources.project.map((root) => root.path),
...sources.claude,
...sources.agents,
...(sources.explicit ? [sources.explicit] : []),
]
// Keep a parent watch for each root so deletion/recreation is observable.
const parents = Map.groupBy(
files.filter((file) => !directories.some((directory) => file !== directory && FSUtil.contains(directory, file))),
(file) => path.dirname(file),
)
return new Map(
[
...directories.map((path) => ({
path,
type: "directory" as const,
ignore: ["node_modules", ".git", "**/{node_modules,.git}/**"],
})),
...Array.from(parents, ([parent, files]) => ({
path: parent,
type: "entries" as const,
names: [...new Set(files.map((file) => path.basename(file)))].toSorted(),
})),
].map((target) => [JSON.stringify(target), target satisfies Watcher.WatchInput]),
)
}
+55 -57
View File
@@ -34,6 +34,7 @@ export type Update = ParcelWatcher.Event
export type WatchInput =
| { readonly path: string; readonly type: "file" }
| { readonly path: string; readonly type: "entries"; readonly names: readonly string[] }
| { readonly path: string; readonly type: "directory"; readonly ignore?: readonly string[] }
export type Subscription = {
@@ -42,25 +43,26 @@ export type Subscription = {
readonly backend?: string
}
type Target = {
readonly target: string
readonly ignore: readonly string[]
} & (
| { readonly type: "entries"; readonly names: readonly string[] }
| { readonly type: "file" | "directory"; readonly names?: readonly string[] }
)
export interface NativeInterface {
/** Starts one OS-level watch, reporting events through `publish` until unsubscribed. */
readonly subscribe: (input: {
readonly type: WatchInput["type"]
readonly target: string
readonly ignore: readonly string[]
readonly publish: (update: Update) => void
}) => Effect.Effect<Subscription | undefined>
readonly subscribe: (
input: Target & { readonly publish: (update: Update) => void },
) => Effect.Effect<Subscription | undefined>
}
/**
* The OS-level watch implementation behind the Watcher service. The default
* layer uses `node:fs.watch` for files and `@parcel/watcher` for directories;
* tests provide implementations they can control.
*/
/** Uses fs.watch for immediate entries and Parcel for recursive directories. */
export class Native extends Context.Service<Native, NativeInterface>()("@opencode/Watcher/Native") {}
export interface Interface {
readonly subscribe: (input: WatchInput) => Effect.Effect<Stream.Stream<Update>>
/** onReady runs after native acquisition and listener registration, when the stream is consumed. */
readonly subscribe: (input: WatchInput, onReady?: Effect.Effect<void>) => Effect.Effect<Stream.Stream<Update>>
}
export const Options = Schema.Struct({
@@ -89,16 +91,13 @@ export const layer = (options?: Options) =>
const native = yield* Native
// Keys compare structurally (effect Equal), so equivalent watches share one entry.
type Key = { readonly type: WatchInput["type"]; readonly target: string; readonly ignore: readonly string[] }
const watchers = yield* RcMap.make({
lookup: (key: Key) =>
lookup: (key: Target) =>
Effect.gen(function* () {
const pubsub = yield* Effect.acquireRelease(PubSub.unbounded<Update>(), (pubsub) => PubSub.shutdown(pubsub))
const subscription = yield* Effect.acquireRelease(
native.subscribe({
type: key.type,
target: key.target,
ignore: key.ignore,
...key,
publish: (update) => PubSub.publishUnsafe(pubsub, update),
}),
(subscription) =>
@@ -127,34 +126,31 @@ export const layer = (options?: Options) =>
}),
})
const subscribe = (input: WatchInput) => {
const subscribe = Effect.fnUntraced(function* (input: WatchInput, onReady: Effect.Effect<void> = Effect.void) {
const target = path.resolve(input.path)
const ignore = [...new Set(input.type === "directory" ? (input.ignore ?? []) : [])].toSorted()
return Effect.gen(function* () {
yield* Effect.logInfo("watcher subscribe", {
path: target,
type: input.type,
ignores: ignore.length,
})
return Stream.unwrap(
Effect.gen(function* () {
const pubsub = yield* RcMap.get(watchers, { type: input.type, target, ignore })
return Stream.fromPubSub(pubsub)
}),
)
const names = [...new Set(input.type === "entries" ? input.names : [])].toSorted()
yield* Effect.logInfo("watcher subscribe", {
path: target,
type: input.type,
ignores: ignore.length,
})
}
return Stream.unwrap(
Effect.gen(function* () {
const pubsub = yield* RcMap.get(watchers, { type: input.type, target, ignore, names })
const subscription = yield* PubSub.subscribe(pubsub)
if (yield* PubSub.isShutdown(pubsub)) return Stream.empty
yield* onReady
return Stream.fromSubscription(subscription)
}),
)
})
return Service.of({ subscribe })
}),
)
/**
* Watcher for tests: the real lifecycle over an in-memory Native that records
* acquired watches and routes emitted updates the way the OS watches would: a
* file watch receives updates for its own path, a directory watch receives
* updates for paths inside it that no ignore entry covers.
*/
/** Real subscription lifecycle with in-memory, path-filtered event delivery. */
export const testLayer = Layer.effectContext(
Effect.gen(function* () {
const subscriptions: WatchInput[] = []
@@ -165,21 +161,22 @@ export const testLayer = Layer.effectContext(
subscriptions.push(
input.type === "file"
? { path: input.target, type: "file" }
: input.ignore.length > 0
? { path: input.target, type: "directory", ignore: input.ignore }
: { path: input.target, type: "directory" },
: input.type === "entries"
? { path: input.target, type: "entries", names: input.names }
: input.ignore.length > 0
? { path: input.target, type: "directory", ignore: input.ignore }
: { path: input.target, type: "directory" },
)
// Ignore entries resolve against the target like the parcel wrapper's
// literal paths. Glob entries resolve to paths nothing lives under, so
// they are inert here rather than compiled the way parcel compiles them.
const ignored = input.ignore.map((entry) => path.resolve(input.target, entry))
active.set(
input.publish,
input.type === "file"
? (target) => target === input.target
: (target) =>
FSUtil.contains(input.target, target) && !ignored.some((entry) => FSUtil.contains(entry, target)),
)
active.set(input.publish, (target) => {
if (input.type === "file") return target === input.target
if (input.type === "entries")
return path.dirname(target) === input.target && input.names.includes(path.basename(target))
return FSUtil.contains(input.target, target) && !ignored.some((entry) => FSUtil.contains(entry, target))
})
return {
unsubscribe: () => {
active.delete(input.publish)
@@ -208,18 +205,19 @@ export const nativeLayer = Layer.succeed(
Native,
Native.of({
subscribe: (input) => {
if (input.type === "file") {
if (input.type === "file" || input.type === "entries") {
return Effect.sync(() => {
const directory = path.dirname(input.target)
const directory = input.type === "file" ? path.dirname(input.target) : input.target
const names = new Set(input.type === "file" ? [path.basename(input.target)] : input.names)
const subscription = watch(directory, { recursive: false }, (_event, file) => {
if (file && path.resolve(directory, file.toString()) !== input.target) return
input.publish({ path: input.target, type: "update" } satisfies Update)
if (file && !names.has(file)) return
for (const name of file ? [file] : names) {
input.publish({ path: path.join(directory, name), type: "update" })
}
})
if ("on" in subscription && typeof subscription.on === "function") {
subscription.on("error", (error: unknown) =>
Effect.runFork(Effect.logError("watcher callback failed", { path: input.target, error })),
)
}
subscription.on("error", (error: unknown) =>
Effect.runFork(Effect.logError("watcher callback failed", { path: directory, error })),
)
return { unsubscribe: () => Promise.resolve(subscription.close()), backend: "node" }
})
}
+60 -9
View File
@@ -105,6 +105,10 @@ describe("Config", () => {
Effect.gen(function* () {
const config = yield* Config.Service
expect(ambient(yield* config.entries())).toEqual([])
const watcher = yield* Watcher.Test
expect(
(yield* watcher.subscriptions()).filter((watch) => watch.type === "entries" && watch.path === home),
).toEqual([])
}).pipe(
Effect.provide(
testLayer(project, global, project, undefined, undefined, undefined, undefined, { global: false }),
@@ -165,7 +169,16 @@ describe("Config", () => {
const entries = yield* config.entries()
expect(entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : []))).toEqual([global])
expect(entries.flatMap((entry) => (entry.type === "document" ? [entry.info.shell] : []))).toEqual(["global"])
expect((yield* watcher.subscriptions()).map((subscription) => subscription.path)).toEqual([global])
expect(
(yield* watcher.subscriptions())
.filter((subscription) => subscription.type === "directory")
.map((subscription) => subscription.path),
).toEqual([global])
expect(
(yield* watcher.subscriptions()).filter((subscription) =>
subscription.path.includes(`${path.sep}.opencode${path.sep}`),
),
).toEqual([])
})
return Effect.promise(async () => {
await fs.mkdir(global, { recursive: true })
@@ -230,6 +243,8 @@ describe("Config", () => {
Effect.gen(function* () {
const config = yield* Config.Service
expect(Config.latest(yield* config.entries(), "shell")).toBe("global")
const watcher = yield* Watcher.Test
expect((yield* watcher.subscriptions()).map((subscription) => subscription.path)).toEqual([global])
}).pipe(
Effect.provide(
testLayer(project, global, project, undefined, undefined, emptyCredentialNode, emptyWellknownNode, {
@@ -243,17 +258,19 @@ describe("Config", () => {
),
)
it.live("reloads external config and publishes directory updates", () =>
it.live("reloads file substitutions when their source changes", () =>
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const global = path.join(tmp.path, "global")
const project = path.join(tmp.path, "project")
const file = path.join(global, "opencode.json")
const source = path.join(global, "shell.txt")
yield* Effect.promise(async () => {
await fs.mkdir(global, { recursive: true })
await fs.mkdir(project, { recursive: true })
await fs.writeFile(file, JSON.stringify({ shell: "first" }))
await fs.writeFile(source, "first")
await fs.writeFile(file, JSON.stringify({ shell: "{file:shell.txt}" }))
})
return yield* Effect.gen(function* () {
const config = yield* Config.Service
@@ -264,9 +281,8 @@ describe("Config", () => {
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.sleep("10 millis")
yield* watcher.emit({ type: "update", path: path.join(global, "commands", "review.md") })
yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ shell: "second" })))
yield* watcher.emit({ type: "update", path: file })
yield* Effect.promise(() => fs.writeFile(source, "second"))
yield* watcher.emit({ type: "update", path: source })
expect(yield* Fiber.join(changed)).toHaveLength(1)
expect(Config.latest(yield* config.entries(), "shell")).toBe("second")
@@ -276,6 +292,35 @@ describe("Config", () => {
),
)
it.live("excludes missing files under symlinked global roots when global is disabled", () =>
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) => {
const global = path.join(tmp.path, "global")
const link = path.join(tmp.path, "link")
const project = path.join(link, "plugins", "demo")
return Effect.promise(async () => {
await fs.mkdir(path.join(global, "plugins", "demo"), { recursive: true })
await fs.symlink(global, link, process.platform === "win32" ? "junction" : undefined)
}).pipe(
Effect.andThen(
Effect.gen(function* () {
const watcher = yield* Watcher.Test
const subscriptions = yield* watcher.subscriptions()
expect(subscriptions.length).toBeGreaterThan(0)
expect(
subscriptions.filter((item) => inFixture(global, item.path) || inFixture(link, item.path)),
).toEqual([])
}).pipe(
Effect.provide(
testLayer(project, global, project, undefined, undefined, undefined, undefined, { global: false }),
),
),
),
)
}),
),
)
it.live("exposes filesystem updates under config roots through changes", () =>
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
@@ -871,7 +916,7 @@ describe("Config", () => {
),
)
it.live("does not watch ecosystem config roots", () =>
it.live("does not recursively watch ecosystem config roots", () =>
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
@@ -892,6 +937,11 @@ describe("Config", () => {
path: AbsolutePath.make(path.join(tmp.path, "global")),
ignore: ["**/{node_modules,.git}/**", ".git", "node_modules"],
},
{
type: "entries",
path: tmp.path,
names: [".agents", ".claude", ".opencode", "opencode.json", "opencode.jsonc"],
},
])
}).pipe(Effect.provide(testLayer(tmp.path, undefined, undefined, undefined, Watcher.testLayer)))
}),
@@ -1447,8 +1497,9 @@ describe("Config", () => {
expect(documents.map((document) => document.info.$schema)).toEqual(["base"])
expect(yield* watcher.subscriptions()).toContainEqual({
path: path.join(tmp.path, "opencode.jsonc"),
type: "file",
path: tmp.path,
type: "entries",
names: [".agents", ".claude", ".opencode", "opencode.json", "opencode.jsonc"],
})
}).pipe(Effect.provide(testLayer(tmp.path)))
}),
+213 -1
View File
@@ -1,4 +1,6 @@
import path from "path"
import fs from "fs/promises"
import { writeFileSync } from "node:fs"
import { describe, expect } from "bun:test"
import { Document, Event, Info } from "@opencode-ai/schema/config"
import { Agent } from "@opencode-ai/core/agent"
@@ -20,12 +22,19 @@ import { Reference } from "@opencode-ai/core/reference"
import { Skill } from "@opencode-ai/core/skill"
import { ShellSelect } from "@opencode-ai/core/shell/select"
import { Global } from "@opencode-ai/util/global"
import { Location } from "@opencode-ai/core/location"
import { Credential } from "@opencode-ai/core/credential"
import { WellKnown } from "@opencode-ai/core/wellknown"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { AppProcess } from "@opencode-ai/util/process"
import { Effect, Layer, Schema } from "effect"
import { Deferred, Effect, Layer, Schema } from "effect"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "../plugin/fixture"
import { emptyCredentialNode, emptyWellknownNode } from "../fixture/config-nodes"
import { location } from "../fixture/location"
import { tmpdir } from "../fixture/tmpdir"
const it = testEffect(
Layer.merge(PluginTestLayer, AppNodeBuilder.build(LayerNode.group([AppProcess.node, ShellSelect.node]))),
@@ -34,6 +43,192 @@ const decode = Schema.decodeUnknownSync(Info)
const document = path.join(import.meta.dir, "opencode.json")
describe("config plugin reloads", () => {
for (const input of [
{ root: ".agents", global: false },
{ root: "../.claude", global: false },
{ root: "home/.agents", global: true },
{ root: "home/.claude", global: true },
]) {
it.live(`loads skills when ${input.root} appears after startup`, () =>
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const project = path.join(tmp.path, "project")
const root = path.resolve(project, input.root)
const file = path.join(project, "opencode.json")
yield* Effect.promise(async () => {
await fs.mkdir(path.join(project, "home"), { recursive: true })
await fs.mkdir(path.join(project, "global"))
await Bun.write(file, JSON.stringify({ shell: "initial" }))
})
return yield* Effect.gen(function* () {
const config = yield* Config.Service
const plugins = yield* Plugin.Service
const skills = yield* Skill.Service
const host = yield* PluginHost.make(plugins)
yield* ConfigSkillPlugin.Plugin.effect(host)
expect(yield* skills.list()).toEqual([])
// Finish startup by observing an ordinary config reload before creating the root.
yield* Effect.promise(() => Bun.write(file, JSON.stringify({ shell: "ready" })))
yield* waitUntil(
config.entries().pipe(Effect.map((entries) => Config.latest(entries, "shell") === "ready")),
)
const skill = path.join(root, "skills", "probe", "SKILL.md")
yield* Effect.promise(() =>
Bun.write(skill, "---\nname: probe\ndescription: Hot reload\n---\nTest skill"),
)
yield* waitUntil(skills.list().pipe(Effect.map((items) => items.some((item) => item.id === "probe"))))
expect((yield* skills.list())[0]?.location).toBe(AbsolutePath.make(skill))
yield* Effect.promise(() => fs.rm(root, { recursive: true }))
yield* waitUntil(skills.list().pipe(Effect.map((items) => items.length === 0)))
yield* Effect.promise(() => Bun.write(skill, "---\nname: probe\ndescription: Recreated\n---\nTest skill"))
yield* waitUntil(skills.list().pipe(Effect.map((items) => items[0]?.description === "Recreated")))
}).pipe(Effect.provide(liveConfig(project, undefined, { global: input.global })))
}),
),
),
)
}
it.live("retains readiness signalled synchronously during initial config startup", () =>
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) => {
const native = Watcher.Native.of({
subscribe: (input) =>
Effect.sync(() => {
if (input.type === "entries" && input.target === tmp.path) {
// No event is emitted: only synchronous readiness can trigger the reload.
writeFileSync(path.join(tmp.path, "opencode.json"), JSON.stringify({ references: { docs: "./docs" } }))
}
return { unsubscribe: () => Promise.resolve() }
}),
})
return Effect.gen(function* () {
const plugins = yield* Plugin.Service
const references = yield* Reference.Service
const host = yield* PluginHost.make(plugins)
yield* ConfigReferencePlugin.Plugin.effect(host)
yield* waitUntil(references.list().pipe(Effect.map((items) => items.some((item) => item.name === "docs"))))
expect((yield* references.list())[0]?.path).toBe(AbsolutePath.make(path.join(tmp.path, "docs")))
}).pipe(Effect.provide(liveConfig(tmp.path, native)))
}),
),
)
it.live("loads the first config written while a new directory watch is starting", () =>
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const root = path.join(tmp.path, ".opencode")
const parent = yield* Deferred.make<(update: Watcher.Update) => void>()
const starting = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const native = Watcher.Native.of({
subscribe: (input) =>
Effect.gen(function* () {
if (input.type === "entries" && input.target === tmp.path) {
yield* Deferred.succeed(parent, input.publish)
}
if (input.type === "directory" && input.target === root) {
yield* Deferred.succeed(starting, undefined)
yield* Deferred.await(release)
}
return { unsubscribe: () => Promise.resolve() }
}),
})
return yield* Effect.gen(function* () {
const plugins = yield* Plugin.Service
const references = yield* Reference.Service
const host = yield* PluginHost.make(plugins)
yield* ConfigReferencePlugin.Plugin.effect(host)
const publish = yield* Deferred.await(parent)
yield* Effect.promise(() => fs.mkdir(root))
publish({ path: root, type: "create" })
yield* Deferred.await(starting).pipe(Effect.timeout("2 seconds"))
// No file event: the recursive native watch has not been acquired yet.
yield* Effect.promise(() =>
fs.writeFile(path.join(root, "opencode.json"), JSON.stringify({ references: { docs: "./docs" } })),
)
yield* Deferred.succeed(release, undefined)
yield* waitUntil(references.list().pipe(Effect.map((items) => items.some((item) => item.name === "docs"))))
expect((yield* references.list())[0]?.path).toBe(AbsolutePath.make(path.join(root, "docs")))
}).pipe(Effect.provide(liveConfig(tmp.path, native)))
}),
),
),
)
for (const input of [
{ file: "opencode.json", empty: false },
{ file: "../opencode.jsonc", empty: false },
{ file: ".opencode/opencode.json", empty: false },
{ file: "../.opencode/opencode.jsonc", empty: true },
]) {
it.live(`loads references when ${input.file} is first created and keeps watching it`, () =>
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const project = path.join(tmp.path, "project")
const target = path.resolve(project, input.file)
yield* Effect.promise(() => fs.mkdir(project))
return yield* Effect.gen(function* () {
const plugins = yield* Plugin.Service
const references = yield* Reference.Service
const host = yield* PluginHost.make(plugins)
yield* ConfigReferencePlugin.Plugin.effect(host)
expect(yield* references.list()).toEqual([])
if (input.empty) {
yield* Effect.promise(() => fs.mkdir(path.dirname(target)))
const config = yield* Config.Service
yield* waitUntil(
config
.entries()
.pipe(
Effect.map((entries) =>
entries.some((entry) => entry.type === "directory" && entry.path === path.dirname(target)),
),
),
)
}
yield* Effect.promise(async () => {
await fs.mkdir(path.dirname(target), { recursive: true })
await fs.writeFile(target, JSON.stringify({ references: { docs: "./docs" } }))
})
yield* waitUntil(
references.list().pipe(Effect.map((items) => items.some((item) => item.name === "docs"))),
)
expect((yield* references.list())[0]?.path).toBe(
AbsolutePath.make(path.join(path.dirname(target), "docs")),
)
yield* Effect.promise(() => fs.writeFile(target, JSON.stringify({ references: { next: "./next" } })))
yield* waitUntil(
references.list().pipe(Effect.map((items) => items.length === 1 && items[0]?.name === "next")),
)
yield* Effect.promise(() =>
fs.rm(input.file.includes(".opencode/") ? path.dirname(target) : target, { recursive: true }),
)
yield* waitUntil(references.list().pipe(Effect.map((items) => items.length === 0)))
yield* Effect.promise(async () => {
await fs.mkdir(path.dirname(target), { recursive: true })
await fs.writeFile(target, JSON.stringify({ references: { docs: "./docs" } }))
})
yield* waitUntil(
references.list().pipe(Effect.map((items) => items.length === 1 && items[0]?.name === "docs")),
)
yield* Effect.promise(() => fs.writeFile(target, JSON.stringify({ references: { next: "./next" } })))
yield* waitUntil(
references.list().pipe(Effect.map((items) => items.length === 1 && items[0]?.name === "next")),
)
}).pipe(Effect.provide(liveConfig(project)))
}),
),
),
)
}
it.effect("preserves reference precedence and insertion order across documents", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
@@ -123,6 +318,23 @@ describe("config plugin reloads", () => {
)
})
function liveConfig(directory: string, native?: Watcher.NativeInterface, options: Config.Options = { global: false }) {
return AppNodeBuilder.build(LayerNode.group([Config.node, Bus.node, Reference.node, Global.node, Location.node]), [
Config.node.replace(Config.configured(options)),
Location.node.replace(
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
),
Global.node.replace(
Global.layerWith({ config: path.join(directory, "global"), home: path.join(directory, "home") }),
),
Credential.node.replace(emptyCredentialNode),
WellKnown.node.replace(emptyWellknownNode),
...(native
? [Watcher.node.replace(Watcher.layer().pipe(Layer.provide(Layer.succeed(Watcher.Native, native))))]
: []),
])
}
function config(name: string) {
return new Document({
type: "document",
+42
View File
@@ -0,0 +1,42 @@
import path from "path"
import { describe, expect, test } from "bun:test"
import type { ConfigDiscovery } from "@opencode-ai/core/config/discovery"
import { ConfigWatch } from "@opencode-ai/core/config/watch"
import { AbsolutePath } from "@opencode-ai/core/schema"
const project = path.resolve("watch-plan-project")
const root = AbsolutePath.make(path.join(project, ".opencode"))
const sources: ConfigDiscovery.Sources = {
direct: ["opencode.json", "opencode.jsonc"].map((name) => AbsolutePath.make(path.join(project, name))),
project: [{ path: root, present: false }],
claude: [AbsolutePath.make(path.join(project, ".claude"))],
agents: [AbsolutePath.make(path.join(project, ".agents"))],
}
describe("ConfigWatch.plan", () => {
test("groups missing candidates and keeps parent watches when roots appear", () => {
const missing = ConfigWatch.plan(sources)
expect(Array.from(missing.values())).toEqual([
{ path: project, type: "entries", names: [".agents", ".claude", ".opencode", "opencode.json", "opencode.jsonc"] },
])
const present = ConfigWatch.plan({ ...sources, project: [{ path: root, present: true }] })
expect(Array.from(present.values())).toEqual([
{ path: root, type: "directory", ignore: ["node_modules", ".git", "**/{node_modules,.git}/**"] },
...missing.values(),
])
})
test("adds exact watches for explicit files only when not already covered", () => {
expect(ConfigWatch.plan({ ...sources, explicit: sources.direct[0] })).toEqual(ConfigWatch.plan(sources))
const present = { ...sources, project: [{ path: root, present: true }] }
expect(ConfigWatch.plan({ ...present, explicit: AbsolutePath.make(path.join(root, "custom.json")) })).toEqual(
ConfigWatch.plan(present),
)
const directory = path.resolve("watch-plan-external")
expect(
Array.from(
ConfigWatch.plan({ ...sources, explicit: AbsolutePath.make(path.join(directory, "custom.json")) }).values(),
),
).toContainEqual({ path: directory, type: "entries", names: ["custom.json"] })
})
})
+137 -16
View File
@@ -53,18 +53,121 @@ function countingNative() {
}
describe("Watcher lifecycle", () => {
it.effect("signals readiness after acquisition and buffers updates published by the ready callback", () =>
Effect.gen(function* () {
const publish = yield* Deferred.make<(update: Watcher.Update) => void>()
const acquired = yield* Deferred.make<void>()
const counts = { ready: 0, closed: 0 }
yield* Effect.gen(function* () {
const watcher = yield* Watcher.Service
const consumer = yield* watcher
.subscribe(
{ path: "/shared", type: "entries", names: ["opencode.json"] },
Effect.gen(function* () {
counts.ready++
const notify = yield* Deferred.await(publish)
notify({ path: "/shared/opencode.json", type: "create" })
}),
)
.pipe(Effect.flatMap(Stream.runHead), Effect.forkScoped({ startImmediately: true }))
yield* Deferred.await(publish)
expect(counts.ready).toBe(0)
yield* Deferred.succeed(acquired, undefined)
expect(Option.getOrUndefined(yield* Fiber.join(consumer))).toEqual({
path: "/shared/opencode.json",
type: "create",
})
expect(counts).toEqual({ ready: 1, closed: 1 })
}).pipe(
withNative({
subscribe: (input) =>
Deferred.succeed(publish, input.publish).pipe(
Effect.andThen(Deferred.await(acquired)),
Effect.as({
unsubscribe: async () => {
counts.closed++
},
}),
),
}),
)
}),
)
it.effect("does not signal readiness for an unavailable native watch", () => {
const counts = { ready: 0 }
return Effect.gen(function* () {
const watcher = yield* Watcher.Service
const stream = yield* watcher.subscribe(
{ path: "/unavailable", type: "directory" },
Effect.sync(() => {
counts.ready++
}),
)
yield* Stream.runDrain(stream)
expect(counts.ready).toBe(0)
}).pipe(withNative({ subscribe: () => Effect.undefined }))
})
it.live("watches only named immediate entries, including missing directories", () =>
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const native = yield* Watcher.Native
const events: Watcher.Update[] = []
yield* Effect.acquireRelease(
native.subscribe({
type: "entries",
target: tmp.path,
names: ["opencode.json", ".opencode"],
ignore: [],
publish: (update) => events.push(update),
}),
(subscription) => Effect.promise(() => subscription?.unsubscribe() ?? Promise.resolve()),
)
yield* Effect.promise(async () => {
await fs.mkdir(path.join(tmp.path, "nested"))
await fs.writeFile(path.join(tmp.path, "nested", "opencode.json"), "ignored")
await fs.writeFile(path.join(tmp.path, "opencode.jsonc"), "ignored")
await fs.writeFile(path.join(tmp.path, "opencode.json"), "first")
})
yield* Effect.sync(() => events.length).pipe(
Effect.filterOrFail((count) => count > 0),
Effect.retry(Schedule.spaced("10 millis")),
Effect.timeout("1 second"),
)
expect(events.every((update) => update.path === path.join(tmp.path, "opencode.json"))).toBe(true)
yield* Effect.sleep("10 millis")
yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, ".opencode")))
yield* Effect.sync(() => events.some((update) => update.path === path.join(tmp.path, ".opencode"))).pipe(
Effect.filterOrFail(Boolean),
Effect.retry(Schedule.spaced("10 millis")),
Effect.timeout("1 second"),
)
}).pipe(Effect.provide(Watcher.nativeLayer)),
),
),
)
it.effect("interrupting a consumer interrupts a pending acquisition", () =>
Effect.gen(function* () {
const started = yield* Deferred.make<void>()
const interrupted = yield* Deferred.make<void>()
const counts = { ready: 0 }
yield* Effect.gen(function* () {
const watcher = yield* Watcher.Service
const consumer = yield* watcher
.subscribe({ path: "/pending", type: "directory" })
.subscribe(
{ path: "/pending", type: "directory" },
Effect.sync(() => {
counts.ready++
}),
)
.pipe(Effect.flatMap(Stream.runDrain), Effect.forkScoped({ startImmediately: true }))
yield* Deferred.await(started)
yield* Fiber.interrupt(consumer)
expect(yield* Deferred.isDone(interrupted)).toBe(true)
expect(counts.ready).toBe(0)
}).pipe(
withNative({
subscribe: () =>
@@ -77,16 +180,16 @@ describe("Watcher lifecycle", () => {
}),
)
it.effect("shares one subscription and releases exactly once after the final consumer", () => {
it.effect("shares equivalent entry sets and releases exactly once after the final consumer", () => {
const { native, counts } = countingNative()
return Effect.gen(function* () {
const watcher = yield* Watcher.Service
const consume = () =>
const consume = (names: string[]) =>
watcher
.subscribe({ path: "/shared", type: "directory" })
.subscribe({ path: "/shared", type: "entries", names })
.pipe(Effect.flatMap(Stream.runDrain), Effect.forkScoped({ startImmediately: true }))
const first = yield* consume()
const second = yield* consume()
const first = yield* consume(["opencode.json", ".opencode", "opencode.json"])
const second = yield* consume([".opencode", "opencode.json"])
yield* Effect.yieldNow
expect(counts.subscribes).toBe(1)
@@ -553,17 +656,35 @@ describeNative("LocationWatcher", () => {
})
it.live("publishes .hg/branch events", () =>
withTmp(
(directory) =>
Effect.gen(function* () {
const started = yield* Deferred.make<string>()
const watcher = Layer.effect(
Watcher.Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const branch = path.join(directory, ".hg", "branch")
yield* ready(branch)
expect(
yield* nextUpdate((event) => event.file === branch, fs.writeFileString(branch, "feature\n")),
).toMatchObject({ file: branch })
const service = yield* Watcher.Service
return Watcher.Service.of({
subscribe: (input, onReady) =>
service.subscribe(
input,
Deferred.succeed(started, input.path).pipe(Effect.andThen(onReady ?? Effect.void)),
),
})
}),
{ vcs: "hg" },
),
).pipe(Layer.provide(AppNodeBuilder.build(Watcher.node)))
return yield* withTmp(
(directory) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const branch = path.join(directory, ".hg", "branch")
// Use the actual acquisition barrier, not a probe write whose event
// callback can race the next write in Bun's filesystem watcher.
expect(yield* Deferred.await(started)).toBe(branch)
expect(
yield* nextUpdate((event) => event.file === branch, fs.writeFileString(branch, "feature\n")),
).toMatchObject({ file: branch })
}),
{ vcs: "hg", watcher },
)
}),
)
})
@@ -280,6 +280,7 @@ test("keeps consecutive close controls fixed across overflow window changes", as
await app.mockMouse.click(11, 0)
await app.waitForFrame((frame) => items().length === 4 && Array.from(frame.split("\n")[0] ?? "")[11] === "✕")
await app.mockMouse.click(11, 0)
await app.waitFor(() => closed.length === 2)
expect(closed).toEqual(["third", "fourth"])
} finally {