summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorKit Langton <[email protected]>2026-03-16 16:18:39 -0400
committerGitHub <[email protected]>2026-03-16 16:18:39 -0400
commit69381f6aea7cec16b469e6242137f0262aac24c5 (patch)
tree6890058be6bcdfc5aeaf14c4f4c736ab6d97f391
parentdf6508530f1f7b22787ac931bf93a1b530b98c33 (diff)
downloadopencode-69381f6aea7cec16b469e6242137f0262aac24c5.tar.gz
opencode-69381f6aea7cec16b469e6242137f0262aac24c5.zip
refactor(file): effectify FileService as scoped service (#17845)
-rw-r--r--packages/opencode/src/effect/instances.ts3
-rw-r--r--packages/opencode/src/file/index.ts1156
-rw-r--r--packages/opencode/test/file/index.test.ts466
3 files changed, 1083 insertions, 542 deletions
diff --git a/packages/opencode/src/effect/instances.ts b/packages/opencode/src/effect/instances.ts
index f5d9ac94a..240f8ee66 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 e03fc8a9f..09bcf9290 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<string, string> = {
+ 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<typeof Content>
- 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<string, string> = {
- 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<Content> {
+ 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<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 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<void>
+ readonly status: () => Effect.Effect<File.Info[]>
+ readonly read: (file: string) => Effect.Effect<File.Content>
+ readonly list: (dir?: string) => Effect.Effect<File.Node[]>
+ readonly search: (input: {
+ query: string
+ limit?: number
+ dirs?: boolean
+ type?: "file" | "directory"
+ }) => Effect.Effect<string[]>
}
+}
- 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<string>()
- 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<FileService, FileService.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<void> | 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<string>()
+ 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<string>()
+ 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<string>()
- 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<File.Content> => {
+ using _ = log.time("read", { file })
+ const full = path.join(instance.directory, file)
- export async function read(file: string): Promise<Content> {
- 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 f269926b5..89de5b571 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()
+ },
+ })
+ })
+ })
})