Kaynağa Gözat

feat: unwrap ufile namespace to flat exports + barrel (#22702)

Kit Langton 4 gün önce
ebeveyn
işleme
26cdbc20b2
2 değiştirilmiş dosya ile 655 ekleme ve 656 silme
  1. 654 0
      packages/opencode/src/file/file.ts
  2. 1 656
      packages/opencode/src/file/index.ts

+ 654 - 0
packages/opencode/src/file/file.ts

@@ -0,0 +1,654 @@
+import { BusEvent } from "@/bus/bus-event"
+import { InstanceState } from "@/effect/instance-state"
+
+import { AppFileSystem } from "@opencode-ai/shared/filesystem"
+import { Git } from "@/git"
+import { Effect, Layer, Context } from "effect"
+import * as Stream from "effect/Stream"
+import { formatPatch, structuredPatch } from "diff"
+import fuzzysort from "fuzzysort"
+import ignore from "ignore"
+import path from "path"
+import z from "zod"
+import { Global } from "../global"
+import { Instance } from "../project/instance"
+import { Log } from "../util/log"
+import { Protected } from "./protected"
+import { Ripgrep } from "./ripgrep"
+
+export const Info = z
+  .object({
+    path: z.string(),
+    added: z.number().int(),
+    removed: z.number().int(),
+    status: z.enum(["added", "deleted", "modified"]),
+  })
+  .meta({
+    ref: "File",
+  })
+
+export type Info = z.infer<typeof Info>
+
+export const Node = z
+  .object({
+    name: z.string(),
+    path: z.string(),
+    absolute: z.string(),
+    type: z.enum(["file", "directory"]),
+    ignored: z.boolean(),
+  })
+  .meta({
+    ref: "FileNode",
+  })
+export type Node = z.infer<typeof Node>
+
+export const Content = z
+  .object({
+    type: z.enum(["text", "binary"]),
+    content: z.string(),
+    diff: z.string().optional(),
+    patch: z
+      .object({
+        oldFileName: z.string(),
+        newFileName: z.string(),
+        oldHeader: z.string().optional(),
+        newHeader: z.string().optional(),
+        hunks: z.array(
+          z.object({
+            oldStart: z.number(),
+            oldLines: z.number(),
+            newStart: z.number(),
+            newLines: z.number(),
+            lines: z.array(z.string()),
+          }),
+        ),
+        index: z.string().optional(),
+      })
+      .optional(),
+    encoding: z.literal("base64").optional(),
+    mimeType: z.string().optional(),
+  })
+  .meta({
+    ref: "FileContent",
+  })
+export type Content = z.infer<typeof Content>
+
+export const Event = {
+  Edited: BusEvent.define(
+    "file.edited",
+    z.object({
+      file: z.string(),
+    }),
+  ),
+}
+
+const log = Log.create({ service: "file" })
+
+const binary = new Set([
+  "exe",
+  "dll",
+  "pdb",
+  "bin",
+  "so",
+  "dylib",
+  "o",
+  "a",
+  "lib",
+  "wav",
+  "mp3",
+  "ogg",
+  "oga",
+  "ogv",
+  "ogx",
+  "flac",
+  "aac",
+  "wma",
+  "m4a",
+  "weba",
+  "mp4",
+  "avi",
+  "mov",
+  "wmv",
+  "flv",
+  "webm",
+  "mkv",
+  "zip",
+  "tar",
+  "gz",
+  "gzip",
+  "bz",
+  "bz2",
+  "bzip",
+  "bzip2",
+  "7z",
+  "rar",
+  "xz",
+  "lz",
+  "z",
+  "pdf",
+  "doc",
+  "docx",
+  "ppt",
+  "pptx",
+  "xls",
+  "xlsx",
+  "dmg",
+  "iso",
+  "img",
+  "vmdk",
+  "ttf",
+  "otf",
+  "woff",
+  "woff2",
+  "eot",
+  "sqlite",
+  "db",
+  "mdb",
+  "apk",
+  "ipa",
+  "aab",
+  "xapk",
+  "app",
+  "pkg",
+  "deb",
+  "rpm",
+  "snap",
+  "flatpak",
+  "appimage",
+  "msi",
+  "msp",
+  "jar",
+  "war",
+  "ear",
+  "class",
+  "kotlin_module",
+  "dex",
+  "vdex",
+  "odex",
+  "oat",
+  "art",
+  "wasm",
+  "wat",
+  "bc",
+  "ll",
+  "s",
+  "ko",
+  "sys",
+  "drv",
+  "efi",
+  "rom",
+  "com",
+])
+
+const image = new Set([
+  "png",
+  "jpg",
+  "jpeg",
+  "gif",
+  "bmp",
+  "webp",
+  "ico",
+  "tif",
+  "tiff",
+  "svg",
+  "svgz",
+  "avif",
+  "apng",
+  "jxl",
+  "heic",
+  "heif",
+  "raw",
+  "cr2",
+  "nef",
+  "arw",
+  "dng",
+  "orf",
+  "raf",
+  "pef",
+  "x3f",
+])
+
+const text = new Set([
+  "ts",
+  "tsx",
+  "mts",
+  "cts",
+  "mtsx",
+  "ctsx",
+  "js",
+  "jsx",
+  "mjs",
+  "cjs",
+  "sh",
+  "bash",
+  "zsh",
+  "fish",
+  "ps1",
+  "psm1",
+  "cmd",
+  "bat",
+  "json",
+  "jsonc",
+  "json5",
+  "yaml",
+  "yml",
+  "toml",
+  "md",
+  "mdx",
+  "txt",
+  "xml",
+  "html",
+  "htm",
+  "css",
+  "scss",
+  "sass",
+  "less",
+  "graphql",
+  "gql",
+  "sql",
+  "ini",
+  "cfg",
+  "conf",
+  "env",
+])
+
+const textName = new Set([
+  "dockerfile",
+  "makefile",
+  ".gitignore",
+  ".gitattributes",
+  ".editorconfig",
+  ".npmrc",
+  ".nvmrc",
+  ".prettierrc",
+  ".eslintrc",
+])
+
+const mime: Record<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",
+}
+
+type Entry = { files: string[]; dirs: string[] }
+
+const ext = (file: string) => path.extname(file).toLowerCase().slice(1)
+const name = (file: string) => path.basename(file).toLowerCase()
+const isImageByExtension = (file: string) => image.has(ext(file))
+const isTextByExtension = (file: string) => text.has(ext(file))
+const isTextByName = (file: string) => textName.has(name(file))
+const isBinaryByExtension = (file: string) => binary.has(ext(file))
+const isImage = (mimeType: string) => mimeType.startsWith("image/")
+const getImageMimeType = (file: string) => mime[ext(file)] || "image/" + ext(file)
+
+function shouldEncode(mimeType: string) {
+  const type = mimeType.toLowerCase()
+  log.debug("shouldEncode", { type })
+  if (!type) return false
+  if (type.startsWith("text/")) return false
+  if (type.includes("charset=")) return false
+  const top = type.split("/", 2)[0]
+  return ["image", "audio", "video", "font", "model", "multipart"].includes(top)
+}
+
+const hidden = (item: string) => {
+  const normalized = item.replaceAll("\\", "/").replace(/\/+$/, "")
+  return normalized.split("/").some((part) => part.startsWith(".") && part.length > 1)
+}
+
+const sortHiddenLast = (items: string[], prefer: boolean) => {
+  if (prefer) return items
+  const visible: string[] = []
+  const hiddenItems: string[] = []
+  for (const item of items) {
+    if (hidden(item)) hiddenItems.push(item)
+    else visible.push(item)
+  }
+  return [...visible, ...hiddenItems]
+}
+
+interface State {
+  cache: Entry
+}
+
+export interface Interface {
+  readonly init: () => Effect.Effect<void>
+  readonly status: () => Effect.Effect<Info[]>
+  readonly read: (file: string) => Effect.Effect<Content>
+  readonly list: (dir?: string) => Effect.Effect<Node[]>
+  readonly search: (input: {
+    query: string
+    limit?: number
+    dirs?: boolean
+    type?: "file" | "directory"
+  }) => Effect.Effect<string[]>
+}
+
+export class Service extends Context.Service<Service, Interface>()("@opencode/File") {}
+
+export const layer = Layer.effect(
+  Service,
+  Effect.gen(function* () {
+    const appFs = yield* AppFileSystem.Service
+    const rg = yield* Ripgrep.Service
+    const git = yield* Git.Service
+
+    const state = yield* InstanceState.make<State>(
+      Effect.fn("File.state")(() =>
+        Effect.succeed({
+          cache: { files: [], dirs: [] } as Entry,
+        }),
+      ),
+    )
+
+    const scan = Effect.fn("File.scan")(function* () {
+      if (Instance.directory === path.parse(Instance.directory).root) return
+      const isGlobalHome = Instance.directory === Global.Path.home && Instance.project.id === "global"
+      const next: Entry = { files: [], dirs: [] }
+
+      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 = yield* appFs.readDirectoryEntries(Instance.directory).pipe(Effect.orElseSucceed(() => []))
+
+        for (const entry of top) {
+          if (entry.type !== "directory") continue
+          if (shouldIgnoreName(entry.name)) continue
+          dirs.add(entry.name + "/")
+
+          const base = path.join(Instance.directory, entry.name)
+          const children = yield* appFs.readDirectoryEntries(base).pipe(Effect.orElseSucceed(() => []))
+          for (const child of children) {
+            if (child.type !== "directory") continue
+            if (shouldIgnoreNested(child.name)) continue
+            dirs.add(entry.name + "/" + child.name + "/")
+          }
+        }
+
+        next.dirs = Array.from(dirs).toSorted()
+      } else {
+        const files = yield* rg.files({ cwd: Instance.directory }).pipe(
+          Stream.runCollect,
+          Effect.map((chunk) => [...chunk]),
+        )
+        const seen = new Set<string>()
+        for (const file of files) {
+          next.files.push(file)
+          let current = file
+          while (true) {
+            const dir = path.dirname(current)
+            if (dir === ".") break
+            if (dir === current) break
+            current = dir
+            if (seen.has(dir)) continue
+            seen.add(dir)
+            next.dirs.push(dir + "/")
+          }
+        }
+      }
+
+      const s = yield* InstanceState.get(state)
+      s.cache = next
+    })
+
+    let cachedScan = yield* Effect.cached(scan().pipe(Effect.catchCause(() => Effect.void)))
+
+    const ensure = Effect.fn("File.ensure")(function* () {
+      yield* cachedScan
+      cachedScan = yield* Effect.cached(scan().pipe(Effect.catchCause(() => Effect.void)))
+    })
+
+    const gitText = Effect.fnUntraced(function* (args: string[]) {
+      return (yield* git.run(args, { cwd: Instance.directory })).text()
+    })
+
+    const init = Effect.fn("File.init")(function* () {
+      yield* ensure()
+    })
+
+    const status = Effect.fn("File.status")(function* () {
+      if (Instance.project.vcs !== "git") return []
+
+      const diffOutput = yield* gitText([
+        "-c",
+        "core.fsmonitor=false",
+        "-c",
+        "core.quotepath=false",
+        "diff",
+        "--numstat",
+        "HEAD",
+      ])
+
+      const changed: Info[] = []
+
+      if (diffOutput.trim()) {
+        for (const line of diffOutput.trim().split("\n")) {
+          const [added, removed, file] = line.split("\t")
+          changed.push({
+            path: file,
+            added: added === "-" ? 0 : parseInt(added, 10),
+            removed: removed === "-" ? 0 : parseInt(removed, 10),
+            status: "modified",
+          })
+        }
+      }
+
+      const untrackedOutput = yield* gitText([
+        "-c",
+        "core.fsmonitor=false",
+        "-c",
+        "core.quotepath=false",
+        "ls-files",
+        "--others",
+        "--exclude-standard",
+      ])
+
+      if (untrackedOutput.trim()) {
+        for (const file of untrackedOutput.trim().split("\n")) {
+          const content = yield* appFs
+            .readFileString(path.join(Instance.directory, file))
+            .pipe(Effect.catch(() => Effect.succeed<string | undefined>(undefined)))
+          if (content === undefined) continue
+          changed.push({
+            path: file,
+            added: content.split("\n").length,
+            removed: 0,
+            status: "added",
+          })
+        }
+      }
+
+      const deletedOutput = yield* gitText([
+        "-c",
+        "core.fsmonitor=false",
+        "-c",
+        "core.quotepath=false",
+        "diff",
+        "--name-only",
+        "--diff-filter=D",
+        "HEAD",
+      ])
+
+      if (deletedOutput.trim()) {
+        for (const file of deletedOutput.trim().split("\n")) {
+          changed.push({
+            path: file,
+            added: 0,
+            removed: 0,
+            status: "deleted",
+          })
+        }
+      }
+
+      return changed.map((item) => {
+        const full = path.isAbsolute(item.path) ? item.path : path.join(Instance.directory, item.path)
+        return {
+          ...item,
+          path: path.relative(Instance.directory, full),
+        }
+      })
+    })
+
+    const read: Interface["read"] = Effect.fn("File.read")(function* (file: string) {
+      using _ = log.time("read", { file })
+      const full = path.join(Instance.directory, file)
+
+      if (!Instance.containsPath(full)) throw new Error("Access denied: path escapes project directory")
+
+      if (isImageByExtension(file)) {
+        const exists = yield* appFs.existsSafe(full)
+        if (exists) {
+          const bytes = yield* appFs.readFile(full).pipe(Effect.catch(() => Effect.succeed(new Uint8Array())))
+          return {
+            type: "text" as const,
+            content: Buffer.from(bytes).toString("base64"),
+            mimeType: getImageMimeType(file),
+            encoding: "base64" as const,
+          }
+        }
+        return { type: "text" as const, content: "" }
+      }
+
+      const knownText = isTextByExtension(file) || isTextByName(file)
+
+      if (isBinaryByExtension(file) && !knownText) return { type: "binary" as const, content: "" }
+
+      const exists = yield* appFs.existsSafe(full)
+      if (!exists) return { type: "text" as const, content: "" }
+
+      const mimeType = AppFileSystem.mimeType(full)
+      const encode = knownText ? false : shouldEncode(mimeType)
+
+      if (encode && !isImage(mimeType)) return { type: "binary" as const, content: "", mimeType }
+
+      if (encode) {
+        const bytes = yield* appFs.readFile(full).pipe(Effect.catch(() => Effect.succeed(new Uint8Array())))
+        return {
+          type: "text" as const,
+          content: Buffer.from(bytes).toString("base64"),
+          mimeType,
+          encoding: "base64" as const,
+        }
+      }
+
+      const content = yield* appFs.readFileString(full).pipe(
+        Effect.map((s) => s.trim()),
+        Effect.catch(() => Effect.succeed("")),
+      )
+
+      if (Instance.project.vcs === "git") {
+        let diff = yield* gitText(["-c", "core.fsmonitor=false", "diff", "--", file])
+        if (!diff.trim()) {
+          diff = yield* gitText(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file])
+        }
+        if (diff.trim()) {
+          const original = yield* git.show(Instance.directory, "HEAD", file)
+          const patch = structuredPatch(file, file, original, content, "old", "new", {
+            context: Infinity,
+            ignoreWhitespace: true,
+          })
+          return { type: "text" as const, content, patch, diff: formatPatch(patch) }
+        }
+        return { type: "text" as const, content }
+      }
+
+      return { type: "text" as const, content }
+    })
+
+    const list = Effect.fn("File.list")(function* (dir?: string) {
+      const exclude = [".git", ".DS_Store"]
+      let ignored = (_: string) => false
+      if (Instance.project.vcs === "git") {
+        const ig = ignore()
+        const gitignore = path.join(Instance.project.worktree, ".gitignore")
+        const gitignoreText = yield* appFs.readFileString(gitignore).pipe(Effect.catch(() => Effect.succeed("")))
+        if (gitignoreText) ig.add(gitignoreText)
+        const ignoreFile = path.join(Instance.project.worktree, ".ignore")
+        const ignoreText = yield* appFs.readFileString(ignoreFile).pipe(Effect.catch(() => Effect.succeed("")))
+        if (ignoreText) ig.add(ignoreText)
+        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 entries = yield* appFs.readDirectoryEntries(resolved).pipe(Effect.orElseSucceed(() => []))
+
+      const nodes: Node[] = []
+      for (const entry of entries) {
+        if (exclude.includes(entry.name)) continue
+        const absolute = path.join(resolved, entry.name)
+        const file = path.relative(Instance.directory, absolute)
+        const type = entry.type === "directory" ? "directory" : "file"
+        nodes.push({
+          name: entry.name,
+          path: file,
+          absolute,
+          type,
+          ignored: ignored(type === "directory" ? file + "/" : file),
+        })
+      }
+      return nodes.sort((a, b) => {
+        if (a.type !== b.type) return a.type === "directory" ? -1 : 1
+        return a.name.localeCompare(b.name)
+      })
+    })
+
+    const search = Effect.fn("File.search")(function* (input: {
+      query: string
+      limit?: number
+      dirs?: boolean
+      type?: "file" | "directory"
+    }) {
+      yield* ensure()
+      const { cache } = yield* InstanceState.get(state)
+
+      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 preferHidden = query.startsWith(".") || query.includes("/.")
+
+      if (!query) {
+        if (kind === "file") return cache.files.slice(0, limit)
+        return sortHiddenLast(cache.dirs.toSorted(), preferHidden).slice(0, limit)
+      }
+
+      const items =
+        kind === "file" ? cache.files : kind === "directory" ? cache.dirs : [...cache.files, ...cache.dirs]
+
+      const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit
+      const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((item) => item.target)
+      const output = kind === "directory" ? sortHiddenLast(sorted, preferHidden).slice(0, limit) : sorted
+
+      log.info("search", { query, kind, results: output.length })
+      return output
+    })
+
+    log.info("init")
+    return Service.of({ init, status, read, list, search })
+  }),
+)
+
+export const defaultLayer = layer.pipe(
+  Layer.provide(Ripgrep.defaultLayer),
+  Layer.provide(AppFileSystem.defaultLayer),
+  Layer.provide(Git.defaultLayer),
+)

