diff --git a/packages/opencode/src/effect/instances.ts b/packages/opencode/src/effect/instances.ts index fa6c72c67e..a92bb46408 100644 --- a/packages/opencode/src/effect/instances.ts +++ b/packages/opencode/src/effect/instances.ts @@ -1,15 +1,15 @@ 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 { File } from "@/file" +import { FileTime } from "@/file/time" +import { FileWatcher } from "@/file/watcher" +import { Format } from "@/format" import { PermissionEffect } from "@/permission/effect" import { Instance } from "@/project/instance" -import { VcsService } from "@/project/vcs" -import { ProviderAuthService } from "@/provider/auth-service" +import { Vcs } from "@/project/vcs" +import { ProviderAuthEffect } from "@/provider/auth-effect" import { QuestionEffect } from "@/question/effect" -import { SkillService } from "@/skill/skill" -import { SnapshotService } from "@/snapshot" +import { Skill } from "@/skill/skill" +import { Snapshot } from "@/snapshot" import { InstanceContext } from "./instance-context" import { registerDisposer } from "./instance-registry" @@ -18,14 +18,14 @@ export { InstanceContext } from "./instance-context" export type InstanceServices = | QuestionEffect.Service | PermissionEffect.Service - | ProviderAuthService - | FileWatcherService - | VcsService - | FileTimeService - | FormatService - | FileService - | SkillService - | SnapshotService + | ProviderAuthEffect.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 @@ -38,14 +38,14 @@ function lookup(_key: string) { return Layer.mergeAll( Layer.fresh(QuestionEffect.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(ProviderAuthEffect.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)) } diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index cee03e0915..de5ec23285 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, Layer, 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,36 +359,29 @@ 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 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[]) @@ -419,7 +402,7 @@ export class FileService extends ServiceMap.Service() + const seen = new Set() for await (const file of Ripgrep.files({ cwd: instance.directory })) { next.files.push(file) let current = file @@ -428,8 +411,8 @@ export class FileService extends ServiceMap.Service kick()) }) - const status = Effect.fn("FileService.status")(function* () { + const status = Effect.fn("File.status")(function* () { if (instance.project.vcs !== "git") return [] return yield* Effect.promise(async () => { @@ -461,14 +444,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 +563,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 +587,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 @@ -682,34 +668,19 @@ export class FileService extends ServiceMap.Service