diff --git a/packages/opencode/AGENTS.md b/packages/opencode/AGENTS.md index f28150622..e2a0c918d 100644 --- a/packages/opencode/AGENTS.md +++ b/packages/opencode/AGENTS.md @@ -9,71 +9,55 @@ - **Output**: creates `migration/_/migration.sql` and `snapshot.json`. - **Tests**: migration tests should read the per-folder layout (no `_journal.json`). -# opencode Effect guide +# opencode Effect rules -Instructions to follow when writing Effect. +Use these rules when writing or migrating Effect code. -## Schemas +See `specs/effect-migration.md` for the compact pattern reference and examples. -- Use `Schema.Class` for data types with multiple fields. -- Use branded schemas (`Schema.brand`) for single-value types. - -## Services - -- Services use `ServiceMap.Service()("@console/")`. -- In `Layer.effect`, always return service implementations with `ServiceName.of({ ... })`, never a plain object. - -## Errors - -- Use `Schema.TaggedErrorClass` for typed errors. -- For defect-like causes, use `Schema.Defect` instead of `unknown`. -- In `Effect.gen`, prefer `yield* new MyError(...)` over `yield* Effect.fail(new MyError(...))` for direct early-failure branches. - -## Effects +## Core - Use `Effect.gen(function* () { ... })` for composition. -- Use `Effect.fn("ServiceName.method")` for named/traced effects and `Effect.fnUntraced` for internal helpers. -- `Effect.fn` / `Effect.fnUntraced` accept pipeable operators as extra arguments, so avoid unnecessary `flow` or outer `.pipe()` wrappers. -- **`Effect.callback`** (not `Effect.async`) for callback-based APIs. The classic `Effect.async` was renamed to `Effect.callback` in effect-smol/v4. - -## Time - +- Use `Effect.fn("Domain.method")` for named/traced effects and `Effect.fnUntraced` for internal helpers. +- `Effect.fn` / `Effect.fnUntraced` accept pipeable operators as extra arguments, so avoid unnecessary outer `.pipe()` wrappers. +- Use `Effect.callback` for callback-based APIs. - Prefer `DateTime.nowAsDate` over `new Date(yield* Clock.currentTimeMillis)` when you need a `Date`. -## Errors +## Schemas and errors -- In `Effect.gen/fn`, prefer `yield* new MyError(...)` over `yield* Effect.fail(new MyError(...))` for direct early-failure branches. +- Use `Schema.Class` for multi-field data. +- Use branded schemas (`Schema.brand`) for single-value types. +- Use `Schema.TaggedErrorClass` for typed errors. +- Use `Schema.Defect` instead of `unknown` for defect-like causes. +- In `Effect.gen` / `Effect.fn`, prefer `yield* new MyError(...)` over `yield* Effect.fail(new MyError(...))` for direct early-failure branches. -## Instance-scoped Effect services +## Runtime vs Instances -Services that need per-directory lifecycle (created/destroyed per instance) go through the `Instances` LayerMap: +- Use the shared runtime for process-wide services with one lifecycle for the whole app. +- Use `src/effect/instances.ts` for per-directory or per-project services that need `InstanceContext`, per-instance state, or per-instance cleanup. +- If two open directories should not share one copy of the service, it belongs in `Instances`. +- Instance-scoped services should read context from `InstanceContext`, not `Instance.*` globals. -1. Define a `ServiceMap.Service` with a `static readonly layer` (see `FileWatcherService`, `QuestionService`, `PermissionService`, `ProviderAuthService`). -2. Add it to `InstanceServices` union and `Layer.mergeAll(...)` in `src/effect/instances.ts`. -3. Use `InstanceContext` inside the layer to read `directory` and `project` instead of `Instance.*` globals. -4. Call from legacy code via `runPromiseInstance(MyService.use((s) => s.method()))`. +## Preferred Effect services -### Instance.bind — ALS context for native callbacks +- In effectified services, prefer yielding existing Effect services over dropping down to ad hoc platform APIs. +- Prefer `FileSystem.FileSystem` instead of raw `fs/promises` for effectful file I/O. +- Prefer `ChildProcessSpawner.ChildProcessSpawner` with `ChildProcess.make(...)` instead of custom process wrappers. +- Prefer `HttpClient.HttpClient` instead of raw `fetch`. +- Prefer `Path.Path`, `Config`, `Clock`, and `DateTime` when those concerns are already inside Effect code. +- For background loops or scheduled tasks, use `Effect.repeat` or `Effect.schedule` with `Effect.forkScoped` in the layer definition. -`Instance.bind(fn)` captures the current Instance AsyncLocalStorage context and returns a wrapper that restores it synchronously when called. +## Instance.bind — ALS for native callbacks -**Use it** when passing callbacks to native C/C++ addons (`@parcel/watcher`, `node-pty`, native `fs.watch`, etc.) that need to call `Bus.publish`, `Instance.state()`, or anything that reads `Instance.directory`. +`Instance.bind(fn)` captures the current Instance AsyncLocalStorage context and restores it synchronously when called. -**Don't need it** for `setTimeout`, `Promise.then`, `EventEmitter.on`, or Effect fibers — Node.js ALS propagates through those automatically. +Use it for native addon callbacks (`@parcel/watcher`, `node-pty`, native `fs.watch`, etc.) that need to call `Bus.publish`, `Instance.state()`, or anything that reads `Instance.directory`. + +You do not need it for `setTimeout`, `Promise.then`, `EventEmitter.on`, or Effect fibers. ```typescript -// Native addon callback — needs Instance.bind const cb = Instance.bind((err, evts) => { Bus.publish(MyEvent, { ... }) }) nativeAddon.subscribe(dir, cb) ``` - -## Flag → Effect.Config migration - -Flags in `src/flag/flag.ts` are being migrated from static `truthy(...)` reads to `Config.boolean(...).pipe(Config.withDefault(false))` as their consumers get effectified. - -- Effectful flags return `Config` and are read with `yield*` inside `Effect.gen`. -- The default `ConfigProvider` reads from `process.env`, so env vars keep working. -- Tests can override via `ConfigProvider.layer(ConfigProvider.fromUnknown({ ... }))`. -- Keep all flags in `flag.ts` as the single registry — just change the implementation from `truthy()` to `Config.boolean()` when the consumer moves to Effect. diff --git a/packages/opencode/specs/effect-migration.md b/packages/opencode/specs/effect-migration.md new file mode 100644 index 000000000..4f195917f --- /dev/null +++ b/packages/opencode/specs/effect-migration.md @@ -0,0 +1,144 @@ +# Effect patterns + +Practical reference for new and migrated Effect code in `packages/opencode`. + +## Choose scope + +Use the shared runtime for process-wide services with one lifecycle for the whole app. + +Use `src/effect/instances.ts` for services that are created per directory or need `InstanceContext`, per-project state, or per-instance cleanup. + +- Shared runtime: config readers, stateless helpers, global clients +- Instance-scoped: watchers, per-project caches, session state, project-bound background work + +Rule of thumb: if two open directories should not share one copy of the service, it belongs in `Instances`. + +## Service shape + +For a fully migrated module, use the public namespace directly: + +```ts +export namespace Foo { + export interface Interface { + readonly get: (id: FooID) => Effect.Effect + } + + export class Service extends ServiceMap.Service()("@opencode/Foo") {} + + export const layer = Layer.effect( + Service, + Effect.gen(function* () { + return Service.of({ + get: Effect.fn("Foo.get")(function* (id) { + return yield* ... + }), + }) + }), + ) + + export const defaultLayer = layer.pipe(Layer.provide(FooRepo.defaultLayer)) +} +``` + +Rules: + +- Keep `Interface`, `Service`, `layer`, and `defaultLayer` on the owning namespace +- Export `defaultLayer` only when wiring dependencies is useful +- Use the direct namespace form once the module is fully migrated + +## Temporary mixed-mode pattern + +Prefer a single namespace whenever possible. + +Use a `*Effect` namespace only when there is a real mixed-mode split, usually because a legacy boundary facade still exists or because merging everything immediately would create awkward cycles. + +```ts +export namespace FooEffect { + export interface Interface { + readonly get: (id: FooID) => Effect.Effect + } + + export class Service extends ServiceMap.Service()("@opencode/Foo") {} + + export const layer = Layer.effect(...) +} +``` + +Then keep the old boundary thin: + +```ts +export namespace Foo { + export function get(id: FooID) { + return runtime.runPromise(FooEffect.Service.use((svc) => svc.get(id))) + } +} +``` + +Remove the `Effect` suffix when the boundary split is gone. + +## Scheduled Tasks + +For loops or periodic work, use `Effect.repeat` or `Effect.schedule` with `Effect.forkScoped` in the layer definition. + +## Preferred Effect services + +In effectified services, prefer yielding existing Effect services over dropping down to ad hoc platform APIs. + +Prefer these first: + +- `FileSystem.FileSystem` instead of raw `fs/promises` for effectful file I/O +- `ChildProcessSpawner.ChildProcessSpawner` with `ChildProcess.make(...)` instead of custom process wrappers +- `HttpClient.HttpClient` instead of raw `fetch` +- `Path.Path` instead of mixing path helpers into service code when you already need a path service +- `Config` for effect-native configuration reads +- `Clock` / `DateTime` for time reads inside effects + +## Child processes + +For child process work in services, yield `ChildProcessSpawner.ChildProcessSpawner` in the layer and use `ChildProcess.make(...)`. + +Keep shelling-out code inside the service, not in callers. + +## Shared leaf models + +Shared schema or model files can stay outside the service namespace when lower layers also depend on them. + +That is fine for leaf files like `schema.ts`. Keep the service surface in the owning namespace. + +## Migration checklist + +Done now: + +- [x] `AccountEffect` (mixed-mode) +- [x] `AuthEffect` (mixed-mode) +- [x] `TruncateEffect` (mixed-mode) +- [x] `Question` +- [x] `PermissionNext` +- [x] `ProviderAuth` +- [x] `FileWatcher` +- [x] `FileTime` +- [x] `Format` +- [x] `Vcs` +- [x] `Skill` +- [x] `Discovery` +- [x] `File` +- [x] `Snapshot` + +Still open and likely worth migrating: + +- [ ] `Plugin` +- [ ] `ToolRegistry` +- [ ] `Pty` +- [ ] `Worktree` +- [ ] `Installation` +- [ ] `Bus` +- [ ] `Command` +- [ ] `Config` +- [ ] `Session` +- [ ] `SessionProcessor` +- [ ] `SessionPrompt` +- [ ] `SessionCompaction` +- [ ] `Provider` +- [ ] `Project` +- [ ] `LSP` +- [ ] `MCP` diff --git a/packages/opencode/src/account/service.ts b/packages/opencode/src/account/effect.ts similarity index 91% rename from packages/opencode/src/account/service.ts rename to packages/opencode/src/account/effect.ts index 87e95c8f4..444676046 100644 --- a/packages/opencode/src/account/service.ts +++ b/packages/opencode/src/account/effect.ts @@ -108,8 +108,8 @@ const mapAccountServiceError = ), ) -export namespace AccountService { - export interface Service { +export namespace AccountEffect { + export interface Interface { readonly active: () => Effect.Effect, AccountError> readonly list: () => Effect.Effect readonly orgsByAccount: () => Effect.Effect @@ -124,11 +124,11 @@ export namespace AccountService { readonly login: (url: string) => Effect.Effect readonly poll: (input: Login) => Effect.Effect } -} -export class AccountService extends ServiceMap.Service()("@opencode/Account") { - static readonly layer: Layer.Layer = Layer.effect( - AccountService, + export class Service extends ServiceMap.Service()("@opencode/Account") {} + + export const layer: Layer.Layer = Layer.effect( + Service, Effect.gen(function* () { const repo = yield* AccountRepo const http = yield* HttpClient.HttpClient @@ -148,8 +148,6 @@ export class AccountService extends ServiceMap.Service now) return row.access_token @@ -218,11 +216,11 @@ export class AccountService extends ServiceMap.Service + const token = Effect.fn("Account.token")((accountID: AccountID) => resolveAccess(accountID).pipe(Effect.map(Option.map((r) => r.accessToken))), ) - const orgsByAccount = Effect.fn("AccountService.orgsByAccount")(function* () { + const orgsByAccount = Effect.fn("Account.orgsByAccount")(function* () { const accounts = yield* repo.list() const [errors, results] = yield* Effect.partition( accounts, @@ -237,7 +235,7 @@ export class AccountService extends ServiceMap.Service(f: (service: AccountService.Service) => Effect.Effect) { - return runtime.runSync(AccountService.use(f)) +function runSync(f: (service: AccountEffect.Interface) => Effect.Effect) { + return runtime.runSync(AccountEffect.Service.use(f)) } -function runPromise(f: (service: AccountService.Service) => Effect.Effect) { - return runtime.runPromise(AccountService.use(f)) +function runPromise(f: (service: AccountEffect.Interface) => Effect.Effect) { + return runtime.runPromise(AccountEffect.Service.use(f)) } export namespace Account { diff --git a/packages/opencode/src/auth/service.ts b/packages/opencode/src/auth/effect.ts similarity index 70% rename from packages/opencode/src/auth/service.ts rename to packages/opencode/src/auth/effect.ts index 100a132b8..043b9002e 100644 --- a/packages/opencode/src/auth/service.ts +++ b/packages/opencode/src/auth/effect.ts @@ -28,31 +28,31 @@ export class WellKnown extends Schema.Class("WellKnownAuth")({ export const Info = Schema.Union([Oauth, Api, WellKnown]) export type Info = Schema.Schema.Type -export class AuthServiceError extends Schema.TaggedErrorClass()("AuthServiceError", { +export class AuthError extends Schema.TaggedErrorClass()("AuthError", { message: Schema.String, cause: Schema.optional(Schema.Defect), }) {} const file = path.join(Global.Path.data, "auth.json") -const fail = (message: string) => (cause: unknown) => new AuthServiceError({ message, cause }) +const fail = (message: string) => (cause: unknown) => new AuthError({ message, cause }) -export namespace AuthService { - export interface Service { - readonly get: (providerID: string) => Effect.Effect - readonly all: () => Effect.Effect, AuthServiceError> - readonly set: (key: string, info: Info) => Effect.Effect - readonly remove: (key: string) => Effect.Effect +export namespace AuthEffect { + export interface Interface { + readonly get: (providerID: string) => Effect.Effect + readonly all: () => Effect.Effect, AuthError> + readonly set: (key: string, info: Info) => Effect.Effect + readonly remove: (key: string) => Effect.Effect } -} -export class AuthService extends ServiceMap.Service()("@opencode/Auth") { - static readonly layer = Layer.effect( - AuthService, + export class Service extends ServiceMap.Service()("@opencode/Auth") {} + + export const layer = Layer.effect( + Service, Effect.gen(function* () { const decode = Schema.decodeUnknownOption(Info) - const all = Effect.fn("AuthService.all")(() => + const all = Effect.fn("Auth.all")(() => Effect.tryPromise({ try: async () => { const data = await Filesystem.readJson>(file).catch(() => ({})) @@ -62,11 +62,11 @@ export class AuthService extends ServiceMap.Service(f: (service: S.AuthService.Service) => Effect.Effect) { - return runtime.runPromise(S.AuthService.use(f)) +function runPromise(f: (service: S.AuthEffect.Interface) => Effect.Effect) { + return runtime.runPromise(S.AuthEffect.Service.use(f)) } export namespace Auth { diff --git a/packages/opencode/src/cli/cmd/account.ts b/packages/opencode/src/cli/cmd/account.ts index b2256837d..c2b47da11 100644 --- a/packages/opencode/src/cli/cmd/account.ts +++ b/packages/opencode/src/cli/cmd/account.ts @@ -2,7 +2,7 @@ import { cmd } from "./cmd" import { Duration, Effect, Match, Option } from "effect" import { UI } from "../ui" import { runtime } from "@/effect/runtime" -import { AccountID, AccountService, OrgID, PollExpired, type PollResult } from "@/account/service" +import { AccountID, AccountEffect, OrgID, PollExpired, type PollResult } from "@/account/effect" import { type AccountError } from "@/account/schema" import * as Prompt from "../effect/prompt" import open from "open" @@ -17,7 +17,7 @@ const isActiveOrgChoice = ( ) => Option.isSome(active) && active.value.id === choice.accountID && active.value.active_org_id === choice.orgID const loginEffect = Effect.fn("login")(function* (url: string) { - const service = yield* AccountService + const service = yield* AccountEffect.Service yield* Prompt.intro("Log in") const login = yield* service.login(url) @@ -58,7 +58,7 @@ const loginEffect = Effect.fn("login")(function* (url: string) { }) const logoutEffect = Effect.fn("logout")(function* (email?: string) { - const service = yield* AccountService + const service = yield* AccountEffect.Service const accounts = yield* service.list() if (accounts.length === 0) return yield* println("Not logged in") @@ -98,7 +98,7 @@ interface OrgChoice { } const switchEffect = Effect.fn("switch")(function* () { - const service = yield* AccountService + const service = yield* AccountEffect.Service const groups = yield* service.orgsByAccount() if (groups.length === 0) return yield* println("Not logged in") @@ -129,7 +129,7 @@ const switchEffect = Effect.fn("switch")(function* () { }) const orgsEffect = Effect.fn("orgs")(function* () { - const service = yield* AccountService + const service = yield* AccountEffect.Service const groups = yield* service.orgsByAccount() if (groups.length === 0) return yield* println("No accounts found") diff --git a/packages/opencode/src/effect/instances.ts b/packages/opencode/src/effect/instances.ts index 3a1fb0cdf..c05458d5d 100644 --- a/packages/opencode/src/effect/instances.ts +++ b/packages/opencode/src/effect/instances.ts @@ -1,31 +1,31 @@ import { Effect, Layer, LayerMap, ServiceMap } from "effect" -import { FileService } from "@/file" -import { FileTimeService } from "@/file/time" -import { FileWatcherService } from "@/file/watcher" -import { FormatService } from "@/format" -import { PermissionEffect } from "@/permission/service" +import { File } from "@/file" +import { FileTime } from "@/file/time" +import { FileWatcher } from "@/file/watcher" +import { Format } from "@/format" +import { PermissionNext } from "@/permission" import { Instance } from "@/project/instance" -import { VcsService } from "@/project/vcs" -import { ProviderAuthService } from "@/provider/auth-service" -import { QuestionService } from "@/question/service" -import { SkillService } from "@/skill/skill" -import { SnapshotService } from "@/snapshot" +import { Vcs } from "@/project/vcs" +import { ProviderAuth } from "@/provider/auth" +import { Question } from "@/question" +import { Skill } from "@/skill/skill" +import { Snapshot } from "@/snapshot" import { InstanceContext } from "./instance-context" import { registerDisposer } from "./instance-registry" export { InstanceContext } from "./instance-context" export type InstanceServices = - | QuestionService - | PermissionEffect.Service - | ProviderAuthService - | FileWatcherService - | VcsService - | FileTimeService - | FormatService - | FileService - | SkillService - | SnapshotService + | Question.Service + | PermissionNext.Service + | ProviderAuth.Service + | FileWatcher.Service + | Vcs.Service + | FileTime.Service + | Format.Service + | File.Service + | Skill.Service + | Snapshot.Service // NOTE: LayerMap only passes the key (directory string) to lookup, but we need // the full instance context (directory, worktree, project). We read from the @@ -36,16 +36,16 @@ export type InstanceServices = function lookup(_key: string) { const ctx = Layer.sync(InstanceContext, () => InstanceContext.of(Instance.current)) return Layer.mergeAll( - Layer.fresh(QuestionService.layer), - Layer.fresh(PermissionEffect.layer), - Layer.fresh(ProviderAuthService.layer), - Layer.fresh(FileWatcherService.layer).pipe(Layer.orDie), - Layer.fresh(VcsService.layer), - Layer.fresh(FileTimeService.layer).pipe(Layer.orDie), - Layer.fresh(FormatService.layer), - Layer.fresh(FileService.layer), - Layer.fresh(SkillService.layer), - Layer.fresh(SnapshotService.layer), + Layer.fresh(Question.layer), + Layer.fresh(PermissionNext.layer), + Layer.fresh(ProviderAuth.defaultLayer), + Layer.fresh(FileWatcher.layer).pipe(Layer.orDie), + Layer.fresh(Vcs.layer), + Layer.fresh(FileTime.layer).pipe(Layer.orDie), + Layer.fresh(Format.layer), + Layer.fresh(File.layer), + Layer.fresh(Skill.defaultLayer), + Layer.fresh(Snapshot.defaultLayer), ).pipe(Layer.provide(ctx)) } @@ -55,9 +55,7 @@ export class Instances extends ServiceMap.Service Effect.runPromise(layerMap.invalidate(directory))) yield* Effect.addFinalizer(() => Effect.sync(unregister)) return Instances.of(layerMap) diff --git a/packages/opencode/src/effect/runtime.ts b/packages/opencode/src/effect/runtime.ts index a55956bfd..f52203b22 100644 --- a/packages/opencode/src/effect/runtime.ts +++ b/packages/opencode/src/effect/runtime.ts @@ -1,6 +1,6 @@ import { Effect, Layer, ManagedRuntime } from "effect" -import { AccountService } from "@/account/service" -import { AuthService } from "@/auth/service" +import { AccountEffect } from "@/account/effect" +import { AuthEffect } from "@/auth/effect" import { Instances } from "@/effect/instances" import type { InstanceServices } from "@/effect/instances" import { TruncateEffect } from "@/tool/truncate-effect" @@ -8,10 +8,10 @@ import { Instance } from "@/project/instance" export const runtime = ManagedRuntime.make( Layer.mergeAll( - AccountService.defaultLayer, // + AccountEffect.defaultLayer, // TruncateEffect.defaultLayer, Instances.layer, - ).pipe(Layer.provideMerge(AuthService.defaultLayer)), + ).pipe(Layer.provideMerge(AuthEffect.layer)), ) export function runPromiseInstance(effect: Effect.Effect) { diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index cee03e091..6e9b91727 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -1,272 +1,20 @@ import { BusEvent } from "@/bus/bus-event" -import z from "zod" -import { formatPatch, structuredPatch } from "diff" -import path from "path" -import fs from "fs" -import ignore from "ignore" -import { Log } from "../util/log" -import { Filesystem } from "../util/filesystem" -import { Instance } from "../project/instance" -import { Ripgrep } from "./ripgrep" -import fuzzysort from "fuzzysort" -import { Global } from "../global" -import { git } from "@/util/git" -import { Protected } from "./protected" import { InstanceContext } from "@/effect/instance-context" -import { Effect, Layer, ServiceMap } from "effect" import { runPromiseInstance } from "@/effect/runtime" - -const log = Log.create({ service: "file" }) - -const binaryExtensions = new Set([ - "exe", - "dll", - "pdb", - "bin", - "so", - "dylib", - "o", - "a", - "lib", - "wav", - "mp3", - "ogg", - "oga", - "ogv", - "ogx", - "flac", - "aac", - "wma", - "m4a", - "weba", - "mp4", - "avi", - "mov", - "wmv", - "flv", - "webm", - "mkv", - "zip", - "tar", - "gz", - "gzip", - "bz", - "bz2", - "bzip", - "bzip2", - "7z", - "rar", - "xz", - "lz", - "z", - "pdf", - "doc", - "docx", - "ppt", - "pptx", - "xls", - "xlsx", - "dmg", - "iso", - "img", - "vmdk", - "ttf", - "otf", - "woff", - "woff2", - "eot", - "sqlite", - "db", - "mdb", - "apk", - "ipa", - "aab", - "xapk", - "app", - "pkg", - "deb", - "rpm", - "snap", - "flatpak", - "appimage", - "msi", - "msp", - "jar", - "war", - "ear", - "class", - "kotlin_module", - "dex", - "vdex", - "odex", - "oat", - "art", - "wasm", - "wat", - "bc", - "ll", - "s", - "ko", - "sys", - "drv", - "efi", - "rom", - "com", - "cmd", - "ps1", - "sh", - "bash", - "zsh", - "fish", -]) - -const imageExtensions = new Set([ - "png", - "jpg", - "jpeg", - "gif", - "bmp", - "webp", - "ico", - "tif", - "tiff", - "svg", - "svgz", - "avif", - "apng", - "jxl", - "heic", - "heif", - "raw", - "cr2", - "nef", - "arw", - "dng", - "orf", - "raf", - "pef", - "x3f", -]) - -const textExtensions = new Set([ - "ts", - "tsx", - "mts", - "cts", - "mtsx", - "ctsx", - "js", - "jsx", - "mjs", - "cjs", - "sh", - "bash", - "zsh", - "fish", - "ps1", - "psm1", - "cmd", - "bat", - "json", - "jsonc", - "json5", - "yaml", - "yml", - "toml", - "md", - "mdx", - "txt", - "xml", - "html", - "htm", - "css", - "scss", - "sass", - "less", - "graphql", - "gql", - "sql", - "ini", - "cfg", - "conf", - "env", -]) - -const textNames = new Set([ - "dockerfile", - "makefile", - ".gitignore", - ".gitattributes", - ".editorconfig", - ".npmrc", - ".nvmrc", - ".prettierrc", - ".eslintrc", -]) - -function isImageByExtension(filepath: string): boolean { - const ext = path.extname(filepath).toLowerCase().slice(1) - return imageExtensions.has(ext) -} - -function isTextByExtension(filepath: string): boolean { - const ext = path.extname(filepath).toLowerCase().slice(1) - return textExtensions.has(ext) -} - -function isTextByName(filepath: string): boolean { - const name = path.basename(filepath).toLowerCase() - return textNames.has(name) -} - -function getImageMimeType(filepath: string): string { - const ext = path.extname(filepath).toLowerCase().slice(1) - const mimeTypes: Record = { - png: "image/png", - jpg: "image/jpeg", - jpeg: "image/jpeg", - gif: "image/gif", - bmp: "image/bmp", - webp: "image/webp", - ico: "image/x-icon", - tif: "image/tiff", - tiff: "image/tiff", - svg: "image/svg+xml", - svgz: "image/svg+xml", - avif: "image/avif", - apng: "image/apng", - jxl: "image/jxl", - heic: "image/heic", - heif: "image/heif", - } - return mimeTypes[ext] || "image/" + ext -} - -function isBinaryByExtension(filepath: string): boolean { - const ext = path.extname(filepath).toLowerCase().slice(1) - return binaryExtensions.has(ext) -} - -function isImage(mimeType: string): boolean { - return mimeType.startsWith("image/") -} - -function shouldEncode(mimeType: string): boolean { - const type = mimeType.toLowerCase() - log.info("shouldEncode", { type }) - if (!type) return false - - if (type.startsWith("text/")) return false - if (type.includes("charset=")) return false - - const parts = type.split("/", 2) - const top = parts[0] - - const tops = ["image", "audio", "video", "font", "model", "multipart"] - if (tops.includes(top)) return true - - return false -} +import { git } from "@/util/git" +import { Effect, Fiber, Layer, Scope, ServiceMap } from "effect" +import { formatPatch, structuredPatch } from "diff" +import fs from "fs" +import fuzzysort from "fuzzysort" +import ignore from "ignore" +import path from "path" +import z from "zod" +import { Global } from "../global" +import { Instance } from "../project/instance" +import { Filesystem } from "../util/filesystem" +import { Log } from "../util/log" +import { Protected } from "./protected" +import { Ripgrep } from "./ripgrep" export namespace File { export const Info = z @@ -336,28 +84,270 @@ export namespace File { } export function init() { - return runPromiseInstance(FileService.use((s) => s.init())) + return runPromiseInstance(Service.use((svc) => svc.init())) } export async function status() { - return runPromiseInstance(FileService.use((s) => s.status())) + return runPromiseInstance(Service.use((svc) => svc.status())) } export async function read(file: string): Promise { - return runPromiseInstance(FileService.use((s) => s.read(file))) + return runPromiseInstance(Service.use((svc) => svc.read(file))) } export async function list(dir?: string) { - return runPromiseInstance(FileService.use((s) => s.list(dir))) + return runPromiseInstance(Service.use((svc) => svc.list(dir))) } export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) { - return runPromiseInstance(FileService.use((s) => s.search(input))) + return runPromiseInstance(Service.use((svc) => svc.search(input))) } -} -export namespace FileService { - export interface Service { + const log = Log.create({ service: "file" }) + + const binary = new Set([ + "exe", + "dll", + "pdb", + "bin", + "so", + "dylib", + "o", + "a", + "lib", + "wav", + "mp3", + "ogg", + "oga", + "ogv", + "ogx", + "flac", + "aac", + "wma", + "m4a", + "weba", + "mp4", + "avi", + "mov", + "wmv", + "flv", + "webm", + "mkv", + "zip", + "tar", + "gz", + "gzip", + "bz", + "bz2", + "bzip", + "bzip2", + "7z", + "rar", + "xz", + "lz", + "z", + "pdf", + "doc", + "docx", + "ppt", + "pptx", + "xls", + "xlsx", + "dmg", + "iso", + "img", + "vmdk", + "ttf", + "otf", + "woff", + "woff2", + "eot", + "sqlite", + "db", + "mdb", + "apk", + "ipa", + "aab", + "xapk", + "app", + "pkg", + "deb", + "rpm", + "snap", + "flatpak", + "appimage", + "msi", + "msp", + "jar", + "war", + "ear", + "class", + "kotlin_module", + "dex", + "vdex", + "odex", + "oat", + "art", + "wasm", + "wat", + "bc", + "ll", + "s", + "ko", + "sys", + "drv", + "efi", + "rom", + "com", + "cmd", + "ps1", + "sh", + "bash", + "zsh", + "fish", + ]) + + const image = new Set([ + "png", + "jpg", + "jpeg", + "gif", + "bmp", + "webp", + "ico", + "tif", + "tiff", + "svg", + "svgz", + "avif", + "apng", + "jxl", + "heic", + "heif", + "raw", + "cr2", + "nef", + "arw", + "dng", + "orf", + "raf", + "pef", + "x3f", + ]) + + const text = new Set([ + "ts", + "tsx", + "mts", + "cts", + "mtsx", + "ctsx", + "js", + "jsx", + "mjs", + "cjs", + "sh", + "bash", + "zsh", + "fish", + "ps1", + "psm1", + "cmd", + "bat", + "json", + "jsonc", + "json5", + "yaml", + "yml", + "toml", + "md", + "mdx", + "txt", + "xml", + "html", + "htm", + "css", + "scss", + "sass", + "less", + "graphql", + "gql", + "sql", + "ini", + "cfg", + "conf", + "env", + ]) + + const textName = new Set([ + "dockerfile", + "makefile", + ".gitignore", + ".gitattributes", + ".editorconfig", + ".npmrc", + ".nvmrc", + ".prettierrc", + ".eslintrc", + ]) + + const mime: Record = { + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + bmp: "image/bmp", + webp: "image/webp", + ico: "image/x-icon", + tif: "image/tiff", + tiff: "image/tiff", + svg: "image/svg+xml", + svgz: "image/svg+xml", + avif: "image/avif", + apng: "image/apng", + jxl: "image/jxl", + heic: "image/heic", + heif: "image/heif", + } + + type Entry = { files: string[]; dirs: string[] } + + const ext = (file: string) => path.extname(file).toLowerCase().slice(1) + const name = (file: string) => path.basename(file).toLowerCase() + const isImageByExtension = (file: string) => image.has(ext(file)) + const isTextByExtension = (file: string) => text.has(ext(file)) + const isTextByName = (file: string) => textName.has(name(file)) + const isBinaryByExtension = (file: string) => binary.has(ext(file)) + const isImage = (mimeType: string) => mimeType.startsWith("image/") + const getImageMimeType = (file: string) => mime[ext(file)] || "image/" + ext(file) + + function shouldEncode(mimeType: string) { + const type = mimeType.toLowerCase() + log.info("shouldEncode", { type }) + if (!type) return false + if (type.startsWith("text/")) return false + if (type.includes("charset=")) return false + const top = type.split("/", 2)[0] + return ["image", "audio", "video", "font", "model", "multipart"].includes(top) + } + + const hidden = (item: string) => { + const normalized = item.replaceAll("\\", "/").replace(/\/+$/, "") + return normalized.split("/").some((part) => part.startsWith(".") && part.length > 1) + } + + const sortHiddenLast = (items: string[], prefer: boolean) => { + if (prefer) return items + const visible: string[] = [] + const hiddenItems: string[] = [] + for (const item of items) { + if (hidden(item)) hiddenItems.push(item) + else visible.push(item) + } + return [...visible, ...hiddenItems] + } + + export interface Interface { readonly init: () => Effect.Effect readonly status: () => Effect.Effect readonly read: (file: string) => Effect.Effect @@ -369,89 +359,83 @@ export namespace FileService { type?: "file" | "directory" }) => Effect.Effect } -} -export class FileService extends ServiceMap.Service()("@opencode/File") { - static readonly layer = Layer.effect( - FileService, + export class Service extends ServiceMap.Service()("@opencode/File") {} + + export const layer = Layer.effect( + Service, Effect.gen(function* () { const instance = yield* InstanceContext - - // File cache state - type Entry = { files: string[]; dirs: string[] } let cache: Entry = { files: [], dirs: [] } - let task: Promise | undefined - const isGlobalHome = instance.directory === Global.Path.home && instance.project.id === "global" - function kick() { - if (task) return task - task = (async () => { - // Disable scanning if in root of file system - if (instance.directory === path.parse(instance.directory).root) return - const next: Entry = { files: [], dirs: [] } - try { - if (isGlobalHome) { - const dirs = new Set() - const protectedNames = Protected.names() + const scan = Effect.fn("File.scan")(function* () { + if (instance.directory === path.parse(instance.directory).root) return + const next: Entry = { files: [], dirs: [] } - const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"]) - const shouldIgnoreName = (name: string) => name.startsWith(".") || protectedNames.has(name) - const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name) + yield* Effect.promise(async () => { + if (isGlobalHome) { + const dirs = new Set() + const protectedNames = Protected.names() + const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"]) + const shouldIgnoreName = (name: string) => name.startsWith(".") || protectedNames.has(name) + const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name) + const top = await fs.promises + .readdir(instance.directory, { withFileTypes: true }) + .catch(() => [] as fs.Dirent[]) - const top = await fs.promises - .readdir(instance.directory, { withFileTypes: true }) - .catch(() => [] as fs.Dirent[]) + for (const entry of top) { + if (!entry.isDirectory()) continue + if (shouldIgnoreName(entry.name)) continue + dirs.add(entry.name + "/") - for (const entry of top) { - if (!entry.isDirectory()) continue - if (shouldIgnoreName(entry.name)) continue - dirs.add(entry.name + "/") - - const base = path.join(instance.directory, entry.name) - const children = await fs.promises.readdir(base, { withFileTypes: true }).catch(() => [] as fs.Dirent[]) - for (const child of children) { - if (!child.isDirectory()) continue - if (shouldIgnoreNested(child.name)) continue - dirs.add(entry.name + "/" + child.name + "/") - } - } - - next.dirs = Array.from(dirs).toSorted() - } else { - const set = new Set() - for await (const file of Ripgrep.files({ cwd: instance.directory })) { - next.files.push(file) - let current = file - while (true) { - const dir = path.dirname(current) - if (dir === ".") break - if (dir === current) break - current = dir - if (set.has(dir)) continue - set.add(dir) - next.dirs.push(dir + "/") - } + const base = path.join(instance.directory, entry.name) + const children = await fs.promises.readdir(base, { withFileTypes: true }).catch(() => [] as fs.Dirent[]) + for (const child of children) { + if (!child.isDirectory()) continue + if (shouldIgnoreNested(child.name)) continue + dirs.add(entry.name + "/" + child.name + "/") + } + } + + next.dirs = Array.from(dirs).toSorted() + } else { + const seen = new Set() + for await (const file of Ripgrep.files({ cwd: instance.directory })) { + next.files.push(file) + let current = file + while (true) { + const dir = path.dirname(current) + if (dir === ".") break + if (dir === current) break + current = dir + if (seen.has(dir)) continue + seen.add(dir) + next.dirs.push(dir + "/") } } - cache = next - } finally { - task = undefined } - })() - return task - } + }) - const getFiles = async () => { - void kick() - return cache - } - - const init = Effect.fn("FileService.init")(function* () { - yield* Effect.promise(() => kick()) + cache = next }) - const status = Effect.fn("FileService.status")(function* () { + const getFiles = () => cache + + const scope = yield* Scope.Scope + let fiber: Fiber.Fiber | undefined + + const init = Effect.fn("File.init")(function* () { + if (!fiber) { + fiber = yield* scan().pipe( + Effect.catchCause(() => Effect.void), + Effect.forkIn(scope), + ) + } + yield* Fiber.join(fiber) + }) + + const status = Effect.fn("File.status")(function* () { if (instance.project.vcs !== "git") return [] return yield* Effect.promise(async () => { @@ -461,14 +445,13 @@ export class FileService extends ServiceMap.Service { - const full = path.isAbsolute(x.path) ? x.path : path.join(instance.directory, x.path) + return changed.map((item) => { + const full = path.isAbsolute(item.path) ? item.path : path.join(instance.directory, item.path) return { - ...x, + ...item, path: path.relative(instance.directory, full), } }) }) }) - const read = Effect.fn("FileService.read")(function* (file: string) { + const read = Effect.fn("File.read")(function* (file: string) { return yield* Effect.promise(async (): Promise => { using _ = log.time("read", { file }) const full = path.join(instance.directory, file) if (!Instance.containsPath(full)) { - throw new Error(`Access denied: path escapes project directory`) + throw new Error("Access denied: path escapes project directory") } - // Fast path: check extension before any filesystem operations if (isImageByExtension(file)) { if (await Filesystem.exists(full)) { const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([])) - const content = buffer.toString("base64") - const mimeType = getImageMimeType(file) - return { type: "text", content, mimeType, encoding: "base64" } + return { + type: "text", + content: buffer.toString("base64"), + mimeType: getImageMimeType(file), + encoding: "base64", + } } return { type: "text", content: "" } } - const text = isTextByExtension(file) || isTextByName(file) + const knownText = isTextByExtension(file) || isTextByName(file) - if (isBinaryByExtension(file) && !text) { + if (isBinaryByExtension(file) && !knownText) { return { type: "binary", content: "" } } @@ -583,7 +564,7 @@ export class FileService extends ServiceMap.Service Buffer.from([])) - const content = buffer.toString("base64") - return { type: "text", content, mimeType, encoding: "base64" } + return { + type: "text", + content: buffer.toString("base64"), + mimeType, + encoding: "base64", + } } const content = (await Filesystem.readText(full).catch(() => "")).trim() @@ -603,7 +588,9 @@ export class FileService extends ServiceMap.Service { const exclude = [".git", ".DS_Store"] let ignored = (_: string) => false if (instance.project.vcs === "git") { const ig = ignore() - const gitignorePath = path.join(instance.project.worktree, ".gitignore") - if (await Filesystem.exists(gitignorePath)) { - ig.add(await Filesystem.readText(gitignorePath)) + const gitignore = path.join(instance.project.worktree, ".gitignore") + if (await Filesystem.exists(gitignore)) { + ig.add(await Filesystem.readText(gitignore)) } - const ignorePath = path.join(instance.project.worktree, ".ignore") - if (await Filesystem.exists(ignorePath)) { - ig.add(await Filesystem.readText(ignorePath)) + const ignoreFile = path.join(instance.project.worktree, ".ignore") + if (await Filesystem.exists(ignoreFile)) { + ig.add(await Filesystem.readText(ignoreFile)) } ignored = ig.ignores.bind(ig) } - const resolved = dir ? path.join(instance.directory, dir) : instance.directory + const resolved = dir ? path.join(instance.directory, dir) : instance.directory if (!Instance.containsPath(resolved)) { - throw new Error(`Access denied: path escapes project directory`) + throw new Error("Access denied: path escapes project directory") } const nodes: File.Node[] = [] - for (const entry of await fs.promises - .readdir(resolved, { - withFileTypes: true, - }) - .catch(() => [])) { + for (const entry of await fs.promises.readdir(resolved, { withFileTypes: true }).catch(() => [])) { if (exclude.includes(entry.name)) continue - const fullPath = path.join(resolved, entry.name) - const relativePath = path.relative(instance.directory, fullPath) + const absolute = path.join(resolved, entry.name) + const file = path.relative(instance.directory, absolute) const type = entry.isDirectory() ? "directory" : "file" nodes.push({ name: entry.name, - path: relativePath, - absolute: fullPath, + path: file, + absolute, type, - ignored: ignored(type === "directory" ? relativePath + "/" : relativePath), + ignored: ignored(type === "directory" ? file + "/" : file), }) } + return nodes.sort((a, b) => { - if (a.type !== b.type) { - return a.type === "directory" ? -1 : 1 - } + if (a.type !== b.type) return a.type === "directory" ? -1 : 1 return a.name.localeCompare(b.name) }) }) }) - const search = Effect.fn("FileService.search")(function* (input: { + const search = Effect.fn("File.search")(function* (input: { query: string limit?: number dirs?: boolean @@ -681,35 +668,20 @@ export class FileService extends ServiceMap.Service