+ 1 - 656
packages/opencode/src/file/index.ts

@@ -1,656 +1 @@
-import { BusEvent } from "@/bus/bus-event"
-import { InstanceState } from "@/effect/instance-state"
-
-import { AppFileSystem } from "@opencode-ai/shared/filesystem"
-import { Git } from "@/git"
-import { Effect, Layer, Context } from "effect"
-import * as Stream from "effect/Stream"
-import { formatPatch, structuredPatch } from "diff"
-import fuzzysort from "fuzzysort"
-import ignore from "ignore"
-import path from "path"
-import z from "zod"
-import { Global } from "../global"
-import { Instance } from "../project/instance"
-import { Log } from "../util/log"
-import { Protected } from "./protected"
-import { Ripgrep } from "./ripgrep"
-
-export namespace File {
-  export const Info = z
-    .object({
-      path: z.string(),
-      added: z.number().int(),
-      removed: z.number().int(),
-      status: z.enum(["added", "deleted", "modified"]),
-    })
-    .meta({
-      ref: "File",
-    })
-
-  export type Info = z.infer<typeof Info>
-
-  export const Node = z
-    .object({
-      name: z.string(),
-      path: z.string(),
-      absolute: z.string(),
-      type: z.enum(["file", "directory"]),
-      ignored: z.boolean(),
-    })
-    .meta({
-      ref: "FileNode",
-    })
-  export type Node = z.infer<typeof Node>
-
-  export const Content = z
-    .object({
-      type: z.enum(["text", "binary"]),
-      content: z.string(),
-      diff: z.string().optional(),
-      patch: z
-        .object({
-          oldFileName: z.string(),
-          newFileName: z.string(),
-          oldHeader: z.string().optional(),
-          newHeader: z.string().optional(),
-          hunks: z.array(
-            z.object({
-              oldStart: z.number(),
-              oldLines: z.number(),
-              newStart: z.number(),
-              newLines: z.number(),
-              lines: z.array(z.string()),
-            }),
-          ),
-          index: z.string().optional(),
-        })
-        .optional(),
-      encoding: z.literal("base64").optional(),
-      mimeType: z.string().optional(),
-    })
-    .meta({
-      ref: "FileContent",
-    })
-  export type Content = z.infer<typeof Content>
-
-  export const Event = {
-    Edited: BusEvent.define(
-      "file.edited",
-      z.object({
-        file: z.string(),
-      }),
-    ),
-  }
-
-  const log = Log.create({ service: "file" })
-
-  const binary = new Set([
-    "exe",
-    "dll",
-    "pdb",
-    "bin",
-    "so",
-    "dylib",
-    "o",
-    "a",
-    "lib",
-    "wav",
-    "mp3",
-    "ogg",
-    "oga",
-    "ogv",
-    "ogx",
-    "flac",
-    "aac",
-    "wma",
-    "m4a",
-    "weba",
-    "mp4",
-    "avi",
-    "mov",
-    "wmv",
-    "flv",
-    "webm",
-    "mkv",
-    "zip",
-    "tar",
-    "gz",
-    "gzip",
-    "bz",
-    "bz2",
-    "bzip",
-    "bzip2",
-    "7z",
-    "rar",
-    "xz",
-    "lz",
-    "z",
-    "pdf",
-    "doc",
-    "docx",
-    "ppt",
-    "pptx",
-    "xls",
-    "xlsx",
-    "dmg",
-    "iso",
-    "img",
-    "vmdk",
-    "ttf",
-    "otf",
-    "woff",
-    "woff2",
-    "eot",
-    "sqlite",
-    "db",
-    "mdb",
-    "apk",
-    "ipa",
-    "aab",
-    "xapk",
-    "app",
-    "pkg",
-    "deb",
-    "rpm",
-    "snap",
-    "flatpak",
-    "appimage",
-    "msi",
-    "msp",
-    "jar",
-    "war",
-    "ear",
-    "class",
-    "kotlin_module",
-    "dex",
-    "vdex",
-    "odex",
-    "oat",
-    "art",
-    "wasm",
-    "wat",
-    "bc",
-    "ll",
-    "s",
-    "ko",
-    "sys",
-    "drv",
-    "efi",
-    "rom",
-    "com",
-  ])
-
-  const image = new Set([
-    "png",
-    "jpg",
-    "jpeg",
-    "gif",
-    "bmp",
-    "webp",
-    "ico",
-    "tif",
-    "tiff",
-    "svg",
-    "svgz",
-    "avif",
-    "apng",
-    "jxl",
-    "heic",
-    "heif",
-    "raw",
-    "cr2",
-    "nef",
-    "arw",
-    "dng",
-    "orf",
-    "raf",
-    "pef",
-    "x3f",
-  ])
-
-  const text = new Set([
-    "ts",
-    "tsx",
-    "mts",
-    "cts",
-    "mtsx",
-    "ctsx",
-    "js",
-    "jsx",
-    "mjs",
-    "cjs",
-    "sh",
-    "bash",
-    "zsh",
-    "fish",
-    "ps1",
-    "psm1",
-    "cmd",
-    "bat",
-    "json",
-    "jsonc",
-    "json5",
-    "yaml",
-    "yml",
-    "toml",
-    "md",
-    "mdx",
-    "txt",
-    "xml",
-    "html",
-    "htm",
-    "css",
-    "scss",
-    "sass",
-    "less",
-    "graphql",
-    "gql",
-    "sql",
-    "ini",
-    "cfg",
-    "conf",
-    "env",
-  ])
-
-  const textName = new Set([
-    "dockerfile",
-    "makefile",
-    ".gitignore",
-    ".gitattributes",
-    ".editorconfig",
-    ".npmrc",
-    ".nvmrc",
-    ".prettierrc",
-    ".eslintrc",
-  ])
-
-  const mime: Record<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",
-  }
-
-  type Entry = { files: string[]; dirs: string[] }
-
-  const ext = (file: string) => path.extname(file).toLowerCase().slice(1)
-  const name = (file: string) => path.basename(file).toLowerCase()
-  const isImageByExtension = (file: string) => image.has(ext(file))
-  const isTextByExtension = (file: string) => text.has(ext(file))
-  const isTextByName = (file: string) => textName.has(name(file))
-  const isBinaryByExtension = (file: string) => binary.has(ext(file))
-  const isImage = (mimeType: string) => mimeType.startsWith("image/")
-  const getImageMimeType = (file: string) => mime[ext(file)] || "image/" + ext(file)
-
-  function shouldEncode(mimeType: string) {
-    const type = mimeType.toLowerCase()
-    log.debug("shouldEncode", { type })
-    if (!type) return false
-    if (type.startsWith("text/")) return false
-    if (type.includes("charset=")) return false
-    const top = type.split("/", 2)[0]
-    return ["image", "audio", "video", "font", "model", "multipart"].includes(top)
-  }
-
-  const hidden = (item: string) => {
-    const normalized = item.replaceAll("\\", "/").replace(/\/+$/, "")
-    return normalized.split("/").some((part) => part.startsWith(".") && part.length > 1)
-  }
-
-  const sortHiddenLast = (items: string[], prefer: boolean) => {
-    if (prefer) return items
-    const visible: string[] = []
-    const hiddenItems: string[] = []
-    for (const item of items) {
-      if (hidden(item)) hiddenItems.push(item)
-      else visible.push(item)
-    }
-    return [...visible, ...hiddenItems]
-  }
-
-  interface State {
-    cache: Entry
-  }
-
-  export interface Interface {
-    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[]>
-  }
-
-  export class Service extends Context.Service<Service, Interface>()("@opencode/File") {}
-
-  export const layer = Layer.effect(
-    Service,
-    Effect.gen(function* () {
-      const appFs = yield* AppFileSystem.Service
-      const rg = yield* Ripgrep.Service
-      const git = yield* Git.Service
-
-      const state = yield* InstanceState.make<State>(
-        Effect.fn("File.state")(() =>
-          Effect.succeed({
-            cache: { files: [], dirs: [] } as Entry,
-          }),
-        ),
-      )
-
-      const scan = Effect.fn("File.scan")(function* () {
-        if (Instance.directory === path.parse(Instance.directory).root) return
-        const isGlobalHome = Instance.directory === Global.Path.home && Instance.project.id === "global"
-        const next: Entry = { files: [], dirs: [] }
-
-        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 = yield* appFs.readDirectoryEntries(Instance.directory).pipe(Effect.orElseSucceed(() => []))
-
-          for (const entry of top) {
-            if (entry.type !== "directory") continue
-            if (shouldIgnoreName(entry.name)) continue
-            dirs.add(entry.name + "/")
-
-            const base = path.join(Instance.directory, entry.name)
-            const children = yield* appFs.readDirectoryEntries(base).pipe(Effect.orElseSucceed(() => []))
-            for (const child of children) {
-              if (child.type !== "directory") continue
-              if (shouldIgnoreNested(child.name)) continue
-              dirs.add(entry.name + "/" + child.name + "/")
-            }
-          }
-
-          next.dirs = Array.from(dirs).toSorted()
-        } else {
-          const files = yield* rg.files({ cwd: Instance.directory }).pipe(
-            Stream.runCollect,
-            Effect.map((chunk) => [...chunk]),
-          )
-          const seen = new Set<string>()
-          for (const file of files) {
-            next.files.push(file)
-            let current = file
-            while (true) {
-              const dir = path.dirname(current)
-              if (dir === ".") break
-              if (dir === current) break
-              current = dir
-              if (seen.has(dir)) continue
-              seen.add(dir)
-              next.dirs.push(dir + "/")
-            }
-          }
-        }
-
-        const s = yield* InstanceState.get(state)
-        s.cache = next
-      })
-
-      let cachedScan = yield* Effect.cached(scan().pipe(Effect.catchCause(() => Effect.void)))
-
-      const ensure = Effect.fn("File.ensure")(function* () {
-        yield* cachedScan
-        cachedScan = yield* Effect.cached(scan().pipe(Effect.catchCause(() => Effect.void)))
-      })
-
-      const gitText = Effect.fnUntraced(function* (args: string[]) {
-        return (yield* git.run(args, { cwd: Instance.directory })).text()
-      })
-
-      const init = Effect.fn("File.init")(function* () {
-        yield* ensure()
-      })
-
-      const status = Effect.fn("File.status")(function* () {
-        if (Instance.project.vcs !== "git") return []
-
-        const diffOutput = yield* gitText([
-          "-c",
-          "core.fsmonitor=false",
-          "-c",
-          "core.quotepath=false",
-          "diff",
-          "--numstat",
-          "HEAD",
-        ])
-
-        const changed: File.Info[] = []
-
-        if (diffOutput.trim()) {
-          for (const line of diffOutput.trim().split("\n")) {
-            const [added, removed, file] = line.split("\t")
-            changed.push({
-              path: file,
-              added: added === "-" ? 0 : parseInt(added, 10),
-              removed: removed === "-" ? 0 : parseInt(removed, 10),
-              status: "modified",
-            })
-          }
-        }
-
-        const untrackedOutput = yield* gitText([
-          "-c",
-          "core.fsmonitor=false",
-          "-c",
-          "core.quotepath=false",
-          "ls-files",
-          "--others",
-          "--exclude-standard",
-        ])
-
-        if (untrackedOutput.trim()) {
-          for (const file of untrackedOutput.trim().split("\n")) {
-            const content = yield* appFs
-              .readFileString(path.join(Instance.directory, file))
-              .pipe(Effect.catch(() => Effect.succeed<string | undefined>(undefined)))
-            if (content === undefined) continue
-            changed.push({
-              path: file,
-              added: content.split("\n").length,
-              removed: 0,
-              status: "added",
-            })
-          }
-        }
-
-        const deletedOutput = yield* gitText([
-          "-c",
-          "core.fsmonitor=false",
-          "-c",
-          "core.quotepath=false",
-          "diff",
-          "--name-only",
-          "--diff-filter=D",
-          "HEAD",
-        ])
-
-        if (deletedOutput.trim()) {
-          for (const file of deletedOutput.trim().split("\n")) {
-            changed.push({
-              path: file,
-              added: 0,
-              removed: 0,
-              status: "deleted",
-            })
-          }
-        }
-
-        return changed.map((item) => {
-          const full = path.isAbsolute(item.path) ? item.path : path.join(Instance.directory, item.path)
-          return {
-            ...item,
-            path: path.relative(Instance.directory, full),
-          }
-        })
-      })
-
-      const read: Interface["read"] = Effect.fn("File.read")(function* (file: string) {
-        using _ = log.time("read", { file })
-        const full = path.join(Instance.directory, file)
-
-        if (!Instance.containsPath(full)) throw new Error("Access denied: path escapes project directory")
-
-        if (isImageByExtension(file)) {
-          const exists = yield* appFs.existsSafe(full)
-          if (exists) {
-            const bytes = yield* appFs.readFile(full).pipe(Effect.catch(() => Effect.succeed(new Uint8Array())))
-            return {
-              type: "text" as const,
-              content: Buffer.from(bytes).toString("base64"),
-              mimeType: getImageMimeType(file),
-              encoding: "base64" as const,
-            }
-          }
-          return { type: "text" as const, content: "" }
-        }
-
-        const knownText = isTextByExtension(file) || isTextByName(file)
-
-        if (isBinaryByExtension(file) && !knownText) return { type: "binary" as const, content: "" }
-
-        const exists = yield* appFs.existsSafe(full)
-        if (!exists) return { type: "text" as const, content: "" }
-
-        const mimeType = AppFileSystem.mimeType(full)
-        const encode = knownText ? false : shouldEncode(mimeType)
-
-        if (encode && !isImage(mimeType)) return { type: "binary" as const, content: "", mimeType }
-
-        if (encode) {
-          const bytes = yield* appFs.readFile(full).pipe(Effect.catch(() => Effect.succeed(new Uint8Array())))
-          return {
-            type: "text" as const,
-            content: Buffer.from(bytes).toString("base64"),
-            mimeType,
-            encoding: "base64" as const,
-          }
-        }
-
-        const content = yield* appFs.readFileString(full).pipe(
-          Effect.map((s) => s.trim()),
-          Effect.catch(() => Effect.succeed("")),
-        )
-
-        if (Instance.project.vcs === "git") {
-          let diff = yield* gitText(["-c", "core.fsmonitor=false", "diff", "--", file])
-          if (!diff.trim()) {
-            diff = yield* gitText(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file])
-          }
-          if (diff.trim()) {
-            const original = yield* git.show(Instance.directory, "HEAD", file)
-            const patch = structuredPatch(file, file, original, content, "old", "new", {
-              context: Infinity,
-              ignoreWhitespace: true,
-            })
-            return { type: "text" as const, content, patch, diff: formatPatch(patch) }
-          }
-          return { type: "text" as const, content }
-        }
-
-        return { type: "text" as const, content }
-      })
-
-      const list = Effect.fn("File.list")(function* (dir?: string) {
-        const exclude = [".git", ".DS_Store"]
-        let ignored = (_: string) => false
-        if (Instance.project.vcs === "git") {
-          const ig = ignore()
-          const gitignore = path.join(Instance.project.worktree, ".gitignore")
-          const gitignoreText = yield* appFs.readFileString(gitignore).pipe(Effect.catch(() => Effect.succeed("")))
-          if (gitignoreText) ig.add(gitignoreText)
-          const ignoreFile = path.join(Instance.project.worktree, ".ignore")
-          const ignoreText = yield* appFs.readFileString(ignoreFile).pipe(Effect.catch(() => Effect.succeed("")))
-          if (ignoreText) ig.add(ignoreText)
-          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 entries = yield* appFs.readDirectoryEntries(resolved).pipe(Effect.orElseSucceed(() => []))
-
-        const nodes: File.Node[] = []
-        for (const entry of entries) {
-          if (exclude.includes(entry.name)) continue
-          const absolute = path.join(resolved, entry.name)
-          const file = path.relative(Instance.directory, absolute)
-          const type = entry.type === "directory" ? "directory" : "file"
-          nodes.push({
-            name: entry.name,
-            path: file,
-            absolute,
-            type,
-            ignored: ignored(type === "directory" ? file + "/" : file),
-          })
-        }
-        return nodes.sort((a, b) => {
-          if (a.type !== b.type) return a.type === "directory" ? -1 : 1
-          return a.name.localeCompare(b.name)
-        })
-      })
-
-      const search = Effect.fn("File.search")(function* (input: {
-        query: string
-        limit?: number
-        dirs?: boolean
-        type?: "file" | "directory"
-      }) {
-        yield* ensure()
-        const { cache } = yield* InstanceState.get(state)
-
-        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 preferHidden = query.startsWith(".") || query.includes("/.")
-
-        if (!query) {
-          if (kind === "file") return cache.files.slice(0, limit)
-          return sortHiddenLast(cache.dirs.toSorted(), preferHidden).slice(0, limit)
-        }
-
-        const items =
-          kind === "file" ? cache.files : kind === "directory" ? cache.dirs : [...cache.files, ...cache.dirs]
-
-        const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit
-        const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((item) => item.target)
-        const output = kind === "directory" ? sortHiddenLast(sorted, preferHidden).slice(0, limit) : sorted
-
-        log.info("search", { query, kind, results: output.length })
-        return output
-      })
-
-      log.info("init")
-      return Service.of({ init, status, read, list, search })
-    }),
-  )
-
-  export const defaultLayer = layer.pipe(
-    Layer.provide(Ripgrep.defaultLayer),
-    Layer.provide(AppFileSystem.defaultLayer),
-    Layer.provide(Git.defaultLayer),
-  )
-}
+export * as File from "./file"