diff --git a/packages/opencode/src/effect/instances.ts b/packages/opencode/src/effect/instances.ts index f5d9ac94a10..240f8ee66ab 100644 --- a/packages/opencode/src/effect/instances.ts +++ b/packages/opencode/src/effect/instances.ts @@ -8,6 +8,7 @@ import { FileWatcherService } from "@/file/watcher" import { VcsService } from "@/project/vcs" import { FileTimeService } from "@/file/time" import { FormatService } from "@/format" +import { FileService } from "@/file" import { Instance } from "@/project/instance" export { InstanceContext } from "./instance-context" @@ -20,6 +21,7 @@ export type InstanceServices = | VcsService | FileTimeService | FormatService + | FileService function lookup(directory: string) { const project = Instance.project @@ -32,6 +34,7 @@ function lookup(directory: string) { Layer.fresh(VcsService.layer), Layer.fresh(FileTimeService.layer).pipe(Layer.orDie), Layer.fresh(FormatService.layer), + Layer.fresh(FileService.layer), ).pipe(Layer.provide(ctx)) } diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index e03fc8a9f30..09bcf92901a 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -12,10 +12,263 @@ 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) +} -export namespace File { - const log = Log.create({ service: "file" }) +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 +} + +export namespace File { export const Info = z .object({ path: z.string(), @@ -73,582 +326,401 @@ export namespace File { }) export type Content = z.infer - 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) + export const Event = { + Edited: BusEvent.define( + "file.edited", + z.object({ + file: z.string(), + }), + ), } - function isTextByName(filepath: string): boolean { - const name = path.basename(filepath).toLowerCase() - return textNames.has(name) + export function init() { + return runPromiseInstance(FileService.use((s) => s.init())) } - 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 + export async function status() { + return runPromiseInstance(FileService.use((s) => s.status())) } - function isBinaryByExtension(filepath: string): boolean { - const ext = path.extname(filepath).toLowerCase().slice(1) - return binaryExtensions.has(ext) + export async function read(file: string): Promise { + return runPromiseInstance(FileService.use((s) => s.read(file))) } - function isImage(mimeType: string): boolean { - return mimeType.startsWith("image/") + export async function list(dir?: string) { + return runPromiseInstance(FileService.use((s) => s.list(dir))) } - async function shouldEncode(mimeType: string): Promise { - 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 + export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) { + return runPromiseInstance(FileService.use((s) => s.search(input))) } +} - export const Event = { - Edited: BusEvent.define( - "file.edited", - z.object({ - file: z.string(), - }), - ), +export namespace FileService { + export interface Service { + readonly init: () => Effect.Effect + readonly status: () => Effect.Effect + readonly read: (file: string) => Effect.Effect + readonly list: (dir?: string) => Effect.Effect + readonly search: (input: { + query: string + limit?: number + dirs?: boolean + type?: "file" | "directory" + }) => Effect.Effect } +} - const state = Instance.state(async () => { - type Entry = { files: string[]; dirs: string[] } - let cache: Entry = { files: [], dirs: [] } - let fetching = false - - const isGlobalHome = Instance.directory === Global.Path.home && Instance.project.id === "global" - - const fn = async (result: Entry) => { - // Disable scanning if in root of file system - if (Instance.directory === path.parse(Instance.directory).root) return - fetching = true - - if (isGlobalHome) { - const dirs = new Set() - const ignore = Protected.names() - - const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"]) - const shouldIgnore = (name: string) => name.startsWith(".") || ignore.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[]) - - for (const entry of top) { - if (!entry.isDirectory()) continue - if (shouldIgnore(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 + "/") +export class FileService extends ServiceMap.Service()("@opencode/File") { + static readonly layer = Layer.effect( + FileService, + 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[]) + + 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 + "/") + } + } + } + cache = next + } finally { + task = undefined } - } - - result.dirs = Array.from(dirs).toSorted() - cache = result - fetching = false - return + })() + return task } - const set = new Set() - for await (const file of Ripgrep.files({ cwd: Instance.directory })) { - result.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) - result.dirs.push(dir + "/") - } - } - cache = result - fetching = false - } - fn(cache) - - return { - async files() { - if (!fetching) { - fn({ - files: [], - dirs: [], - }) - } + const getFiles = async () => { + void kick() return cache - }, - } - }) + } - export function init() { - state() - } + const init = Effect.fn("FileService.init")(function* () { + void kick() + }) - export async function status() { - const project = Instance.project - if (project.vcs !== "git") return [] + const status = Effect.fn("FileService.status")(function* () { + if (instance.project.vcs !== "git") return [] + + return yield* Effect.promise(async () => { + const diffOutput = ( + await git(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], { + cwd: instance.directory, + }) + ).text() + + const changedFiles: File.Info[] = [] + + if (diffOutput.trim()) { + const lines = diffOutput.trim().split("\n") + for (const line of lines) { + const [added, removed, filepath] = line.split("\t") + changedFiles.push({ + path: filepath, + added: added === "-" ? 0 : parseInt(added, 10), + removed: removed === "-" ? 0 : parseInt(removed, 10), + status: "modified", + }) + } + } - const diffOutput = ( - await git(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], { - cwd: Instance.directory, - }) - ).text() - - const changedFiles: Info[] = [] - - if (diffOutput.trim()) { - const lines = diffOutput.trim().split("\n") - for (const line of lines) { - const [added, removed, filepath] = line.split("\t") - changedFiles.push({ - path: filepath, - added: added === "-" ? 0 : parseInt(added, 10), - removed: removed === "-" ? 0 : parseInt(removed, 10), - status: "modified", - }) - } - } - - const untrackedOutput = ( - await git( - ["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "ls-files", "--others", "--exclude-standard"], - { - cwd: Instance.directory, - }, - ) - ).text() - - if (untrackedOutput.trim()) { - const untrackedFiles = untrackedOutput.trim().split("\n") - for (const filepath of untrackedFiles) { - try { - const content = await Filesystem.readText(path.join(Instance.directory, filepath)) - const lines = content.split("\n").length - changedFiles.push({ - path: filepath, - added: lines, - removed: 0, - status: "added", + const untrackedOutput = ( + await git( + [ + "-c", + "core.fsmonitor=false", + "-c", + "core.quotepath=false", + "ls-files", + "--others", + "--exclude-standard", + ], + { + cwd: instance.directory, + }, + ) + ).text() + + if (untrackedOutput.trim()) { + const untrackedFiles = untrackedOutput.trim().split("\n") + for (const filepath of untrackedFiles) { + try { + const content = await Filesystem.readText(path.join(instance.directory, filepath)) + const lines = content.split("\n").length + changedFiles.push({ + path: filepath, + added: lines, + removed: 0, + status: "added", + }) + } catch { + continue + } + } + } + + // Get deleted files + const deletedOutput = ( + await git( + [ + "-c", + "core.fsmonitor=false", + "-c", + "core.quotepath=false", + "diff", + "--name-only", + "--diff-filter=D", + "HEAD", + ], + { + cwd: instance.directory, + }, + ) + ).text() + + if (deletedOutput.trim()) { + const deletedFiles = deletedOutput.trim().split("\n") + for (const filepath of deletedFiles) { + changedFiles.push({ + path: filepath, + added: 0, + removed: 0, // Could get original line count but would require another git command + status: "deleted", + }) + } + } + + return changedFiles.map((x) => { + const full = path.isAbsolute(x.path) ? x.path : path.join(instance.directory, x.path) + return { + ...x, + path: path.relative(instance.directory, full), + } }) - } catch { - continue - } - } - } - - // Get deleted files - const deletedOutput = ( - await git( - ["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--name-only", "--diff-filter=D", "HEAD"], - { - cwd: Instance.directory, - }, - ) - ).text() - - if (deletedOutput.trim()) { - const deletedFiles = deletedOutput.trim().split("\n") - for (const filepath of deletedFiles) { - changedFiles.push({ - path: filepath, - added: 0, - removed: 0, // Could get original line count but would require another git command - status: "deleted", }) - } - } + }) - return changedFiles.map((x) => { - const full = path.isAbsolute(x.path) ? x.path : path.join(Instance.directory, x.path) - return { - ...x, - path: path.relative(Instance.directory, full), - } - }) - } + const read = Effect.fn("FileService.read")(function* (file: string) { + return yield* Effect.promise(async (): Promise => { + using _ = log.time("read", { file }) + const full = path.join(instance.directory, file) - export async function read(file: string): Promise { - using _ = log.time("read", { file }) - const project = Instance.project - const full = path.join(Instance.directory, file) - - // TODO: Filesystem.contains is lexical only - symlinks inside the project can escape. - // TODO: On Windows, cross-drive paths bypass this check. Consider realpath canonicalization. - if (!Instance.containsPath(full)) { - 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: "" } - } + if (!Instance.containsPath(full)) { + throw new Error(`Access denied: path escapes project directory`) + } - const text = isTextByExtension(file) || isTextByName(file) + // 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: "" } + } - if (isBinaryByExtension(file) && !text) { - return { type: "binary", content: "" } - } + const text = isTextByExtension(file) || isTextByName(file) - if (!(await Filesystem.exists(full))) { - return { type: "text", content: "" } - } + if (isBinaryByExtension(file) && !text) { + return { type: "binary", content: "" } + } - const mimeType = Filesystem.mimeType(full) - const encode = text ? false : await shouldEncode(mimeType) + if (!(await Filesystem.exists(full))) { + return { type: "text", content: "" } + } - if (encode && !isImage(mimeType)) { - return { type: "binary", content: "", mimeType } - } + const mimeType = Filesystem.mimeType(full) + const encode = text ? false : shouldEncode(mimeType) - if (encode) { - const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([])) - const content = buffer.toString("base64") - return { type: "text", content, mimeType, encoding: "base64" } - } + if (encode && !isImage(mimeType)) { + return { type: "binary", content: "", mimeType } + } - const content = (await Filesystem.readText(full).catch(() => "")).trim() + if (encode) { + const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([])) + const content = buffer.toString("base64") + return { type: "text", content, mimeType, encoding: "base64" } + } - if (project.vcs === "git") { - let diff = (await git(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: Instance.directory })).text() - if (!diff.trim()) { - diff = ( - await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], { cwd: Instance.directory }) - ).text() - } - if (diff.trim()) { - const original = (await git(["show", `HEAD:${file}`], { cwd: Instance.directory })).text() - const patch = structuredPatch(file, file, original, content, "old", "new", { - context: Infinity, - ignoreWhitespace: true, + const content = (await Filesystem.readText(full).catch(() => "")).trim() + + if (instance.project.vcs === "git") { + let diff = ( + await git(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: instance.directory }) + ).text() + if (!diff.trim()) { + diff = ( + await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], { cwd: instance.directory }) + ).text() + } + if (diff.trim()) { + const original = (await git(["show", `HEAD:${file}`], { cwd: instance.directory })).text() + const patch = structuredPatch(file, file, original, content, "old", "new", { + context: Infinity, + ignoreWhitespace: true, + }) + const diff = formatPatch(patch) + return { type: "text", content, patch, diff } + } + } + return { type: "text", content } }) - const diff = formatPatch(patch) - return { type: "text", content, patch, diff } - } - } - return { type: "text", content } - } + }) - export async function list(dir?: string) { - const exclude = [".git", ".DS_Store"] - const project = Instance.project - let ignored = (_: string) => false - if (project.vcs === "git") { - const ig = ignore() - const gitignorePath = path.join(Instance.worktree, ".gitignore") - if (await Filesystem.exists(gitignorePath)) { - ig.add(await Filesystem.readText(gitignorePath)) - } - const ignorePath = path.join(Instance.worktree, ".ignore") - if (await Filesystem.exists(ignorePath)) { - ig.add(await Filesystem.readText(ignorePath)) - } - ignored = ig.ignores.bind(ig) - } - const resolved = dir ? path.join(Instance.directory, dir) : Instance.directory - - // TODO: Filesystem.contains is lexical only - symlinks inside the project can escape. - // TODO: On Windows, cross-drive paths bypass this check. Consider realpath canonicalization. - if (!Instance.containsPath(resolved)) { - throw new Error(`Access denied: path escapes project directory`) - } - - const nodes: Node[] = [] - for (const entry of await fs.promises - .readdir(resolved, { - withFileTypes: true, + const list = Effect.fn("FileService.list")(function* (dir?: string) { + return yield* Effect.promise(async () => { + 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 ignorePath = path.join(instance.project.worktree, ".ignore") + if (await Filesystem.exists(ignorePath)) { + ig.add(await Filesystem.readText(ignorePath)) + } + ignored = ig.ignores.bind(ig) + } + const resolved = dir ? path.join(instance.directory, dir) : instance.directory + + if (!Instance.containsPath(resolved)) { + 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(() => [])) { + if (exclude.includes(entry.name)) continue + const fullPath = path.join(resolved, entry.name) + const relativePath = path.relative(instance.directory, fullPath) + const type = entry.isDirectory() ? "directory" : "file" + nodes.push({ + name: entry.name, + path: relativePath, + absolute: fullPath, + type, + ignored: ignored(type === "directory" ? relativePath + "/" : relativePath), + }) + } + return nodes.sort((a, b) => { + if (a.type !== b.type) { + return a.type === "directory" ? -1 : 1 + } + return a.name.localeCompare(b.name) + }) + }) }) - .catch(() => [])) { - if (exclude.includes(entry.name)) continue - const fullPath = path.join(resolved, entry.name) - const relativePath = path.relative(Instance.directory, fullPath) - const type = entry.isDirectory() ? "directory" : "file" - nodes.push({ - name: entry.name, - path: relativePath, - absolute: fullPath, - type, - ignored: ignored(type === "directory" ? relativePath + "/" : relativePath), + + const search = Effect.fn("FileService.search")(function* (input: { + query: string + limit?: number + dirs?: boolean + type?: "file" | "directory" + }) { + return yield* Effect.promise(async () => { + const query = input.query.trim() + const limit = input.limit ?? 100 + const kind = input.type ?? (input.dirs === false ? "file" : "all") + log.info("search", { query, kind }) + + const result = await getFiles() + + const hidden = (item: string) => { + const normalized = item.replaceAll("\\", "/").replace(/\/+$/, "") + return normalized.split("/").some((p) => p.startsWith(".") && p.length > 1) + } + const preferHidden = query.startsWith(".") || query.includes("/.") + const sortHiddenLast = (items: string[]) => { + if (preferHidden) return items + const visible: string[] = [] + const hiddenItems: string[] = [] + for (const item of items) { + const isHidden = hidden(item) + if (isHidden) hiddenItems.push(item) + if (!isHidden) visible.push(item) + } + return [...visible, ...hiddenItems] + } + if (!query) { + if (kind === "file") return result.files.slice(0, limit) + return sortHiddenLast(result.dirs.toSorted()).slice(0, limit) + } + + const items = + kind === "file" ? result.files : kind === "directory" ? result.dirs : [...result.files, ...result.dirs] + + const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit + const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((r) => r.target) + const output = kind === "directory" ? sortHiddenLast(sorted).slice(0, limit) : sorted + + log.info("search", { query, kind, results: output.length }) + return output + }) }) - } - return nodes.sort((a, b) => { - if (a.type !== b.type) { - return a.type === "directory" ? -1 : 1 - } - return a.name.localeCompare(b.name) - }) - } - export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) { - const query = input.query.trim() - const limit = input.limit ?? 100 - const kind = input.type ?? (input.dirs === false ? "file" : "all") - log.info("search", { query, kind }) - - const result = await state().then((x) => x.files()) - - const hidden = (item: string) => { - const normalized = item.replaceAll("\\", "/").replace(/\/+$/, "") - return normalized.split("/").some((p) => p.startsWith(".") && p.length > 1) - } - const preferHidden = query.startsWith(".") || query.includes("/.") - const sortHiddenLast = (items: string[]) => { - if (preferHidden) return items - const visible: string[] = [] - const hiddenItems: string[] = [] - for (const item of items) { - const isHidden = hidden(item) - if (isHidden) hiddenItems.push(item) - if (!isHidden) visible.push(item) - } - return [...visible, ...hiddenItems] - } - if (!query) { - if (kind === "file") return result.files.slice(0, limit) - return sortHiddenLast(result.dirs.toSorted()).slice(0, limit) - } - - const items = - kind === "file" ? result.files : kind === "directory" ? result.dirs : [...result.files, ...result.dirs] - - const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit - const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((r) => r.target) - const output = kind === "directory" ? sortHiddenLast(sorted).slice(0, limit) : sorted - - log.info("search", { query, kind, results: output.length }) - return output - } + log.info("init") + + return FileService.of({ init, status, read, list, search }) + }), + ) } diff --git a/packages/opencode/test/file/index.test.ts b/packages/opencode/test/file/index.test.ts index f269926b532..89de5b571b1 100644 --- a/packages/opencode/test/file/index.test.ts +++ b/packages/opencode/test/file/index.test.ts @@ -1,4 +1,5 @@ import { describe, test, expect } from "bun:test" +import { $ } from "bun" import path from "path" import fs from "fs/promises" import { File } from "../../src/file" @@ -391,4 +392,469 @@ describe("file/index Filesystem patterns", () => { }) }) }) + + describe("File.status()", () => { + test("detects modified file", async () => { + await using tmp = await tmpdir({ git: true }) + const filepath = path.join(tmp.path, "file.txt") + await fs.writeFile(filepath, "original\n", "utf-8") + await $`git add .`.cwd(tmp.path).quiet() + await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet() + await fs.writeFile(filepath, "modified\nextra line\n", "utf-8") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const result = await File.status() + const entry = result.find((f) => f.path === "file.txt") + expect(entry).toBeDefined() + expect(entry!.status).toBe("modified") + expect(entry!.added).toBeGreaterThan(0) + expect(entry!.removed).toBeGreaterThan(0) + }, + }) + }) + + test("detects untracked file as added", async () => { + await using tmp = await tmpdir({ git: true }) + await fs.writeFile(path.join(tmp.path, "new.txt"), "line1\nline2\nline3\n", "utf-8") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const result = await File.status() + const entry = result.find((f) => f.path === "new.txt") + expect(entry).toBeDefined() + expect(entry!.status).toBe("added") + expect(entry!.added).toBe(4) // 3 lines + trailing newline splits to 4 + expect(entry!.removed).toBe(0) + }, + }) + }) + + test("detects deleted file", async () => { + await using tmp = await tmpdir({ git: true }) + const filepath = path.join(tmp.path, "gone.txt") + await fs.writeFile(filepath, "content\n", "utf-8") + await $`git add .`.cwd(tmp.path).quiet() + await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet() + await fs.rm(filepath) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const result = await File.status() + // Deleted files appear in both numstat (as "modified") and diff-filter=D (as "deleted") + const entries = result.filter((f) => f.path === "gone.txt") + expect(entries.some((e) => e.status === "deleted")).toBe(true) + }, + }) + }) + + test("detects mixed changes", async () => { + await using tmp = await tmpdir({ git: true }) + await fs.writeFile(path.join(tmp.path, "keep.txt"), "keep\n", "utf-8") + await fs.writeFile(path.join(tmp.path, "remove.txt"), "remove\n", "utf-8") + await $`git add .`.cwd(tmp.path).quiet() + await $`git commit --no-gpg-sign -m "initial"`.cwd(tmp.path).quiet() + + // Modify one, delete one, add one + await fs.writeFile(path.join(tmp.path, "keep.txt"), "changed\n", "utf-8") + await fs.rm(path.join(tmp.path, "remove.txt")) + await fs.writeFile(path.join(tmp.path, "brand-new.txt"), "hello\n", "utf-8") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const result = await File.status() + expect(result.some((f) => f.path === "keep.txt" && f.status === "modified")).toBe(true) + expect(result.some((f) => f.path === "remove.txt" && f.status === "deleted")).toBe(true) + expect(result.some((f) => f.path === "brand-new.txt" && f.status === "added")).toBe(true) + }, + }) + }) + + test("returns empty for non-git project", async () => { + await using tmp = await tmpdir() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const result = await File.status() + expect(result).toEqual([]) + }, + }) + }) + + test("returns empty for clean repo", async () => { + await using tmp = await tmpdir({ git: true }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const result = await File.status() + expect(result).toEqual([]) + }, + }) + }) + + test("parses binary numstat as 0", async () => { + await using tmp = await tmpdir({ git: true }) + const filepath = path.join(tmp.path, "data.bin") + // Write content with null bytes so git treats it as binary + const binaryData = Buffer.alloc(256) + for (let i = 0; i < 256; i++) binaryData[i] = i + await fs.writeFile(filepath, binaryData) + await $`git add .`.cwd(tmp.path).quiet() + await $`git commit --no-gpg-sign -m "add binary"`.cwd(tmp.path).quiet() + // Modify the binary + const modified = Buffer.alloc(512) + for (let i = 0; i < 512; i++) modified[i] = i % 256 + await fs.writeFile(filepath, modified) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const result = await File.status() + const entry = result.find((f) => f.path === "data.bin") + expect(entry).toBeDefined() + expect(entry!.status).toBe("modified") + expect(entry!.added).toBe(0) + expect(entry!.removed).toBe(0) + }, + }) + }) + }) + + describe("File.list()", () => { + test("returns files and directories with correct shape", async () => { + await using tmp = await tmpdir({ git: true }) + await fs.mkdir(path.join(tmp.path, "subdir")) + await fs.writeFile(path.join(tmp.path, "file.txt"), "content", "utf-8") + await fs.writeFile(path.join(tmp.path, "subdir", "nested.txt"), "nested", "utf-8") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const nodes = await File.list() + expect(nodes.length).toBeGreaterThanOrEqual(2) + for (const node of nodes) { + expect(node).toHaveProperty("name") + expect(node).toHaveProperty("path") + expect(node).toHaveProperty("absolute") + expect(node).toHaveProperty("type") + expect(node).toHaveProperty("ignored") + expect(["file", "directory"]).toContain(node.type) + } + }, + }) + }) + + test("sorts directories before files, alphabetical within each", async () => { + await using tmp = await tmpdir({ git: true }) + await fs.mkdir(path.join(tmp.path, "beta")) + await fs.mkdir(path.join(tmp.path, "alpha")) + await fs.writeFile(path.join(tmp.path, "zz.txt"), "", "utf-8") + await fs.writeFile(path.join(tmp.path, "aa.txt"), "", "utf-8") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const nodes = await File.list() + const dirs = nodes.filter((n) => n.type === "directory") + const files = nodes.filter((n) => n.type === "file") + // Dirs come first + const firstFile = nodes.findIndex((n) => n.type === "file") + const lastDir = nodes.findLastIndex((n) => n.type === "directory") + if (lastDir >= 0 && firstFile >= 0) { + expect(lastDir).toBeLessThan(firstFile) + } + // Alphabetical within dirs + expect(dirs.map((d) => d.name)).toEqual(dirs.map((d) => d.name).toSorted()) + // Alphabetical within files + expect(files.map((f) => f.name)).toEqual(files.map((f) => f.name).toSorted()) + }, + }) + }) + + test("excludes .git and .DS_Store", async () => { + await using tmp = await tmpdir({ git: true }) + await fs.writeFile(path.join(tmp.path, ".DS_Store"), "", "utf-8") + await fs.writeFile(path.join(tmp.path, "visible.txt"), "", "utf-8") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const nodes = await File.list() + const names = nodes.map((n) => n.name) + expect(names).not.toContain(".git") + expect(names).not.toContain(".DS_Store") + expect(names).toContain("visible.txt") + }, + }) + }) + + test("marks gitignored files as ignored", async () => { + await using tmp = await tmpdir({ git: true }) + await fs.writeFile(path.join(tmp.path, ".gitignore"), "*.log\nbuild/\n", "utf-8") + await fs.writeFile(path.join(tmp.path, "app.log"), "log data", "utf-8") + await fs.writeFile(path.join(tmp.path, "main.ts"), "code", "utf-8") + await fs.mkdir(path.join(tmp.path, "build")) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const nodes = await File.list() + const logNode = nodes.find((n) => n.name === "app.log") + const tsNode = nodes.find((n) => n.name === "main.ts") + const buildNode = nodes.find((n) => n.name === "build") + expect(logNode?.ignored).toBe(true) + expect(tsNode?.ignored).toBe(false) + expect(buildNode?.ignored).toBe(true) + }, + }) + }) + + test("lists subdirectory contents", async () => { + await using tmp = await tmpdir({ git: true }) + await fs.mkdir(path.join(tmp.path, "sub")) + await fs.writeFile(path.join(tmp.path, "sub", "a.txt"), "", "utf-8") + await fs.writeFile(path.join(tmp.path, "sub", "b.txt"), "", "utf-8") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const nodes = await File.list("sub") + expect(nodes.length).toBe(2) + expect(nodes.map((n) => n.name).sort()).toEqual(["a.txt", "b.txt"]) + // Paths should be relative to project root (normalize for Windows) + expect(nodes[0].path.replaceAll("\\", "/").startsWith("sub/")).toBe(true) + }, + }) + }) + + test("throws for paths outside project directory", async () => { + await using tmp = await tmpdir({ git: true }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await expect(File.list("../outside")).rejects.toThrow("Access denied") + }, + }) + }) + + test("works without git", async () => { + await using tmp = await tmpdir() + await fs.writeFile(path.join(tmp.path, "file.txt"), "hi", "utf-8") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const nodes = await File.list() + expect(nodes.length).toBeGreaterThanOrEqual(1) + // Without git, ignored should be false for all + for (const node of nodes) { + expect(node.ignored).toBe(false) + } + }, + }) + }) + }) + + describe("File.search()", () => { + async function setupSearchableRepo() { + const tmp = await tmpdir({ git: true }) + await fs.writeFile(path.join(tmp.path, "index.ts"), "code", "utf-8") + await fs.writeFile(path.join(tmp.path, "utils.ts"), "utils", "utf-8") + await fs.writeFile(path.join(tmp.path, "readme.md"), "readme", "utf-8") + await fs.mkdir(path.join(tmp.path, "src")) + await fs.mkdir(path.join(tmp.path, ".hidden")) + await fs.writeFile(path.join(tmp.path, "src", "main.ts"), "main", "utf-8") + await fs.writeFile(path.join(tmp.path, ".hidden", "secret.ts"), "secret", "utf-8") + return tmp + } + + test("empty query returns files", async () => { + await using tmp = await setupSearchableRepo() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + File.init() + // Give the background scan time to populate + await new Promise((r) => setTimeout(r, 500)) + + const result = await File.search({ query: "", type: "file" }) + expect(result.length).toBeGreaterThan(0) + }, + }) + }) + + test("empty query returns dirs sorted with hidden last", async () => { + await using tmp = await setupSearchableRepo() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + File.init() + await new Promise((r) => setTimeout(r, 500)) + + const result = await File.search({ query: "", type: "directory" }) + expect(result.length).toBeGreaterThan(0) + // Find first hidden dir index + const firstHidden = result.findIndex((d) => d.split("/").some((p) => p.startsWith(".") && p.length > 1)) + const lastVisible = result.findLastIndex((d) => !d.split("/").some((p) => p.startsWith(".") && p.length > 1)) + if (firstHidden >= 0 && lastVisible >= 0) { + expect(firstHidden).toBeGreaterThan(lastVisible) + } + }, + }) + }) + + test("fuzzy matches file names", async () => { + await using tmp = await setupSearchableRepo() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + File.init() + await new Promise((r) => setTimeout(r, 500)) + + const result = await File.search({ query: "main", type: "file" }) + expect(result.some((f) => f.includes("main"))).toBe(true) + }, + }) + }) + + test("type filter returns only files", async () => { + await using tmp = await setupSearchableRepo() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + File.init() + await new Promise((r) => setTimeout(r, 500)) + + const result = await File.search({ query: "", type: "file" }) + // Files don't end with / + for (const f of result) { + expect(f.endsWith("/")).toBe(false) + } + }, + }) + }) + + test("type filter returns only directories", async () => { + await using tmp = await setupSearchableRepo() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + File.init() + await new Promise((r) => setTimeout(r, 500)) + + const result = await File.search({ query: "", type: "directory" }) + // Directories end with / + for (const d of result) { + expect(d.endsWith("/")).toBe(true) + } + }, + }) + }) + + test("respects limit", async () => { + await using tmp = await setupSearchableRepo() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + File.init() + await new Promise((r) => setTimeout(r, 500)) + + const result = await File.search({ query: "", type: "file", limit: 2 }) + expect(result.length).toBeLessThanOrEqual(2) + }, + }) + }) + + test("query starting with dot prefers hidden files", async () => { + await using tmp = await setupSearchableRepo() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + File.init() + await new Promise((r) => setTimeout(r, 500)) + + const result = await File.search({ query: ".hidden", type: "directory" }) + expect(result.length).toBeGreaterThan(0) + expect(result[0]).toContain(".hidden") + }, + }) + }) + }) + + describe("File.read() - diff/patch", () => { + test("returns diff and patch for modified tracked file", async () => { + await using tmp = await tmpdir({ git: true }) + const filepath = path.join(tmp.path, "file.txt") + await fs.writeFile(filepath, "original content\n", "utf-8") + await $`git add .`.cwd(tmp.path).quiet() + await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet() + await fs.writeFile(filepath, "modified content\n", "utf-8") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const result = await File.read("file.txt") + expect(result.type).toBe("text") + expect(result.content).toBe("modified content") + expect(result.diff).toBeDefined() + expect(result.diff).toContain("original content") + expect(result.diff).toContain("modified content") + expect(result.patch).toBeDefined() + expect(result.patch!.hunks.length).toBeGreaterThan(0) + }, + }) + }) + + test("returns diff for staged changes", async () => { + await using tmp = await tmpdir({ git: true }) + const filepath = path.join(tmp.path, "staged.txt") + await fs.writeFile(filepath, "before\n", "utf-8") + await $`git add .`.cwd(tmp.path).quiet() + await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet() + await fs.writeFile(filepath, "after\n", "utf-8") + await $`git add .`.cwd(tmp.path).quiet() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const result = await File.read("staged.txt") + expect(result.diff).toBeDefined() + expect(result.patch).toBeDefined() + }, + }) + }) + + test("returns no diff for unmodified file", async () => { + await using tmp = await tmpdir({ git: true }) + const filepath = path.join(tmp.path, "clean.txt") + await fs.writeFile(filepath, "unchanged\n", "utf-8") + await $`git add .`.cwd(tmp.path).quiet() + await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet() + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const result = await File.read("clean.txt") + expect(result.type).toBe("text") + expect(result.content).toBe("unchanged") + expect(result.diff).toBeUndefined() + expect(result.patch).toBeUndefined() + }, + }) + }) + }) })