Skip to content

Commit aa9ed00

Browse files
authored
refactor(file): use AppFileSystem instead of raw Filesystem (anomalyco#19458)
1 parent 6086072 commit aa9ed00

2 files changed

Lines changed: 109 additions & 89 deletions

File tree

packages/opencode/src/file/index.ts

Lines changed: 80 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { BusEvent } from "@/bus/bus-event"
22
import { InstanceState } from "@/effect/instance-state"
33
import { makeRuntime } from "@/effect/run-service"
4+
import { AppFileSystem } from "@/filesystem"
45
import { git } from "@/util/git"
56
import { Effect, Layer, ServiceMap } from "effect"
67
import { formatPatch, structuredPatch } from "diff"
@@ -343,6 +344,8 @@ export namespace File {
343344
export const layer = Layer.effect(
344345
Service,
345346
Effect.gen(function* () {
347+
const appFs = yield* AppFileSystem.Service
348+
346349
const state = yield* InstanceState.make<State>(
347350
Effect.fn("File.state")(() =>
348351
Effect.succeed({
@@ -512,57 +515,54 @@ export namespace File {
512515
})
513516

514517
const read = Effect.fn("File.read")(function* (file: string) {
515-
return yield* Effect.promise(async (): Promise<File.Content> => {
516-
using _ = log.time("read", { file })
517-
const full = path.join(Instance.directory, file)
518+
using _ = log.time("read", { file })
519+
const full = path.join(Instance.directory, file)
518520

519-
if (!Instance.containsPath(full)) {
520-
throw new Error("Access denied: path escapes project directory")
521-
}
521+
if (!Instance.containsPath(full)) throw new Error("Access denied: path escapes project directory")
522522

523-
if (isImageByExtension(file)) {
524-
if (await Filesystem.exists(full)) {
525-
const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
526-
return {
527-
type: "text",
528-
content: buffer.toString("base64"),
529-
mimeType: getImageMimeType(file),
530-
encoding: "base64",
531-
}
523+
if (isImageByExtension(file)) {
524+
const exists = yield* appFs.existsSafe(full)
525+
if (exists) {
526+
const bytes = yield* appFs.readFile(full).pipe(Effect.catch(() => Effect.succeed(new Uint8Array())))
527+
return {
528+
type: "text" as const,
529+
content: Buffer.from(bytes).toString("base64"),
530+
mimeType: getImageMimeType(file),
531+
encoding: "base64" as const,
532532
}
533-
return { type: "text", content: "" }
534533
}
534+
return { type: "text" as const, content: "" }
535+
}
535536

536-
const knownText = isTextByExtension(file) || isTextByName(file)
537+
const knownText = isTextByExtension(file) || isTextByName(file)
537538

538-
if (isBinaryByExtension(file) && !knownText) {
539-
return { type: "binary", content: "" }
540-
}
539+
if (isBinaryByExtension(file) && !knownText) return { type: "binary" as const, content: "" }
541540

542-
if (!(await Filesystem.exists(full))) {
543-
return { type: "text", content: "" }
544-
}
541+
const exists = yield* appFs.existsSafe(full)
542+
if (!exists) return { type: "text" as const, content: "" }
545543

546-
const mimeType = Filesystem.mimeType(full)
547-
const encode = knownText ? false : shouldEncode(mimeType)
544+
const mimeType = Filesystem.mimeType(full)
545+
const encode = knownText ? false : shouldEncode(mimeType)
548546

549-
if (encode && !isImage(mimeType)) {
550-
return { type: "binary", content: "", mimeType }
551-
}
547+
if (encode && !isImage(mimeType)) return { type: "binary" as const, content: "", mimeType }
552548

553-
if (encode) {
554-
const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
555-
return {
556-
type: "text",
557-
content: buffer.toString("base64"),
558-
mimeType,
559-
encoding: "base64",
560-
}
549+
if (encode) {
550+
const bytes = yield* appFs.readFile(full).pipe(Effect.catch(() => Effect.succeed(new Uint8Array())))
551+
return {
552+
type: "text" as const,
553+
content: Buffer.from(bytes).toString("base64"),
554+
mimeType,
555+
encoding: "base64" as const,
561556
}
557+
}
562558

563-
const content = (await Filesystem.readText(full).catch(() => "")).trim()
559+
const content = yield* appFs.readFileString(full).pipe(
560+
Effect.map((s) => s.trim()),
561+
Effect.catch(() => Effect.succeed("")),
562+
)
564563

565-
if (Instance.project.vcs === "git") {
564+
if (Instance.project.vcs === "git") {
565+
return yield* Effect.promise(async (): Promise<File.Content> => {
566566
let diff = (
567567
await git(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: Instance.directory })
568568
).text()
@@ -579,60 +579,51 @@ export namespace File {
579579
context: Infinity,
580580
ignoreWhitespace: true,
581581
})
582-
return {
583-
type: "text",
584-
content,
585-
patch,
586-
diff: formatPatch(patch),
587-
}
582+
return { type: "text", content, patch, diff: formatPatch(patch) }
588583
}
589-
}
584+
return { type: "text", content }
585+
})
586+
}
590587

591-
return { type: "text", content }
592-
})
588+
return { type: "text" as const, content }
593589
})
594590

595591
const list = Effect.fn("File.list")(function* (dir?: string) {
596-
return yield* Effect.promise(async () => {
597-
const exclude = [".git", ".DS_Store"]
598-
let ignored = (_: string) => false
599-
if (Instance.project.vcs === "git") {
600-
const ig = ignore()
601-
const gitignore = path.join(Instance.project.worktree, ".gitignore")
602-
if (await Filesystem.exists(gitignore)) {
603-
ig.add(await Filesystem.readText(gitignore))
604-
}
605-
const ignoreFile = path.join(Instance.project.worktree, ".ignore")
606-
if (await Filesystem.exists(ignoreFile)) {
607-
ig.add(await Filesystem.readText(ignoreFile))
608-
}
609-
ignored = ig.ignores.bind(ig)
610-
}
611-
612-
const resolved = dir ? path.join(Instance.directory, dir) : Instance.directory
613-
if (!Instance.containsPath(resolved)) {
614-
throw new Error("Access denied: path escapes project directory")
615-
}
616-
617-
const nodes: File.Node[] = []
618-
for (const entry of await fs.promises.readdir(resolved, { withFileTypes: true }).catch(() => [])) {
619-
if (exclude.includes(entry.name)) continue
620-
const absolute = path.join(resolved, entry.name)
621-
const file = path.relative(Instance.directory, absolute)
622-
const type = entry.isDirectory() ? "directory" : "file"
623-
nodes.push({
624-
name: entry.name,
625-
path: file,
626-
absolute,
627-
type,
628-
ignored: ignored(type === "directory" ? file + "/" : file),
629-
})
630-
}
631-
632-
return nodes.sort((a, b) => {
633-
if (a.type !== b.type) return a.type === "directory" ? -1 : 1
634-
return a.name.localeCompare(b.name)
592+
const exclude = [".git", ".DS_Store"]
593+
let ignored = (_: string) => false
594+
if (Instance.project.vcs === "git") {
595+
const ig = ignore()
596+
const gitignore = path.join(Instance.project.worktree, ".gitignore")
597+
const gitignoreText = yield* appFs.readFileString(gitignore).pipe(Effect.catch(() => Effect.succeed("")))
598+
if (gitignoreText) ig.add(gitignoreText)
599+
const ignoreFile = path.join(Instance.project.worktree, ".ignore")
600+
const ignoreText = yield* appFs.readFileString(ignoreFile).pipe(Effect.catch(() => Effect.succeed("")))
601+
if (ignoreText) ig.add(ignoreText)
602+
ignored = ig.ignores.bind(ig)
603+
}
604+
605+
const resolved = dir ? path.join(Instance.directory, dir) : Instance.directory
606+
if (!Instance.containsPath(resolved)) throw new Error("Access denied: path escapes project directory")
607+
608+
const entries = yield* appFs.readDirectoryEntries(resolved).pipe(Effect.orElseSucceed(() => []))
609+
610+
const nodes: File.Node[] = []
611+
for (const entry of entries) {
612+
if (exclude.includes(entry.name)) continue
613+
const absolute = path.join(resolved, entry.name)
614+
const file = path.relative(Instance.directory, absolute)
615+
const type = entry.type === "directory" ? "directory" : "file"
616+
nodes.push({
617+
name: entry.name,
618+
path: file,
619+
absolute,
620+
type,
621+
ignored: ignored(type === "directory" ? file + "/" : file),
635622
})
623+
}
624+
return nodes.sort((a, b) => {
625+
if (a.type !== b.type) return a.type === "directory" ? -1 : 1
626+
return a.name.localeCompare(b.name)
636627
})
637628
})
638629

@@ -676,7 +667,9 @@ export namespace File {
676667
}),
677668
)
678669

679-
const { runPromise } = makeRuntime(Service, layer)
670+
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
671+
672+
const { runPromise } = makeRuntime(Service, defaultLayer)
680673

681674
export function init() {
682675
return runPromise((svc) => svc.init())

packages/opencode/src/filesystem/index.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { NodeFileSystem } from "@effect/platform-node"
22
import { dirname, join, relative, resolve as pathResolve } from "path"
33
import { realpathSync } from "fs"
4+
import * as NFS from "fs/promises"
45
import { lookup } from "mime-types"
56
import { Effect, FileSystem, Layer, Schema, ServiceMap } from "effect"
67
import type { PlatformError } from "effect/PlatformError"
@@ -14,13 +15,20 @@ export namespace AppFileSystem {
1415

1516
export type Error = PlatformError | FileSystemError
1617

18+
export interface DirEntry {
19+
readonly name: string
20+
readonly type: "file" | "directory" | "symlink" | "other"
21+
}
22+
1723
export interface Interface extends FileSystem.FileSystem {
18-
readonly isDir: (path: string) => Effect.Effect<boolean, Error>
19-
readonly isFile: (path: string) => Effect.Effect<boolean, Error>
24+
readonly isDir: (path: string) => Effect.Effect<boolean>
25+
readonly isFile: (path: string) => Effect.Effect<boolean>
26+
readonly existsSafe: (path: string) => Effect.Effect<boolean>
2027
readonly readJson: (path: string) => Effect.Effect<unknown, Error>
2128
readonly writeJson: (path: string, data: unknown, mode?: number) => Effect.Effect<void, Error>
2229
readonly ensureDir: (path: string) => Effect.Effect<void, Error>
2330
readonly writeWithDirs: (path: string, content: string | Uint8Array, mode?: number) => Effect.Effect<void, Error>
31+
readonly readDirectoryEntries: (path: string) => Effect.Effect<DirEntry[], Error>
2432
readonly findUp: (target: string, start: string, stop?: string) => Effect.Effect<string[], Error>
2533
readonly up: (options: { targets: string[]; start: string; stop?: string }) => Effect.Effect<string[], Error>
2634
readonly globUp: (pattern: string, start: string, stop?: string) => Effect.Effect<string[], Error>
@@ -35,6 +43,10 @@ export namespace AppFileSystem {
3543
Effect.gen(function* () {
3644
const fs = yield* FileSystem.FileSystem
3745

46+
const existsSafe = Effect.fn("FileSystem.existsSafe")(function* (path: string) {
47+
return yield* fs.exists(path).pipe(Effect.orElseSucceed(() => false))
48+
})
49+
3850
const isDir = Effect.fn("FileSystem.isDir")(function* (path: string) {
3951
const info = yield* fs.stat(path).pipe(Effect.catch(() => Effect.void))
4052
return info?.type === "Directory"
@@ -45,6 +57,19 @@ export namespace AppFileSystem {
4557
return info?.type === "File"
4658
})
4759

60+
const readDirectoryEntries = Effect.fn("FileSystem.readDirectoryEntries")(function* (dirPath: string) {
61+
return yield* Effect.tryPromise({
62+
try: async () => {
63+
const entries = await NFS.readdir(dirPath, { withFileTypes: true })
64+
return entries.map((e): DirEntry => ({
65+
name: e.name,
66+
type: e.isDirectory() ? "directory" : e.isSymbolicLink() ? "symlink" : e.isFile() ? "file" : "other",
67+
}))
68+
},
69+
catch: (cause) => new FileSystemError({ method: "readDirectoryEntries", cause }),
70+
})
71+
})
72+
4873
const readJson = Effect.fn("FileSystem.readJson")(function* (path: string) {
4974
const text = yield* fs.readFileString(path)
5075
return JSON.parse(text)
@@ -135,8 +160,10 @@ export namespace AppFileSystem {
135160

136161
return Service.of({
137162
...fs,
163+
existsSafe,
138164
isDir,
139165
isFile,
166+
readDirectoryEntries,
140167
readJson,
141168
writeJson,
142169
ensureDir,

0 commit comments

Comments
 (0)