7f571d36ea
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
173 lines
6.0 KiB
TypeScript
173 lines
6.0 KiB
TypeScript
import { Cause, Effect, Layer, Context, Schema } from "effect"
|
|
// @ts-ignore
|
|
import { createWrapper } from "@parcel/watcher/wrapper"
|
|
import type ParcelWatcher from "@parcel/watcher"
|
|
import { readdir, realpath } from "fs/promises"
|
|
import path from "path"
|
|
import { EventV2 } from "@opencode-ai/core/event"
|
|
import { EventV2Bridge } from "@/event-v2-bridge"
|
|
import { EffectBridge } from "@/effect/bridge"
|
|
import { InstanceState } from "@/effect/instance-state"
|
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
|
import { Git } from "@/git"
|
|
import { lazy } from "@/util/lazy"
|
|
import { Config } from "@/config/config"
|
|
import { FileIgnore } from "./ignore"
|
|
import { Protected } from "./protected"
|
|
import * as Log from "@opencode-ai/core/util/log"
|
|
|
|
declare const OPENCODE_LIBC: string | undefined
|
|
|
|
const log = Log.create({ service: "file.watcher" })
|
|
const SUBSCRIBE_TIMEOUT_MS = 10_000
|
|
|
|
export const Event = {
|
|
Updated: EventV2.define({
|
|
type: "file.watcher.updated",
|
|
schema: {
|
|
file: Schema.String,
|
|
event: Schema.Literals(["add", "change", "unlink"]),
|
|
},
|
|
}),
|
|
}
|
|
|
|
const watcher = lazy((): typeof import("@parcel/watcher") | undefined => {
|
|
try {
|
|
const binding = require(
|
|
`@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${OPENCODE_LIBC || "glibc"}` : ""}`,
|
|
)
|
|
return createWrapper(binding) as typeof import("@parcel/watcher")
|
|
} catch (error) {
|
|
log.error("failed to load watcher binding", { error })
|
|
return
|
|
}
|
|
})
|
|
|
|
function getBackend() {
|
|
if (process.platform === "win32") return "windows"
|
|
if (process.platform === "darwin") return "fs-events"
|
|
if (process.platform === "linux") return "inotify"
|
|
}
|
|
|
|
function protecteds(dir: string) {
|
|
return Protected.paths().filter((item) => {
|
|
const rel = path.relative(dir, item)
|
|
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel)
|
|
})
|
|
}
|
|
|
|
export const hasNativeBinding = () => !!watcher()
|
|
|
|
export interface Interface {
|
|
readonly init: () => Effect.Effect<void>
|
|
}
|
|
|
|
export class Service extends Context.Service<Service, Interface>()("@opencode/FileWatcher") {}
|
|
|
|
export const layer = Layer.effect(
|
|
Service,
|
|
Effect.gen(function* () {
|
|
const config = yield* Config.Service
|
|
const git = yield* Git.Service
|
|
const events = yield* EventV2Bridge.Service
|
|
|
|
const state = yield* InstanceState.make(
|
|
Effect.fn("FileWatcher.state")(
|
|
function* () {
|
|
if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return
|
|
|
|
const ctx = yield* InstanceState.context
|
|
|
|
log.info("init", { directory: ctx.directory })
|
|
|
|
const backend = getBackend()
|
|
if (!backend) {
|
|
log.error("watcher backend not supported", { directory: ctx.directory, platform: process.platform })
|
|
return
|
|
}
|
|
|
|
const w = watcher()
|
|
if (!w) return
|
|
|
|
log.info("watcher backend", { directory: ctx.directory, platform: process.platform, backend })
|
|
const bridge = yield* EffectBridge.make()
|
|
const subs: ParcelWatcher.AsyncSubscription[] = []
|
|
yield* Effect.addFinalizer(() =>
|
|
Effect.promise(() => Promise.allSettled(subs.map((sub) => sub.unsubscribe()))),
|
|
)
|
|
|
|
const cb: ParcelWatcher.SubscribeCallback = bridge.bind((err, evts) => {
|
|
// if (err) return
|
|
for (const evt of evts) {
|
|
if (evt.type === "create") bridge.fork(events.publish(Event.Updated, { file: evt.path, event: "add" }))
|
|
if (evt.type === "update") bridge.fork(events.publish(Event.Updated, { file: evt.path, event: "change" }))
|
|
if (evt.type === "delete") bridge.fork(events.publish(Event.Updated, { file: evt.path, event: "unlink" }))
|
|
}
|
|
})
|
|
|
|
const subscribe = (dir: string, ignore: string[]) => {
|
|
const pending = w.subscribe(dir, cb, { ignore, backend })
|
|
return Effect.gen(function* () {
|
|
const sub = yield* Effect.promise(() => pending)
|
|
subs.push(sub)
|
|
}).pipe(
|
|
Effect.timeout(SUBSCRIBE_TIMEOUT_MS),
|
|
Effect.catchCause((cause) => {
|
|
log.error("failed to subscribe", { dir, cause: Cause.pretty(cause) })
|
|
pending.then((s) => s.unsubscribe()).catch(() => {})
|
|
return Effect.void
|
|
}),
|
|
)
|
|
}
|
|
|
|
const cfg = yield* config.get()
|
|
const cfgIgnores = cfg.watcher?.ignore ?? []
|
|
|
|
if (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) {
|
|
yield* Effect.forkScoped(
|
|
subscribe(ctx.directory, [...FileIgnore.PATTERNS, ...cfgIgnores, ...protecteds(ctx.directory)]),
|
|
)
|
|
}
|
|
|
|
if (ctx.project.vcs === "git") {
|
|
const result = yield* git.run(["rev-parse", "--git-dir"], {
|
|
cwd: ctx.worktree,
|
|
})
|
|
const resolved = result.exitCode === 0 ? path.resolve(ctx.worktree, result.text().trim()) : undefined
|
|
const vcsDir = resolved ? yield* Effect.promise(() => realpath(resolved).catch(() => resolved)) : undefined
|
|
if (
|
|
vcsDir &&
|
|
!cfgIgnores.includes(".git") &&
|
|
!cfgIgnores.includes(vcsDir) &&
|
|
(!resolved || !cfgIgnores.includes(resolved))
|
|
) {
|
|
const ignore = (yield* Effect.promise(() => readdir(vcsDir).catch(() => []))).filter(
|
|
(entry) => entry !== "HEAD",
|
|
)
|
|
yield* Effect.forkScoped(subscribe(vcsDir, ignore))
|
|
}
|
|
}
|
|
},
|
|
Effect.catchCause((cause) => {
|
|
log.error("failed to init watcher service", { cause: Cause.pretty(cause) })
|
|
return Effect.void
|
|
}),
|
|
),
|
|
)
|
|
|
|
return Service.of({
|
|
init: Effect.fn("FileWatcher.init")(function* () {
|
|
yield* InstanceState.get(state)
|
|
}),
|
|
})
|
|
}),
|
|
)
|
|
|
|
export const defaultLayer = layer.pipe(
|
|
Layer.provide(Config.defaultLayer),
|
|
Layer.provide(Git.defaultLayer),
|
|
Layer.provide(EventV2Bridge.defaultLayer),
|
|
)
|
|
|
|
export * as FileWatcher from "./watcher"
|