refactor(file): use AppFileSystem instead of raw Filesystem (#19458)
This commit is contained in:
parent
6086072567
commit
aa9ed001d3
2 changed files with 109 additions and 89 deletions
|
|
@ -1,6 +1,7 @@
|
||||||
import { BusEvent } from "@/bus/bus-event"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
import { makeRuntime } from "@/effect/run-service"
|
||||||
|
import { AppFileSystem } from "@/filesystem"
|
||||||
import { git } from "@/util/git"
|
import { git } from "@/util/git"
|
||||||
import { Effect, Layer, ServiceMap } from "effect"
|
import { Effect, Layer, ServiceMap } from "effect"
|
||||||
import { formatPatch, structuredPatch } from "diff"
|
import { formatPatch, structuredPatch } from "diff"
|
||||||
|
|
@ -343,6 +344,8 @@ export namespace File {
|
||||||
export const layer = Layer.effect(
|
export const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
const appFs = yield* AppFileSystem.Service
|
||||||
|
|
||||||
const state = yield* InstanceState.make<State>(
|
const state = yield* InstanceState.make<State>(
|
||||||
Effect.fn("File.state")(() =>
|
Effect.fn("File.state")(() =>
|
||||||
Effect.succeed({
|
Effect.succeed({
|
||||||
|
|
@ -512,57 +515,54 @@ export namespace File {
|
||||||
})
|
})
|
||||||
|
|
||||||
const read = Effect.fn("File.read")(function* (file: string) {
|
const read = Effect.fn("File.read")(function* (file: string) {
|
||||||
return yield* Effect.promise(async (): Promise<File.Content> => {
|
using _ = log.time("read", { file })
|
||||||
using _ = log.time("read", { file })
|
const full = path.join(Instance.directory, file)
|
||||||
const full = path.join(Instance.directory, file)
|
|
||||||
|
|
||||||
if (!Instance.containsPath(full)) {
|
if (!Instance.containsPath(full)) throw new Error("Access denied: path escapes project directory")
|
||||||
throw new Error("Access denied: path escapes project directory")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isImageByExtension(file)) {
|
if (isImageByExtension(file)) {
|
||||||
if (await Filesystem.exists(full)) {
|
const exists = yield* appFs.existsSafe(full)
|
||||||
const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
|
if (exists) {
|
||||||
return {
|
const bytes = yield* appFs.readFile(full).pipe(Effect.catch(() => Effect.succeed(new Uint8Array())))
|
||||||
type: "text",
|
|
||||||
content: buffer.toString("base64"),
|
|
||||||
mimeType: getImageMimeType(file),
|
|
||||||
encoding: "base64",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { type: "text", content: "" }
|
|
||||||
}
|
|
||||||
|
|
||||||
const knownText = isTextByExtension(file) || isTextByName(file)
|
|
||||||
|
|
||||||
if (isBinaryByExtension(file) && !knownText) {
|
|
||||||
return { type: "binary", content: "" }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!(await Filesystem.exists(full))) {
|
|
||||||
return { type: "text", content: "" }
|
|
||||||
}
|
|
||||||
|
|
||||||
const mimeType = Filesystem.mimeType(full)
|
|
||||||
const encode = knownText ? false : shouldEncode(mimeType)
|
|
||||||
|
|
||||||
if (encode && !isImage(mimeType)) {
|
|
||||||
return { type: "binary", content: "", mimeType }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (encode) {
|
|
||||||
const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
|
|
||||||
return {
|
return {
|
||||||
type: "text",
|
type: "text" as const,
|
||||||
content: buffer.toString("base64"),
|
content: Buffer.from(bytes).toString("base64"),
|
||||||
mimeType,
|
mimeType: getImageMimeType(file),
|
||||||
encoding: "base64",
|
encoding: "base64" as const,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return { type: "text" as const, content: "" }
|
||||||
|
}
|
||||||
|
|
||||||
const content = (await Filesystem.readText(full).catch(() => "")).trim()
|
const knownText = isTextByExtension(file) || isTextByName(file)
|
||||||
|
|
||||||
if (Instance.project.vcs === "git") {
|
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 = Filesystem.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") {
|
||||||
|
return yield* Effect.promise(async (): Promise<File.Content> => {
|
||||||
let diff = (
|
let diff = (
|
||||||
await git(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: Instance.directory })
|
await git(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: Instance.directory })
|
||||||
).text()
|
).text()
|
||||||
|
|
@ -579,60 +579,51 @@ export namespace File {
|
||||||
context: Infinity,
|
context: Infinity,
|
||||||
ignoreWhitespace: true,
|
ignoreWhitespace: true,
|
||||||
})
|
})
|
||||||
return {
|
return { type: "text", content, patch, diff: formatPatch(patch) }
|
||||||
type: "text",
|
|
||||||
content,
|
|
||||||
patch,
|
|
||||||
diff: formatPatch(patch),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
return { type: "text", content }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return { type: "text", content }
|
return { type: "text" as const, content }
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const list = Effect.fn("File.list")(function* (dir?: string) {
|
const list = Effect.fn("File.list")(function* (dir?: string) {
|
||||||
return yield* Effect.promise(async () => {
|
const exclude = [".git", ".DS_Store"]
|
||||||
const exclude = [".git", ".DS_Store"]
|
let ignored = (_: string) => false
|
||||||
let ignored = (_: string) => false
|
if (Instance.project.vcs === "git") {
|
||||||
if (Instance.project.vcs === "git") {
|
const ig = ignore()
|
||||||
const ig = ignore()
|
const gitignore = path.join(Instance.project.worktree, ".gitignore")
|
||||||
const gitignore = path.join(Instance.project.worktree, ".gitignore")
|
const gitignoreText = yield* appFs.readFileString(gitignore).pipe(Effect.catch(() => Effect.succeed("")))
|
||||||
if (await Filesystem.exists(gitignore)) {
|
if (gitignoreText) ig.add(gitignoreText)
|
||||||
ig.add(await Filesystem.readText(gitignore))
|
const ignoreFile = path.join(Instance.project.worktree, ".ignore")
|
||||||
}
|
const ignoreText = yield* appFs.readFileString(ignoreFile).pipe(Effect.catch(() => Effect.succeed("")))
|
||||||
const ignoreFile = path.join(Instance.project.worktree, ".ignore")
|
if (ignoreText) ig.add(ignoreText)
|
||||||
if (await Filesystem.exists(ignoreFile)) {
|
ignored = ig.ignores.bind(ig)
|
||||||
ig.add(await Filesystem.readText(ignoreFile))
|
}
|
||||||
}
|
|
||||||
ignored = ig.ignores.bind(ig)
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolved = dir ? path.join(Instance.directory, dir) : Instance.directory
|
const resolved = dir ? path.join(Instance.directory, dir) : Instance.directory
|
||||||
if (!Instance.containsPath(resolved)) {
|
if (!Instance.containsPath(resolved)) throw new Error("Access denied: path escapes project directory")
|
||||||
throw new Error("Access denied: path escapes project directory")
|
|
||||||
}
|
|
||||||
|
|
||||||
const nodes: File.Node[] = []
|
const entries = yield* appFs.readDirectoryEntries(resolved).pipe(Effect.orElseSucceed(() => []))
|
||||||
for (const entry of await fs.promises.readdir(resolved, { withFileTypes: true }).catch(() => [])) {
|
|
||||||
if (exclude.includes(entry.name)) continue
|
|
||||||
const absolute = path.join(resolved, entry.name)
|
|
||||||
const file = path.relative(Instance.directory, absolute)
|
|
||||||
const type = entry.isDirectory() ? "directory" : "file"
|
|
||||||
nodes.push({
|
|
||||||
name: entry.name,
|
|
||||||
path: file,
|
|
||||||
absolute,
|
|
||||||
type,
|
|
||||||
ignored: ignored(type === "directory" ? file + "/" : file),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return nodes.sort((a, b) => {
|
const nodes: File.Node[] = []
|
||||||
if (a.type !== b.type) return a.type === "directory" ? -1 : 1
|
for (const entry of entries) {
|
||||||
return a.name.localeCompare(b.name)
|
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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -676,7 +667,9 @@ export namespace File {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const { runPromise } = makeRuntime(Service, layer)
|
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
|
||||||
|
|
||||||
|
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||||
|
|
||||||
export function init() {
|
export function init() {
|
||||||
return runPromise((svc) => svc.init())
|
return runPromise((svc) => svc.init())
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { NodeFileSystem } from "@effect/platform-node"
|
import { NodeFileSystem } from "@effect/platform-node"
|
||||||
import { dirname, join, relative, resolve as pathResolve } from "path"
|
import { dirname, join, relative, resolve as pathResolve } from "path"
|
||||||
import { realpathSync } from "fs"
|
import { realpathSync } from "fs"
|
||||||
|
import * as NFS from "fs/promises"
|
||||||
import { lookup } from "mime-types"
|
import { lookup } from "mime-types"
|
||||||
import { Effect, FileSystem, Layer, Schema, ServiceMap } from "effect"
|
import { Effect, FileSystem, Layer, Schema, ServiceMap } from "effect"
|
||||||
import type { PlatformError } from "effect/PlatformError"
|
import type { PlatformError } from "effect/PlatformError"
|
||||||
|
|
@ -14,13 +15,20 @@ export namespace AppFileSystem {
|
||||||
|
|
||||||
export type Error = PlatformError | FileSystemError
|
export type Error = PlatformError | FileSystemError
|
||||||
|
|
||||||
|
export interface DirEntry {
|
||||||
|
readonly name: string
|
||||||
|
readonly type: "file" | "directory" | "symlink" | "other"
|
||||||
|
}
|
||||||
|
|
||||||
export interface Interface extends FileSystem.FileSystem {
|
export interface Interface extends FileSystem.FileSystem {
|
||||||
readonly isDir: (path: string) => Effect.Effect<boolean, Error>
|
readonly isDir: (path: string) => Effect.Effect<boolean>
|
||||||
readonly isFile: (path: string) => Effect.Effect<boolean, Error>
|
readonly isFile: (path: string) => Effect.Effect<boolean>
|
||||||
|
readonly existsSafe: (path: string) => Effect.Effect<boolean>
|
||||||
readonly readJson: (path: string) => Effect.Effect<unknown, Error>
|
readonly readJson: (path: string) => Effect.Effect<unknown, Error>
|
||||||
readonly writeJson: (path: string, data: unknown, mode?: number) => Effect.Effect<void, Error>
|
readonly writeJson: (path: string, data: unknown, mode?: number) => Effect.Effect<void, Error>
|
||||||
readonly ensureDir: (path: string) => Effect.Effect<void, Error>
|
readonly ensureDir: (path: string) => Effect.Effect<void, Error>
|
||||||
readonly writeWithDirs: (path: string, content: string | Uint8Array, mode?: number) => Effect.Effect<void, Error>
|
readonly writeWithDirs: (path: string, content: string | Uint8Array, mode?: number) => Effect.Effect<void, Error>
|
||||||
|
readonly readDirectoryEntries: (path: string) => Effect.Effect<DirEntry[], Error>
|
||||||
readonly findUp: (target: string, start: string, stop?: string) => Effect.Effect<string[], Error>
|
readonly findUp: (target: string, start: string, stop?: string) => Effect.Effect<string[], Error>
|
||||||
readonly up: (options: { targets: string[]; start: string; stop?: string }) => Effect.Effect<string[], Error>
|
readonly up: (options: { targets: string[]; start: string; stop?: string }) => Effect.Effect<string[], Error>
|
||||||
readonly globUp: (pattern: string, start: string, stop?: string) => Effect.Effect<string[], Error>
|
readonly globUp: (pattern: string, start: string, stop?: string) => Effect.Effect<string[], Error>
|
||||||
|
|
@ -35,6 +43,10 @@ export namespace AppFileSystem {
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* FileSystem.FileSystem
|
const fs = yield* FileSystem.FileSystem
|
||||||
|
|
||||||
|
const existsSafe = Effect.fn("FileSystem.existsSafe")(function* (path: string) {
|
||||||
|
return yield* fs.exists(path).pipe(Effect.orElseSucceed(() => false))
|
||||||
|
})
|
||||||
|
|
||||||
const isDir = Effect.fn("FileSystem.isDir")(function* (path: string) {
|
const isDir = Effect.fn("FileSystem.isDir")(function* (path: string) {
|
||||||
const info = yield* fs.stat(path).pipe(Effect.catch(() => Effect.void))
|
const info = yield* fs.stat(path).pipe(Effect.catch(() => Effect.void))
|
||||||
return info?.type === "Directory"
|
return info?.type === "Directory"
|
||||||
|
|
@ -45,6 +57,19 @@ export namespace AppFileSystem {
|
||||||
return info?.type === "File"
|
return info?.type === "File"
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const readDirectoryEntries = Effect.fn("FileSystem.readDirectoryEntries")(function* (dirPath: string) {
|
||||||
|
return yield* Effect.tryPromise({
|
||||||
|
try: async () => {
|
||||||
|
const entries = await NFS.readdir(dirPath, { withFileTypes: true })
|
||||||
|
return entries.map((e): DirEntry => ({
|
||||||
|
name: e.name,
|
||||||
|
type: e.isDirectory() ? "directory" : e.isSymbolicLink() ? "symlink" : e.isFile() ? "file" : "other",
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
catch: (cause) => new FileSystemError({ method: "readDirectoryEntries", cause }),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
const readJson = Effect.fn("FileSystem.readJson")(function* (path: string) {
|
const readJson = Effect.fn("FileSystem.readJson")(function* (path: string) {
|
||||||
const text = yield* fs.readFileString(path)
|
const text = yield* fs.readFileString(path)
|
||||||
return JSON.parse(text)
|
return JSON.parse(text)
|
||||||
|
|
@ -135,8 +160,10 @@ export namespace AppFileSystem {
|
||||||
|
|
||||||
return Service.of({
|
return Service.of({
|
||||||
...fs,
|
...fs,
|
||||||
|
existsSafe,
|
||||||
isDir,
|
isDir,
|
||||||
isFile,
|
isFile,
|
||||||
|
readDirectoryEntries,
|
||||||
readJson,
|
readJson,
|
||||||
writeJson,
|
writeJson,
|
||||||
ensureDir,
|
ensureDir,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue