浏览代码

refactor(file): effectify FileService as scoped service (#17845)

Kit Langton 5 月之前
父节点
当前提交
69381f6aea
共有 3 个文件被更改,包括 1083 次插入542 次删除
  1. 3 0
      packages/opencode/src/effect/instances.ts
  2. 614 542
      packages/opencode/src/file/index.ts
  3. 466 0
      packages/opencode/test/file/index.test.ts

+ 3 - 0
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))
 }
 

+ 614 - 542
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 })
+    }),
+  )
 }

+ 466 - 0
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()
+        },
+      })
+    })
+  })
 })